Skip to content

fix(sync): guard fan-out publishedAt against future/null (scheduling leak) - #335

Merged
JustMaier merged 1 commit into
mainfrom
fix/fanout-future-publishedat-guard
Jul 18, 2026
Merged

fix(sync): guard fan-out publishedAt against future/null (scheduling leak)#335
JustMaier merged 1 commit into
mainfrom
fix/fanout-future-publishedat-guard

Conversation

@JustMaier

Copy link
Copy Markdown
Contributor

Problem

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, not a publish — flipped isPublished=true with a future sortAt, 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 old queryOpSet fan-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 publishedAt conditionally:

CASE WHEN _p."publishedAt" IS NULL OR _p."publishedAt" > now()
  THEN jsonb_build_object('op','remove',...)   -- flips isPublished shadow FALSE (hidden)
  ELSE jsonb_build_object('op','set',...)       -- only already-past values publish
END
  • Shared INSERT function bitdex_post_fanout_ops(_p "Post") — the guarded element.
  • 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, preserving OLD/NEW diff semantics.
  • A remove is correct for both unpublish (NULL) and scheduling (FUTURE) — it flips the shadow false = image hidden.
  • availability / postedToId are untouched.
  • The Image trigger's Mode-B INSERT-read is NOT touched — fresh inserts flow through the doc path where the engine's deferred branch correctly quarantines future values.

⚠️ Activation of the alive-then-scheduled population — re-emitter flag is LOAD-BEARING

Once guarded, an alive image scheduled for the future stays hidden until its publishedAt becomes past. It is then re-activated by:

  1. the overdue sweep — only where the doc retained publishedAt, and
  2. PRIMARILY the W1-3 re-emitter (civitai PR #3231) — its lookback window 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 now load-bearing. Without it, this population never activates.

Deploy note (hand-apply)

The trigger name is content-hashed and changed bitdex_post_8511462abitdex_post_54f0a619. run_setup_v2 reconciles automatically (drops any bitdex_% trigger not in the new config), so bitdex-sync setup is 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:

DROP TRIGGER IF EXISTS bitdex_post_8511462a ON "Post";
DROP FUNCTION IF EXISTS bitdex_post_ops_8511462a();

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; pins that availability/postedToId stay unconditional.
  • Parity test still green (embedded function == standalone generator — single builder intact).
  • Regenerated docs/design/trigger-sql-review.sql (diff touches only publishedAt + 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

…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>
@JustMaier
JustMaier merged commit 219fc5e into main Jul 18, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant