fix(sync): guard fan-out publishedAt against future/null (scheduling leak) - #335
Merged
Merged
Conversation
…leak)
The per-image Post fan-out (fan_out_per_row, trigger_gen.rs) emitted
publishedAt as an unconditional `set` op. For already-ALIVE images the
engine applies it directly, and because the isPublished filter target is
an exists_boolean shadow (a null-check), a future publishedAt — a
*scheduling* event — flipped isPublished=true with a future sortAt,
landing the image at feed top. This leaked ~26.5k slots in prod
2026-07-18. The engine is FROZEN; the old queryOpSet path had an explicit
deferred guard we lost in the fan-out rewrite. Fix is codegen-only.
Both fan-out emission paths now emit publishedAt CONDITIONALLY:
CASE WHEN _p."publishedAt" IS NULL OR _p."publishedAt" > now()
THEN <remove-op> -- flips isPublished shadow FALSE (hidden)
ELSE <set-op> -- only already-past values publish
END
applied to the shared INSERT function (bitdex_post_fanout_ops) and to the
UPDATE-branch new-side (the guard applies to the NEW value: a future NEW
produces a remove, not a set; the OLD-side remove is unchanged).
availability/postedToId are untouched. The Image trigger's Mode-B
INSERT-read is untouched (fresh inserts flow through the doc path where
the engine's deferred branch correctly quarantines future values).
Activation of the alive-then-scheduled population now depends on the
overdue sweep (only where the doc kept publishedAt) and PRIMARILY the
W1-3 re-emitter (civitai PR #3231): its lookback catches publishedAt
entering [now-15m, now] at Tf and re-emits a past-value set that flips
the shadow back true. The re-emitter flag being ON is LOAD-BEARING.
Tests: new test_fan_out_publishedat_future_guard pins the conditional
both directions (future/null -> remove, past -> set) in the INSERT
function and the UPDATE new-side, and pins that availability/postedToId
stay unconditional. Parity test still green (embedded == standalone).
Regenerated docs/design/trigger-sql-review.sql.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The per-image Post fan-out (
fan_out_per_row,trigger_gen.rs) emittedpublishedAtas an unconditionalsetop. For already-ALIVE images the engine applies it directly, and because theisPublishedfilter target is anexists_booleanshadow (a null-check), a futurepublishedAt— a scheduling event, not a publish — flippedisPublished=truewith a futuresortAt, landing the image at the top of the feed. This leaked ~26.5k slots in prod 2026-07-18 (blanket-remove mitigation already applied). The oldqueryOpSetfan-out path had an explicit deferred guard that was lost in the per-row rewrite.The engine is FROZEN — this fix is codegen/config only.
Fix
Both fan-out emission paths now emit
publishedAtconditionally:bitdex_post_fanout_ops(_p "Post")— the guarded element.remove, not aset); the OLD-sideremoveis unchanged, preserving OLD/NEW diff semantics.removeis correct for both unpublish (NULL) and scheduling (FUTURE) — it flips the shadow false = image hidden.availability/postedToIdare untouched.Once guarded, an alive image scheduled for the future stays hidden until its
publishedAtbecomes past. It is then re-activated by:publishedAt, andpublishedAtentering[now-15m, now]at Tf and re-emits a past-valuesetthat flips the shadow back true.The re-emitter flag being ON is now load-bearing. Without it, this population never activates.
Deploy note (hand-apply)
The trigger name is content-hashed and changed
bitdex_post_8511462a→bitdex_post_54f0a619.run_setup_v2reconciles automatically (drops anybitdex_%trigger not in the new config), sobitdex-sync setupis the safe path. A literal hand-apply of only the new SQL would leave the old trigger still attached and firing the unconditional set — a hand-apply MUST also:Tests
test_fan_out_publishedat_future_guard: pins the conditional both directions (future/null → remove, past → set) in the INSERT function AND the UPDATE new-side; pins thatavailability/postedToIdstay unconditional.docs/design/trigger-sql-review.sql(diff touches onlypublishedAt+ the expected hash rename).cargo test --features server,pg-sync --lib pg_sync::trigger_gen: 30 passed. Full lib suite green except two pre-existing, unrelated Windows env flakes (query_stream_full_channel_drops_oldest,test_flush_thread_appends_ops_to_shard_stores).🤖 Generated with Claude Code