Skip to content

feat: add a script that removes null-channel duplicate activity relations - #5267

Open
themarolt wants to merge 23 commits into
mainfrom
feat/issue-852-null-channel-cleanup
Open

themarolt wants to merge 23 commits into
mainfrom
feat/issue-852-null-channel-cleanup

Conversation

@themarolt

Copy link
Copy Markdown
Contributor

Summary

Adds a hand-run script that removes the NULL-channel duplicates in activityRelations. Before #850, every re-send of an activity without a channel inserted a new row, because NULLs are distinct in the dedup index. The script keeps one copy per key and moves the others to a dated backup table. The copy it keeps is the one data-sink-worker already reads: newest by updatedAt, createdAt, activityId, the same order as findActivityRelationsByDedupKeys. It runs in short transactions that can be stopped and resumed.

Changes

  • collect walks the dedup index in time windows. It records every key with more than one NULL-channel copy for the chosen platforms in a work table. Windows halve on a statement timeout, days without rows are skipped, and it resumes from the last finished window.
  • clean takes whole keys, up to --batch-rows rows per transaction:
    • It locks the copies FOR UPDATE NOWAIT, so it never waits on app transactions.
    • It ranks only the copies it locked and keeps rank 1.
    • It copies parentId, conversationId and sourceParentId onto the kept copy when that copy has none.
    • It moves the kept copy's updatedAt at least 2 ms past every deleted copy's, so CDC and Tinybird see it as newest.
    • It deletes the other copies into the backup table, checks in the same transaction that every key kept exactly one locked copy and that every deleted row was backed up, and rolls back otherwise.
  • Guards:
    • waits on Sequin replication-slot lag, and stops if a slot is missing;
    • won't start during the nightly meetings export window (00:30–05:30 UTC);
    • one run per tag (advisory lock);
    • --max-rows / --until required for a real run;
    • retries lock, deadlock and timeout errors, and splits a batch on timeout;
    • keys that keep failing are deferred and can be retried later with --retry-deferred.
  • collect --rescan scans from the oldest row again after clean. It re-opens keys that got duplicates in the meantime, and a second clean handles them.
  • Observability: JSON log events per batch and per window, periodic progress with ETA and backup size, a per-batch metrics table, and status for progress at any time. Exit codes tell a wrapper whether to restart (2 stopped, 4 connection lost) or stop and look (1).
  • The script uses a raw pinned pg-promise session instead of queryExecutor, on purpose. The advisory run lock, the session temp table of locked ids and the set local timeouts all have to live on one connection for the whole run.
  • Size: about 1900 lines, all in one new file. The script only makes sense whole, since collect without clean does nothing in prod. It reads best commit by commit: collect, clean, guards, then the review fixes.
  • Tested locally against synthetic data with concurrent writers that imitate data-sink-worker, affiliation refresh, member merge with cascade delete, channel updates and new copies. It was also killed mid-run, interrupted with SIGINT/SIGTERM, and resumed. No wrong row was deleted, no key was left empty and no backup row was lost.
  • Depends on fix: keep activity relation batch moves going when rows disappear concurrently #5258, the batch-loop fix in member/organization merge and affiliation refresh, which is already deployed.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Performance improvement
  • Chore / dependency update
  • Documentation

Related issue

Refs linuxfoundation/lfx-cdp-insights-ops#852

First part of the #852 cleanup script: creates its dated backup, work and
progress tables and walks the dedup index in time windows, recording every
key with more than one NULL-channel copy. Resumes from the last window.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
Adds the clean command: pending keys are taken in whole-key batches; each
transaction locks the key's NULL-channel copies, keeps the copy data-sink-worker
reads, carries parent and conversation ids over to it, moves its updatedAt past
every deleted copy and moves the other copies to the dated backup table.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
…nightly export

clean waits while a Sequin replication slot lags past --max-slot-lag-mb,
refuses to run during the nightly meetings export unless --allow-nightly,
needs --max-rows or --until, and stops cleanly after the current batch on
Ctrl-C or SIGTERM. A batch whose rows are locked is retried.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
…bservable

Review fixes: rank and delete only the copies the batch locked (NOWAIT, in
activityId order), recheck the NULL channel on update and delete, and roll the
batch back unless every key keeps exactly one copy and every deleted row is
in the backup. The kept row's updatedAt only moves past the deleted copies at
millisecond precision, never back and never to the clock. Keys come from the
work table by id; lock, deadlock and timeout failures retry with backoff and
then defer the keys. Slot lag fails closed, stop conditions are rechecked
after waits, flags are validated, real runs ask for confirmation and hold a
per-tag advisory lock. Every batch records its metrics in a batches table and
logs a structured batch.done line; runs log progress with remaining rows, ETA
and backup size, and a summary with an exit code.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
…plicated keys

