feat(merge-users): dry-run/run framework, handler registry and coverage manifest (#17494) - #17535
Draft
Ludovic Bouges (ludovic) wants to merge 7 commits into
Draft
feat(merge-users): dry-run/run framework, handler registry and coverage manifest (#17494)#17535Ludovic Bouges (ludovic) wants to merge 7 commits into
Ludovic Bouges (ludovic) wants to merge 7 commits into
Conversation
The engine cannot name what no handler covers unless it holds the full list independently of the handlers. Without it a report can only show what is done, never what is missing — and the blind spots go invisible exactly when they are most numerous, at MVP delivery. The register is the questions, the handlers are the answers. This list is fixed: it describes the existing codebase, not what the merge can do. It does not grow chunk by chunk; what grows is the set of rows claimed by a handler. 100 rows transcribed from register v2: transfer 39, invalidate 22, conditional 21, retain 12, out-of-scope 6. Row ids are stable and are what handlers declare coverage against. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Deviation from the SanityOperation precedent, deliberate: a handler does not expose dryRun() and run(). It exposes one read-only compute() and one write-only apply(), and the engine derives both modes — dry is compute, real is compute then apply. The precedent exposes both methods and relies on each implementation happening to call the same helper. That is a convention, and conventions drift the day someone fixes a bug in one branch and forgets the other. With a single selection function a handler has no second code path to drift into, so "dry-run == real impact" is structural rather than declarative. Registration validates at import time, so a mistake surfaces when the platform boots rather than when a merge is launched: - coverage on a register row that does not exist - a handler written against an older register version, since a row can be requalified without its id changing (v1 to v2 requalified 12 rows that way) - two handlers claiming the same row - read/write disjointness across handlers Index scope is defined once at this level: platform indices plus the trash, which is restorable and would otherwise re-inject source ids into live data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
None of the platform's existing bulk paths is usable for a merge, and this is what the coverage manifest's real counts depend on. elOperationForMigration polls the task until completed and logs the duration, but never inspects failures or version_conflicts, and returns nothing: a task that updates 3 000 documents out of 10 000 and conflicts on the rest is reported as a success. The other paths run with conflicts: 'proceed' and silently skip conflicting documents; one is fire-and-forget with no task follow-up. This wrapper surfaces updated, total, failures and version_conflicts, and throws on any non-empty failure or conflict. The platform is supposed to be at rest during a merge, so a conflict means the execution precondition was violated — precisely when to stop rather than carry on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An interrupted merge must stay diagnosable. On a batch, a failure on the 57th user leaves 56 completed merges, one partial and the rest untouched, and without a journal nothing in the database says so. The entry is opened before the handler runs, so a process killed mid-handler still names where it stopped, and a handler that throws leaves a FAILED entry rather than one stuck in RUNNING. The journal is excluded from the handlers' own scope: it carries a creator_id in a live index, and later chunks rewrite exactly that field, so a merge would otherwise rewrite its own trace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every handler computes first, the complete report is produced, and only then does any handler write. Interleaving -- compute A, write A, compute B -- would let B observe what A wrote, so B's dry figure and B's real figure would describe different platform states and dry-run == real impact would stop holding at the second handler. Before writing, each handler recomputes and the plan fingerprint is compared with what the dry pass reported. The platform is required to be at rest during a merge, so a divergence is not a race to retry: it means the premise of the operation is false, and writing anyway would apply changes the operator never reviewed. A failure returns a FAILED result rather than throwing, because what was and was not applied is only readable from the journal, and that needs the merge id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The manifest is derived from the register rather than from the handlers, so an uncovered row is named instead of being absent. Built from the handlers alone, a report can only show what was done: with a partial handler set -- the state of every intermediate build -- it would look complete while leaving most of the register untouched. The coverage is attached to every execution report, not offered as a separate opt-in query only, because three handlers succeeding reads as a complete merge unless the report also says what the register still holds. is_complete is what a later chunk reads to decide whether deleting the source account is legitimate; a filtered view deliberately keeps its counts on the whole register so it cannot claim completeness by narrowing the question. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## issue/17493 #17535 +/- ##
===============================================
- Coverage 34.39% 34.18% -0.22%
===============================================
Files 3381 3386 +5
Lines 138611 137814 -797
Branches 37741 37256 -485
===============================================
- Hits 47676 47109 -567
+ Misses 90935 90705 -230
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The journal was an indexed internal object. Measured, that costs ~15 fields on a mapping shared by every index and already at 2312/3000, permanently -- fields cannot be removed without a reindex -- for a feature meant to run once. It does not need to be indexed: what authorizes deleting the source account is the coverage manifest, derived from the register and the registered handlers. The journal is diagnostic, not evidential. It now follows the playbook-execution precedent: a key per entry with a 30 day TTL, indexed in a sorted set globally and per merge id. This also removes a self-reference. As an indexed entity the journal carried a creator_id in a live index -- the very field later chunks rewrite -- which forced an explicit exclusion of its own entity type from the handlers' scope. That exclusion is gone with it. The GraphQL contract is unchanged, and reads are now immediate rather than refresh-dependent, which is what the follow-up query needs during a run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
5 tasks
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.
Proposed changes
MergeHandlercontract. A handler exposescompute()(reads only) andapply()(writes only); the engine derives both execution modes from them, so dry-run iscomputeand the real run iscomputethenapply. A handler has no second code path that could drift from the first.FAILEDresult carrying the merge id rather than throwing, because what was applied is only readable from the journal.is_completeis true only when every row is claimed. A disposition filter narrows the returned rows but never the counts. Exposed asuserMergeCoverageand attached to every execution report.conflicts: 'abort',wait_for_completion: true,refresh: true, throwing whenfailuresis non-empty orversion_conflictsis greater than zero, and returning theupdatedcount.FAILEDentry. Stored in Redis with a 30 day TTL, keyed per entry and indexed in a sorted set globally and per merge id, following the existing playbook-execution pattern.No handler is registered in this chunk, so a merge still performs no write. Handlers land from #17495 onward.
Related issues
How to test this PR
Stacked on #17507, so review the last 7 commits only. Enable the flag and start the platform:
With no handler registered, the coverage manifest must list all 100 register rows as uncovered, by name:
Filtering by disposition must narrow the returned rows while leaving the counts on the whole register:
Run a dry merge and read the journal back with the returned id:
The journal entries live in Redis and survive a platform restart for 30 days.
Automated coverage:
Checklist
Further comments
48 unit tests and 21 integration tests. Notable cases: with zero handler registered the manifest lists all 100 register rows as uncovered by name; a filtered manifest keeps its counts on the whole register; a real pass whose recomputation diverges from the dry pass writes nothing; per-disposition register counts are asserted as literals rather than recomputed from the array.
Commit by commit:
fc4278dec8fa927bMergeHandlercontract and handler registry5e539e7668b90751422eeca960558cc663ccabcbThe precedent in the platform,
SanityOperation, exposesdryRun()andrun()as two methods each implementation writes, relying on both happening to call the same helper. That is a convention, and conventions drift the day someone fixes a bug in one branch and forgets the other. Here there is a single selection function, so "dry-run equals real impact" cannot drift.