Final review fixes: collect jumps over empty years and only moves its progress
forward (never on an explicit --to); re-collecting re-opens finished keys that
have duplicates again. clean gives the lock step its own 60 s timeout, splits a
batch that times out instead of retrying it whole, can pause through the
nightly export (--wait-nightly, --until up to 7 days), waits up to 2 minutes
for the run lock and names its holder, exits 4 on a lost connection, logs
batch.start, and records batch metrics inside the batch transaction. The kept
row moves 2 ms past the deleted copies. status shows the active run and
percent done.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
Day rounding used ms - ms % DAY, which rounds negative epochs up, so collect
skipped the first day with rows on every window before 1970 (prod rows go back to
year 0026). Also: a split batch stopped by the nightly window no longer leaves keys
behind the cursor, Ctrl-C at the confirmation prompt aborts, a lost replication
slot exits 1 instead of the usage code, a re-opened key keeps its deleted count,
--help prints the full usage, and pg errors are typed instead of any.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
…in the null-channel cleanup

The lock step now writes the locked ids into a session temp table (on commit delete
rows) instead of returning them as a 20k-element array param, so the shared pg-promise
error hook no longer logs ~800 KB per retried batch. A connection lost mid-run that
reaches @crowd/database's bare process.exit() now exits 4 (restartable) instead of 1.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
…l-channel cleanup

collect --rescan scans from the oldest row again: without it a final sweep with the same
tag stopped at collect.uptodate and re-opened nothing. The clean plan takes the kept row's
columns from window functions instead of joining the ranked CTE back to itself, which the
planner ran as a nested loop over the whole batch per kept row (7x faster on a 20k-row
batch). Also: the rate and ETA leave out slot and nightly waits, keys without duplicates
count keys with no locked rows, the WAL metric is named serverWalBytes since it is
server-wide, and a run lock still held after the wait exits 2 so a restart loop retries.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
The stored collect end was read with a plain select, which returned no row after a
collect that never finished a window or only ran with --to, so every later collect
failed with "No data returned from the query". It now reads max(windowEnd), which is
null for an empty table. The run-lock holder lookup is also limited to the current
database.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI balanced review requested due to automatic review settings November 6, 2026 19:02
@cursor

cursor Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Bulk deletes/updates on activityRelations with backup moves; mis-runs could corrupt analytics or lose rows despite guards. Requires careful operational review and staging validation.

Overview
Adds a hand-run maintenance tool for legacy NULL-channel duplicates on activityRelations: collect walks time windows and fills per-tag work/progress/batch tables; clean runs bounded transactions that FOR UPDATE NOWAIT lock copies, keep the same row data-sink would (rank by updatedAt, createdAt, activityId), merge missing parent/thread fields onto the survivor, bump updatedAt for CDC/Tinybird, delete extras into a dated backup table with invariant checks, and supports retries, --max-rows/--until, Sequin slot lag, nightly window, and deferred keys; status reports progress.

Wires pnpm run cleanup-null-channel-duplicates in script_executor_worker to src/bin/cleanup-null-channel-duplicates.ts.

Reviewed by Cursor Bugbot for commit 385e57b. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[High impact] The PR appears safe to merge; no new actionable issues were found.

Summary

Adds a resumable script to collect and remove NULL-channel duplicates from activityRelations, with backups, limits, retries, and progress reports.

  • Clean keeps one activity row and backs up the rest.
  • Collect scans duplicate activity keys a window at a time.
  • Operators can launch the cleanup with a tagged, bounded command.

Reviews (12) · Last reviewed commit: "fix: stop a null-channel collect whose p..." · Reviewed by Greptile

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cleanup retries and split batches can bypass stop, time, and replication-lag safeguards, while batch deletion can exceed the configured maximum row limit.

No open issues 🎉

What changed in this PR

Adds a hand-run maintenance script to remove NULL-channel duplicates from activityRelations, retaining the copy selected by data-sink-worker and backing up deleted rows. It builds on the concurrent batch-move fix in #5258.

Changes:

  • Adds resumable key collection, transactional cleanup, and status reporting.
  • Preserves relationship fields and adjusts survivor timestamps for downstream replication, with safety checks, retries, and operational guards.
  • Registers a package command for running the script.
File Description
services/​apps/​script_executor_worker/​src/​bin/​cleanup-null-channel-duplicates.ts Implements collection, cleanup, backups, guards, and progress reporting.
services/​apps/​script_executor_worker/​package.json Registers the cleanup script command.

Review effort: Balanced

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

…ap scans

Every batch attempt, retry and split half now re-checks the stop conditions (signal,
--until, --max-rows, nightly window) and waits on replication-slot lag first, so a retry
delay that crosses a deadline no longer starts more work. --max-rows trims each batch to
what is left (whole keys only). A run ends with exit 3 while deferred keys from any run
remain in its scope. A collect whose --from starts past the scanned range no longer
records progress, so the skipped days are still scanned later.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI balanced review requested due to automatic review settings November 6, 2026 19:19
@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · summary findings

  • Retries and split batches bypass stop, time and replication-lag guards
    Fixed in 7e81c3f. Every batch attempt, retry and split half now re-checks the stop conditions and waits on slot lag before it starts (details in the Greptile thread on processKeys).
  • Deletion can exceed the configured maximum row limit
    Fixed in 7e81c3f. Batches are trimmed to the remaining --max-rows allowance. Only a single key can still go over, because keys are never split, and the help text says so.

USAGE already carries the description and the issue reference.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved checkpoint and rescan-resume defects, incorrect nightly-stop exit handling, and ineffective dry-run sample limits can block valid cleanup runs and exceed intended query bounds.

No open issues 🎉

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 6, 2026 19:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The cleanup can exceed its deletion limit, fail to resume interrupted rescans, and incorrectly reject collections completed in separate platform runs.

No open issues 🎉

Review effort: Balanced

…r-platform collects resume

--max-rows is now a ceiling: a key that doesn't fit what is left isn't started, and a
batch that would still go over (copies added since collect) rolls back and the run stops
with exit 2. An interrupted --rescan continues from its own progress row, removed when the
rescan finishes. Clean counts collect coverage per platform, so collects done in separate
platform runs are accepted. Not starting inside the nightly window exits 2 like stopping
there does. Collect windows halve down to an hour, so one dense day can't block a collect.
Dry runs count every batch tried towards --sample-batches.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI balanced review requested due to automatic review settings November 6, 2026 19:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because skipped keeper timestamp updates can let replicated DELETE before-images replace keeper metadata and propagate deleted-copy metadata into daily snapshots.

No open issues 🎉

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 6, 2026 20:08
@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · summary findings

  • Skipped kept-row timestamp updates could let replicated DELETE before-images replace the kept row's data
    Not changed. The script only skips updating the kept row when the kept row is already strictly newer, so a DELETE before-image can't win in either Tinybird layer:
    • When the kept row is left alone. That only happens when kept.updatedAt >= date_trunc('ms', max(dropped.updatedAt)) + 2 ms, so it is strictly newer at the millisecond precision Tinybird stores.
    • Raw layer. A DELETE before-image comes in through $.record.* as a row with the dropped copy's own updatedAt. activityRelations is a ReplacingMergeTree on the dedup key with ENGINE_VER updatedAt, so the kept version wins.
    • Served layer. The MV snapshotId is the day of the row's updatedAt, plus one. A dropped before-image therefore never has a later snapshotId than the kept row. When both fall in the merge window, LIMIT 1 BY ... ORDER BY updatedAt DESC picks the kept row. When the kept row's day was merged earlier, the before-image's day is no later, so it is outside the window as well.
    • Kept rows the script updates get now() (3d18994), so they win everywhere.
    • Copies with a different createdAt year sit in other partitions, which ReplacingMergeTree doesn't merge. They are already separate rows in Tinybird today, and are a separate cleanup item on linuxfoundation/lfx-cdp-insights-ops#852.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because the published review omits a moderate-severity concern about platform-filtered status reporting.

No open issues 🎉

Review effort: Balanced

…atus

status always reported every platform of the tag, broken down per platform, so the
accepted --platforms only suggested a filter that didn't happen. It is now rejected as an
unknown option.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI balanced review requested due to automatic review settings November 6, 2026 20:15
@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · summary findings

  • Platform-filtered status reporting
    Fixed in bdc48cf. status accepted --platforms but ignored it: it always reported every platform of the tag, broken down per platform. The option is removed from status, so passing it now fails as an unknown option instead of suggesting a filter that didn't happen. The usage text says status covers every platform.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Deleting roughly 100M rows with live writers requires DBA and data-platform validation of query plans, locking, CDC convergence, and recovery beyond the reported synthetic local tests.

No open issues 🎉

Review effort: Balanced

@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · summary findings

  • A delete of this size with live writers needs DBA and data-platform validation beyond synthetic local tests
    Agreed. No code change; this is the rollout plan, tracked on linuxfoundation/lfx-cdp-insights-ops#852.
    • Production checks already done, through read-only sessions only:
      • plain EXPLAIN of the lock, clean and verify statements with real keys;
      • catalog, size and replication-slot checks;
      • collect --dry-run over sample days, to size the work (about 120–180M rows, collect about 24 h, clean 45–60 h of active time, WAL 80–120 GB).
    • Before production: a staging run of collect, clean, rescan and status. Then the first production runs are kept short with --max-rows, with data-sink-worker stopped, while the operator watches lock waits, Sequin slot lag, Kafka lag and Tinybird lag by hand.
    • Recovery: every deleted row is in the dated backup table with its kept activityId, so any batch can be restored. A manual VACUUM and the parentId repoint from the backup run after the final collect --rescan sweep.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Date validation accepts nonexistent calendar dates and silently shifts collection boundaries instead of reporting a usage error for invalid --from or --to values.

No open issues 🎉

Review effort: Balanced

… and --to

Date rolled 2025-02-30 over to 2025-03-02, so a typo silently moved the collect range. A
date now has to come back unchanged from parsing, or the run stops with a usage error.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · summary findings

  • Date validation accepts nonexistent calendar dates
    Fixed in the latest commit on this branch. JavaScript's Date rolled 2025-02-30 over to 2025-03-02, so a typo moved the collect range without any error. --from and --to now have to parse back to the same YYYY-MM-DD, or the run stops with a usage error (exit 64). Checked: 2025-02-30 is rejected, while 2024-02-29 and 0026-03-12 are accepted.

Copilot AI balanced review requested due to automatic review settings November 6, 2026 20:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed to evaluate unexpectedly large dry-run workloads and shutdown behavior that can leave database sessions and advisory locks held while awaiting prompt input.

No open issues 🎉

Review effort: Balanced

…ompt

A run waiting for the operator's answer held the run lock and its session, and SIGTERM or
SIGHUP didn't end the prompt. The lock is now taken after the confirmation, and the prompt
closes on any stop signal. The help text also says that collect --dry-run scans the same
range as a real collect.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · summary findings

  • Sessions and advisory locks held while waiting for prompt input
    Fixed in 0265d35. The run lock is taken after the confirmation prompt, so a run waiting for an answer holds no lock. SIGTERM and SIGHUP now close the prompt, as Ctrl-C already did. Local tests: status showed no active run while the prompt was waiting, and a SIGTERM at the prompt exited in 0.2 s with "Aborted by the operator". A confirmed run still takes the lock, and a second run waits for it.
  • Unexpectedly large dry-run workloads
    clean --dry-run is bounded by --sample-batches per platform, and every batch tried counts. collect --dry-run scans the same range as a real collect, because it reports exactly what a collect would find, which is hours on production. The help text now says so and points to --from/--to for a sample.

Copilot AI balanced review requested due to automatic review settings November 6, 2026 21:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Deleting ~100M live rows requires DBA/data-pipeline validation of concurrent-write locking and Sequin-to-Tinybird delete ordering that source review and synthetic tests cannot establish.

No open issues 🎉

Review effort: Balanced

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0265d35. Configure here.

@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · summary findings

  • Concurrent-write locking and Sequin-to-Tinybird delete ordering need DBA and data-pipeline validation
    No code change; both points are covered in earlier replies:
    • Locking is covered in the NOWAIT reply. App writes wait at most for one batch. FK waits are capped by lock_timeout and retried, and so are deadlocks.
    • Delete ordering in Tinybird is covered in the DELETE before-image reply. A deleted copy can't win over the kept row whatever order the events arrive in, because the result is decided by updatedAt (ReplacingMergeTree version, and ORDER BY updatedAt DESC in the merger), not by arrival order.
    • Validation on real infrastructure follows the rollout plan: a staging run, then short --max-rows production runs with data-sink-worker stopped while Sequin, Kafka and Tinybird lag are watched, with every deleted row kept in the backup table.

…d at the prompt

Collect plans its range before the confirmation prompt and takes the run lock after it, so
another run could move or finish the progress in between. After the lock it now reads the
progress again and stops with exit 2 when it changed, instead of scanning from a stale
start.

Refs linuxfoundation/lfx-cdp-insights-ops#852

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI balanced review requested due to automatic review settings November 6, 2026 21:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because an unpublished finding identifies orderly interruptions being reported as invalid CLI usage (exit 64) instead of the documented exit 2.

No open issues 🎉

Review effort: Balanced

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.

2 participants