epic-095 iter1: mp-rewrite — KurrentDB rewrite tool (+ MicroPlumberd.Migration, Scripting) - #17
Conversation
…ced ProjectionCopier Offline, raw/untyped, forward-only store rewrite (SOURCE read-only -> FRESH dest): - Migrate(IMigrationBuilder) raw rules (DropStream(s)/DropEvent/RenameType/TransformJson/ RenameStream), mp-migrations history + checksum guard, MigratedAt stamp, --dry-run, dest-empty pre-flight. - Streaming copy in $all order: batch-capped (bytes+count) to stay under MaxAppendSize, unparseable-but-declared-JSON copied VERBATIM (never dropped), original EventId preserved, per-dest-stream write-fidelity hash verification (catches reorder/truncation, not just counts). - ProjectionCopier: pre-creates the app's [OutputStream] join projections on the empty dest (verbatim query via HTTP + mp_query_hash so the app NO-OPs on boot) and PACES the copy (per-event link emission, bounded timeout) so the merge streams rebuild in commit/arrival order with zero backlog. Deterministic (15/15). No MicroPlumberd core change. - Observability: thread-safe status snapshot + progress stream for a migrator UI. - mp-migrate CLI + _0001_DropTombstonedTestOffers + RUNBOOK. Proven on real :5081 data: discovers 22 ERP join projections, drops the 5 tombstoned Offer streams (26 events), every stream verifies OK. 39/39 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ABTs2khmyRWnJVxdQMRhu7
…commit-order merge)
An ALTERNATIVE to the paced ProjectionCopier for the migration merge problem: a
`fromStreams([$et-A,$et-B]).linkTo` join projection type-clusters on catch-up (drains $et-A
then $et-B → 0,2,4,1,3, not commit order). This uses KurrentDB 26.1 USER-DEFINED INDEXES to
read the merge in true $all commit order with no paced-copy machinery.
- UserDefinedIndexSource: create POST /v2/indexes/{name} (idempotent; name normalized to
lowercase alnum/_/- + sha suffix; JS filter escaped), then read via
ReadAllAsync(Forwards, Start, StreamFilter.Prefix("$idx-user-{name}"), resolveLinkTos:true)
→ RawEvents in commit order.
- AUTHORITATIVE readiness: WaitUntilReadyAsync(name, expectedCount, timeout) waits for the
indexed count to reach an EXACT known total (CountMatchingAsync over the frozen dest's $all,
immediately consistent) — NOT a stability heuristic; > expected throws, timeout fails loud.
KurrentDB 26.1 exposes no index checkpoint/position, so count-to-known-total is the only
authoritative signal. The gate counter mirrors the reader's resolveLinkTos so they can't diverge.
- KurrentHttpEndpoint extracted + shared with ProjectionCopier (behaviour unchanged, unregressed).
- MigrationRunner: optional indexCopy, mutually exclusive with projectionCopy; CreatedIndexes.
BOUNDARY (documented at 3 API surfaces + a runtime LogWarning): the index path does NOT rebuild
the physical [OutputStream] merge (no $> links / no mp_query_hash). Consumers MUST be rewired to
read $idx-user-{name}; leaving the app on its own projections REINTRODUCES type-clustering. Use
ProjectionCopyContext when the physical merge stream is required.
Reviewed (2 rounds): authoritative-readiness proving test + 20,000-event large-backfill
no-truncation test; ProjectionCopier 15x determinism gate unregressed. 45/45 integration tests
green against real KurrentDB 26.1.0.3443 (MicroPlumberd.Testing Docker).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tDB 26.1) VERDICT: feasible for event-type/stream merges (fromStreams([$et-A,$et-B]).linkTo), NOT for body-property lookup projections ($ce + e.body.Key routing). Empirical (real KurrentDB 26.1, MicroPlumberd.Testing): - live tail stays commit-ordered (not just initial backfill); - subscribe works ONLY via filtered SubscribeToAll(StreamFilter.Prefix($idx-user-<name>), resolveLinkTos:true) — SubscribeToStream on the index stream silently delivers ZERO events (trap); - checkpoint/resume via $all Position -> FromAll.After; - body/data filters match zero events + no dynamic linkTo -> lookup projections stay on projections. feasibility-index-backed-merge-streams.md = architecture framing (inventory, capability gap, integration design, risk register). LiveIndexSubscriptionSpikes.cs = the empirical spike. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FEASIBILITY (all spikes green, real KurrentDB 26.1): index-backing serves ALL CATCH-UP consumers (client-checkpointed, $all-Position) — shape-A event-type/stream merges AND shape-B per-key lookups (field-partitioned index, one index per category, read via $idx-user-<name>:<value> prefix). ONE permanent exclusion: PERSISTENT-subscription consumers (SPIKE-7 four-way control: persistent sees nothing on $idx-user-*). Direct SubscribeToStream/ReadStream on the index stream both non-functional (SPIKE-9) — consume via filtered $all prefix only. Filter change = delete+recreate+rebuild (in-place 409, DELETE 200, ~3.7s/10k). CaughtUp fires at history->live; live path = subscribe+tail, no count-gate. $ce-category selection expressible (SPIKE-12, for v2). DESIGN (docs/design-index-backed-merge-streams.md, FINAL): opt-in coexistence, projection default; shared SubscriptionRunner via ISubscriptionState (StreamSubscriptionState + IndexSubscriptionState); optimal recipe SubscribeToAll(FromAll.Start, resolveLinkTos:true, StreamFilter.Prefix($idx-user-<name>)); CreateNewAndSwap reconciler + DELETE orphan cleanup; UDI primitives relocate Migration->core; SubscribeToStream/ReadStream guard. Slices S1-S4 = v1 shape-A; S5 = shape-B field-partition lookup (v2). Inbox/Outbox merge=persistent (stays), Lookup=catch-up read (v2 candidate). Spikes: LiveIndexSubscriptionSpikes.cs (SPIKE-1..7), FieldPartitionAndDirectSubscribeSpikes.cs (SPIKE-8..12). Evidence record. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reframe around the confirmed dividing line (catch-up=index-backable both shapes /
persistent=projection-only both shapes). v1 = shape-A opt-in, S1-S4; shape-B moved to a
V2/fast-follow appendix (proven feasible, one field-keyed index per lookup CATEGORY —
KurrentDB fans out per-value, not one-index-per-value). Inbox/Outbox merge=persistent
(stays), {PM}Lookup=catch-up read (v2 candidate). $ce-category filter flagged v2-open.
CaughtUp + no-count-gate closed; optimal recipe + SubscribeToStream/ReadStream guard standard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(SPIKE-12)
$ce-category source is a direct index filter (rec.position.stream.startsWith('<cat>-'),
control-proven), not an enumerated-event-types workaround -> no 'misses a later type'
caveat. Whole design now closed end-to-end; v1 (S1-S4) unchanged. Sole permanent boundary:
persistent-subscription consumers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ewed Opt-in alternative to fromStreams([$et-...]).linkTo join projections for CATCH-UP read models: back a read model's merge with a KurrentDB 26.1 user-defined index consumed via filtered $all, eliminating the type-clustering-on-catch-up problem. Projection-backed stays the DEFAULT; index-backing is per-handler opt-in via AddEventHandler<T>(mergeSource: MergeSource.UserDefinedIndex). Shape-B (field-partition per-key lookup) is v2. - ISubscriptionState seam: SubscriptionRunnerState (stream, byte-identical to before) + IndexSubscriptionState (SubscribeToAll FromAll.Start + resolveLinkTos + StreamFilter.Prefix $idx-user-<name>; checkpoint OriginalPosition; resume FromAll.After) share ONE SubscriptionRunner loop. Loud guard: SubscribeToStream/ReadStream on a $idx-user- name throws (silent-zero footgun). - UserDefinedIndexReconciler: filter-hashed name, FromAll.Start rebuild on event-type-set change, 409 no-op reuse when unchanged, DELETE orphan cleanup after cut-over; RegisterManagedBase collision guard (two output streams normalizing to one managed base throw, never silently delete each other's index). - Registration guards: UserDefinedIndex + persistently/revision-start REJECTED (persistent can't see index links, SPIKE-7). CaughtUp is weaker on the index path (fires + no-loss + commit-order, NOT history-before-CaughtUp) — documented at every opt-in surface. - Index primitives relocated Migration -> core (UserDefinedIndex + KurrentHttpEndpoint); Migration delegates (one impl); StreamMetadata shadowing fully-qualified. 17/17 index-backed integration tests + 6 Migration parity tests green (real KurrentDB 26.1, MicroPlumberd.Testing). Acceptance bar = the projection-vs-index PARITY test (identical final state + delivery order via both paths). Reviewed APPROVE (infra-grade). Deferred follow-ups (dev-log): mid-backfill reorder-window test, NormalizeName doc-charset nit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e test The spike asserts a direct SubscribeToStream on $idx-user-* delivers events; the feasibility study (§7) and design (R1) record that it silently delivers zero on KurrentDB 26.1 and a loud guard exists for it. Post master-merge suite: 58 passed, this one red by construction. Skipped with the reason; Spike2b covers the live path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7cLv6W5QG6qcWcwCHGHP1
…croPlumberd.Migration.Scripting (Jint) — UT-01..07 - IMigrationBuilder.Transform(Func<RawEvent, RawEvent?>) / TransformOp (null, empty StreamId or empty Type drop; EventNumber/EventId/Created on the returned event are ignored) - RawEvent.EventId + Created, populated by CopyEngine and UserDefinedIndexSource - MigrationPlan.AnyTransformRule gates payload parsing for Transform rules - MigrationApplied.Descriptors (optional, RUNBOOK m10) filled by MigrationRunner - Migration.ChecksumOverride extension point; ScriptMigration = sha256(script) - MicroPlumberd.Migration.Scripting: Jint 4.16.1 sandbox (no CLR, LimitRecursion 64, 2 s per event), Replicator transform(original) contract + dropStream/dropEvent/update/updateById/ renameType/renameStream/log helpers; payload snapshot taken as JS renders it so identity transforms do not re-serialise untouched events - MicroPlumberd.Rewrite.Tests: UT-01..07 (21 tests, sequential via xunit.runner.json) Epic-095 iteration 1, milestone 1. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7cLv6W5QG6qcWcwCHGHP1
…, status; E2E-01..12 + UT-08 - RewriteCommand.RunAsync(RewriteOptions, IDockerClient, ILoggerFactory): inspect → guards → scratch store → MigrationRunner copy → Verifier → swap by rename → restart → report; --dry-run, --rollback, --status, --no-projection-copy, --eval/--script, exit codes 0..5 - client guard reads /metrics kurrentdb_current_incoming_grpc_calls (/stats proc.tcp.connections counts legacy TCP only and read 0 with a live gRPC $all subscription); absent metric refuses - data.new.<ts> is written by uid 1001 with 0755 subdirs, so a non-root operator cannot delete it: purge and ownership go through a root helper container from the same image, path double-guarded - KURRENTDB_MEM_DB forced off on the scratch store (inheriting it from an in-memory source would swap an empty store in) - e2e fixture: bind-mounted temp dir, compose-project label, seeded streams, expired stream with a dangling $et-LineAdded link (proved before each scenario), unguarded linkTo projection that reproduces Faulted; 45 tests sequential, 6 m 38 s Epic-095 iteration 1, milestone 2. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7cLv6W5QG6qcWcwCHGHP1
…sets, README; refuse --force-volume-copy in iter1 - .github/workflows/rewrite-e2e.yml: ubuntu-latest, PR→master and feature/** pushes, runs MicroPlumberd.Rewrite.Tests sequentially, 30 min timeout, TRX on failure - publish-nuget.yml: pack MicroPlumberd.Migration, .Migration.Scripting, .Rewrite (dotnet tool); rewrite-binaries job publishes single-file self-contained linux-x64 + linux-arm64 and attaches them to the tag's GitHub release (contents: write, fail_on_unmatched_files) - README: usage, script contract, helper examples, exit codes with the resulting store state, /metrics guard keys with measured values, uid/root-helper note, rollback/status, Limits - --script with --eval decided in one place (RewriteCommand, exit 2) instead of two - --force-volume-copy refused with exit 2 before any docker call: the named-volume swap is not implemented in this version (design §1 / ADR 5); RewriteCommandTests pins it in every mode Epic-095 iteration 1, milestone 3. Rewrite.Tests: 50 passed, 6 m 48 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7cLv6W5QG6qcWcwCHGHP1
|
[MUST] / [SHOULD] / [COULD] / [WOULD] — Moderator tagging of this summary comment. Full ruling:
Moderator finding M1 — [MUST] — the guards are evaluated once, minutes before an irreversible swapRaised because this review files it only as a footnote under owner question 2, where it reads as part Required, both halves from data already in hand: (1) re-run .NET review — epic-095 iteration 1 (
|
| What I broke | Went red | Verdict |
|---|---|---|
ScratchContainersAsync filter → a never-matching prefix |
nothing — E2E-06 and E2E-08 both passed | → Finding #4 |
| Added "pure copy writes history" to E2E-01 | E2E-01, with the tool at exit 0 | → Finding #1 |
transform returning {Data,Metadata} / [] |
probe test — both silently dropped | → Finding #3 |
Verified correct as claimed, no finding:
- Preconditions are established, not assumed.
AssertDanglingLinkExistsAsyncandWaitUntilDanglingAsyncfail loudly; E2E-01 asserts>Faultyreally is Faulted and>Testreally is not before claiming a repair;total.Should().BeGreaterThan(0, "zero links would satisfy 'none dangling' vacuously")anchors the negative;WaitUntilOpenCallsAsyncthrows rather than proceeding, so E2E-09 cannot pass against a store with nothing connected. This is the right shape and it is most of the suite. WaitForFaultedAsync/WaitForRunningAsyncreturn the last status on timeout and the caller asserts on it, so a timeout is a red test naming the last state — the correct pattern (contrast theWaitUntilnote below).- Every assertion reads through the ORIGINAL container after its restart, never the scratch store — checked call by call across all 16 scenarios.
- Reference-equality change detection plus the entry snapshot taken as JS text rather than .NET text (
JsRuleHost.cs:243-245) — dev-log D3's fix, and it is the right one:1.0survives untouched and a touched event is re-serialised from exactly what the script returned. UT-01 pins both directions. - The per-event budget really is shared across every helper and
transform(PerEventBudgetConstraint.Reset()a deliberate no-op); UT-05's three-handler case is what distinguishes it from Jint's per-entryTimeoutInterval. D4's reasoning is correct. - No CLR reachable from the script: no
AllowClr(), no modules, helpers registered asClrFunctions. ReadMetricreturns −1 for an absent metric and the caller refuses — with unit tests for the absent case and for a longer metric name that merely starts the same. Right call: a renamed counter fails loudly instead of silently disabling the guard.BuildScratchEnvreplaces rather than appends the forced keys,MEM_DB=falseincluded.- The swap never leaves a half-swapped state:
Swapreverses its first rename in acatchbefore rethrowing,Restoreis idempotent about which rename already happened, and E2E-10 proves the post-swap restore end to end including a write through the restarted container. --force-volume-copyis refused before any docker call, with a real control —Without_the_flag_the_same_command_line_gets_as_far_as_dockeris exactly the assertion that makes "refused before docker" falsifiable.- Standards: no Moq, no
lock, noconfig["key"], noConsole.WriteLineas logging (theConsoleuses are the CLI's own report and prompt, overridable viaRewriteOptions.Output), no sync-over-async,System.Text.Jsonthroughout,sealed recordDTOs, file-scoped namespaces,ArgumentNullException.ThrowIfNull, xUnit + FluentAssertions, andMigrationApplied.Descriptorscorrectly optional per RUNBOOK m10. Zero CS1591 in the new projects. - Packaging verified by execution, not by reading YAML:
dotnet pack --no-buildon thePackAsToolproject really does produce a self-contained tool package (28 DLLs undertools/net10.0/any, no<dependencies>node), both single-file RIDs build to the paths themvand the release glob expect sofail_on_unmatched_files: truewill not trip, no typo'd job output, and nothing non-MicroPlumberd.*reaches the nuget.org push.
The Spike1 claim (progress.md § Items for the reviewer) — verified, holds
Asked to check rather than accept it, and it checks out on both halves:
ProjectionBehaviorSpikes.cs(it lives atsrc/MicroPlumberd.Migration.Tests/, notIntegration/) hasusingdeclarations forKurrentDB.ClientandMicroPlumberd.Testingonly — noMigrationtype appears anywhere in the file, so no line of theMigrationPlan/CopyEnginepayload-parse change is reachable from it.- The proposed root cause is visible at
ProjectionBehaviorSpikes.cs:91-95:WaitUntilreturns silently on timeout. Spike1 uses it (:108) to wait for$et-SpikeA/$et-SpikeBto fill before creating the join projection, so under load the projection is created over partially populated$etstreams and observes the interleaved-ish order instead of the type-clustered one.
Pre-existing defect in the spike harness, not in this diff — a starved fixture reports a wrong-looking finding about KurrentDB instead of "the precondition never held". Recommend a follow-up to make WaitUntil throw. Not iteration-1 scope.
Restating the two owner questions
- int64 rounding. The engineer recommends a canonicalisation-drift refusal before this tool is pointed at a production store with int64 ids. I agree, and would rank it above named-volume support in the follow-up queue — Finding Make command bus (EventStoreDB) #3 is the same failure class (a silent, plausible-looking wrong result) and this iteration already contains two of them.
- The connected-client guard is narrower than requirements.md's wording. The measurement is sound and the reasoning for not gating on
kurrentdb_kestrel_connectionsis right; accept as narrowed. But note the guard is evaluated once atRewriteCommand.cs:91and never re-checked — a client can connect during the confirmation prompt and the entire copy. A cheap re-check immediately beforeStopAsync(c.Id)would close the window that actually matters.
— .NET Reviewer
Tagged by Moderator
|
[MUST] — Moderator: the promotion of #21 to MAJOR #22 is correct and I uphold it. It is observed rather than theorised, the leaked directory is undeletable by a uid-1000 operator (dev-log D11 measured exactly that), and saturn's root filesystem sat at 92 % throughout the iteration — so calling it a transport flake would have hidden a real host-filling defect. Fix Addendum — counts revised to 3 BLOCKER · 10 MAJOR · 8 MINOR. While cleaning up after my own mutation runs I found the cause of the transport flake I had listed as MINOR #21, and it is a real defect rather than a flake, so it has been promoted and re-filed as MAJOR #22 (line comment on The Spike1 verification section is renumbered #23 accordingly. Housekeeping: my scratch worktree is removed, the main checkout is untouched at — .NET Reviewer Tagged by Moderator |
…pendent verification gate (exit 4), missing Stream/EventType is an error - #1: a script-less run is a ScriptMigration over an empty script: timestamped id, checksum sha256(""), Descriptors = [] (not null); MigrationApplied always appended - #2: RewriteVerification gate independent of the copy engine's bookkeeping — re-counts the source per stream with a re-stated classification, every event read is kept or dropped, every vanished stream must be attributable to a recorded rule (DescriptorRecorder keeps the real predicates); a pure copy may lose nothing; unattributable ⇒ exit 4, no swap, no backup, container never stopped. MigrationRunner.HistoryStreamName exposes the library constant. - #3: a transform result missing Stream/EventType, a non-string Stream, or an array is an error naming the event and field; only an explicit ""/undefined drops (Replicator contract kept) - E2E-09 waited on a metric poll instead of the condition it asserts (lazy connect under load); now waits for a received event and surfaces the subscription's own failure Rewrite.Tests: 63 passed, 6 m 59 s. Each fix shown failing first (mutation at the seam). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7cLv6W5QG6qcWcwCHGHP1
…anchor, structural purge mount, exit-code split Completes the moderated review of PR #17. Note: checkpoint 5710652 (titled BLOCKERs #1-#3) already swept in most of these edits because the working tree kept moving during the lead's verification run; this commit carries the remainder and is the state verified as a whole. - M1: guards re-run immediately before StopAsync (the tool's own source clients are disposed first, or it would refuse itself); last copyable $all position anchored after the first guard pass and re-read before the swap — an advance is exit 4, old store untouched - #2 follow-up: the SourceCount == Kept + Dropped check was a tautology and is removed - #4 positive anchor for ScratchContainersAsync; #5 inherited KURRENTDB_DB/INDEX and EVENTSTORE_* paths outside the swapped mount refused naming the setting (KURRENTDB_LOG reported, decision 9); #6/#6b rollback re-guards and only accepts one of this store's backups; #7 root helper mounts the directory being deleted, never P, depth heuristic gone; #8 confirmation deny path; #9 status lists data.new.* and live scratch containers; #10 401/403 vs unreachable, MP_REWRITE_USER credentials, StoreHttp; #11 executed-count floor (= real count, 80) and outcome=Completed check, TRX if: always(); #12 master trigger + concurrency; #13/decision 8: named-volume refusal exit 1, --force-volume-copy exit 2, pinned as a pair; #15 --no-projection-copy test; #16 sub-second ScriptIdPrefix, second run appends a second record; #18 package metadata on the three new packages; #22 fixture cleans up on setup failure Rewrite.Tests: 80 passed, 9 m 52 s, TRX executed=80 outcome=Completed. Open item: the test host aborts ~1 run in 3 on saturn (root cause unknown, caught by the #11 checks, see progress.md). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7cLv6W5QG6qcWcwCHGHP1
Re-review — epic-095 iteration 1, head
|
| Mutation | Expected red | Result |
|---|---|---|
anchorAfter != anchorBefore → false && … (RewriteCommand.cs:253) |
the exit-4 test | A_write_to_the_source_during_the_run_aborts_before_the_swap_with_exit_4 — "Expected report.Code to be ExitCode.EngineFailure {value: 4}, but found ExitCode.Ok {value: 0}" |
RewriteVerification.Check returns [] unconditionally |
the gate's unit tests | 3 failed, 8 passed — and the shape is right: the three "…_is_caught" tests went red while all four "…_is_accepted / raises_nothing" controls stayed green. A mutation that killed the controls too would have meant the tests only assert "returns something" |
RequiredString reverted to the old undefined → "" folding |
the new UT-02 cases | 4 failed — all three missing-field theory rows plus the non-string case |
RewriteFixture.ScratchContainersAsync filter → never-matching prefix |
the new positive anchor | The_scratch_container_query_sees_the_scratch_container_while_it_is_up — "…if this query cannot see a scratch container that certainly existed, every 'no scratch container' assertion in this file is unfalsifiable" |
The last one matters most to me: at 0d02ef6 that same mutation left every scenario green. The anchor is real now.
The three BLOCKERs
#1 — fixed at the root. RewriteCommand.cs:80-90 no longer models "no script" as "no migration"; a script-less run is a run of the empty script, so it has an id, a checksum (sha256("")) and an empty-but-present descriptor list. That is the right shape — it keeps a pure copy comparable with a scripted one instead of special-casing it out of the history. E2E-01 now asserts the record, its rewrite_ id, the checksum, and — nicely — Descriptors.Should().NotBeNull().And.BeEmpty("'no rules were recorded' and 'no rules ran' must not look the same"), which is exactly the distinction that made MigrationApplied.Descriptors optional in the first place.
#2 — fixed, and my proposed remedy was correctly refused. The moderator was right that SourceCount == Kept + Dropped is a tautology in CopyEngine.cs:288-312; I filed a check that could not fail, which is the class I spent the review objecting to. RewriteVerification.cs:150-153 now carries that reasoning as a comment where the check would have gone, which is better than silently omitting it. Confirming what you asked: nothing tautological remains in RewriteVerification. Its two checks both compare independently obtained facts — RecountSourceAsync re-reads the source and deliberately re-states the engine's classification rather than sharing it (:90-96), which is the right call: sharing it would inherit the bug it exists to catch.
#3 — fixed exactly as prescribed, both directions pinned. RequiredString (JsRuleHost.cs:342-364) rejects absent and non-string; an explicit "" still drops, so ADR 3's Replicator compatibility is untouched. The theory covers {Data, Metadata}, {Stream, Data} and return [].
Your four questions
2. Is the anchor sound, and can a write slip between the re-read and the stop?
The anchor is sound in principle — ReadSourceHeadAsync reads $all backwards and skips $-streams and $-typed events, which is what stops idle $stats traffic and projection $> links from advancing it on their own and refusing every run. That reasoning is correct and it is the only version of this check that can work.
Yes, a write can still slip, and the window is larger than it needs to be. Order today (RewriteCommand.cs:230-245) is: read anchorAfter → dispose the tool's clients → WaitForOwnCallsToDrainAsync (up to 15 s) → re-run the guards → compare anchors → StopAsync. So the unprotected span is the drain plus the guard evaluation, not a round trip. It is partly covered: a client still holding a call at guard time is refused. What is missed is a client that writes and fully disconnects inside that window. Reading anchorAfter after the guard re-check instead would shrink the window to one round trip; the cost is opening one more of the tool's own gRPC calls after the guard has already passed, which is harmless because the guard has already passed. Filed as a MINOR, not a blocker — this is now a seconds-wide race where it used to be minutes-wide, and the remaining sliver inside StopContainerAsync itself (the store accepts writes until it shuts down) cannot be closed without a read-only mode KurrentDB does not have.
One more anchor caveat, filed MINOR: a user projection using emit() rather than linkTo() writes real events into a user stream, so it would advance the anchor and abort the run — correctly (those events really are not in the new store) but unhelpfully, and the operator has no override. The fleet is linkTo throughout (ADR 6) so this is not a today problem, and it is untested.
3. The purge mount — re-proved. DockerStoreLifecycle.cs:236-244: Binds = [$"{full}:/purge"] and rm -rf /purge/..?* /purge/.[!.]* /purge/*. The blast radius is now the named directory by construction: nothing outside it is visible inside the container, every glob is rooted at /purge/, ..?* requires at least three characters so it cannot match .., and no glob ever yields . or ... P — live store and every backup — is never mounted into a root container at all. The depth heuristic is gone and only "not a filesystem root" remains, which is the honest guard once the mount is the interlock. This also explains why my original remedy was wrong: RewriteFixture purges data and data.bak.* for its own cleanup, so a name check inside the method would have broken the suite — the moderator caught that and the prescribed fix is better than mine.
4. Decisions 8 and 9 — no hole in either.
Decision 8 is defensible and, more importantly, pinned as a pair: A_store_on_a_named_volume_is_refused_with_exit_1 with a positive control, alongside the existing exit-2 theory for the flag. "Unsupported argument" (2, like --script with --eval) versus "the store's state" (1) is a real distinction and it can no longer drift.
Decision 9 is not merely acceptable, it corrects me. My #5 lumped KURRENTDB_LOG in with _DB/_INDEX; the engineer pushed back with the decisive fact — KurrentDB's own default log path (/var/log/kurrentdb) is outside the data directory, so refusing on it would refuse essentially every container. Reported-not-refused is right, and _DB/_INDEX plus both EVENTSTORE_* aliases are refused with the setting named. Containment_is_by_path_SEGMENT_not_by_string_prefix pins the /var/lib/kurrentdb-index trap, which is the bug this check would otherwise have shipped with.
5. The abort — hypothesis, ~20 minutes spent.
The strongest candidate is not in the code, it is the host: /tmp on saturn is a tmpfs, so every store the suite creates lives in RAM.
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,...) TMPDIR unset → .NET's GetTempPath() is /tmp
Mem: 121 total, 64 used, 4 free (available 56, of which 62 is buff/cache)
RewriteFixture.StartAsync bind-mounts Path.Combine(Path.GetTempPath(), ...), and each scenario holds up to three stores at once (data, data.new.<ts>, data.bak.<ts>) across ~16 container scenarios. That fits the symptom precisely: a testhost that vanishes rather than throwing (vstest then reports the completed subset as Passed!), non-deterministic at roughly 1 in 3, specific to saturn, and invisible on GitHub runners where /tmp is disk-backed and the VM is fresh. It also composes with finding #22's class — RewriteFixture.DisposeAsync wraps its purges in Safe(...), which swallows, so a failed cleanup silently leaves a whole store resident in RAM for the rest of the run.
I ruled out the alternatives you listed, cheaply: ThrowUnobservedTaskExceptions is not set anywhere, so an unobserved task exception is not fatal; both Task.Run blocks (RewriteE2ETests.cs:337, :641) are awaited, so their faults surface as test failures; there are no finalizers in either project; and the fixture does dispose its DockerClient (RewriteFixture.cs:446), including on the new #22 failure path. I could not check for an OOM kill directly — dmesg is Operation not permitted for this user and the journal is not readable — which is the one piece of evidence that would settle it.
One line falsifies it: run the suite with TMPDIR=/var/tmp (disk-backed) and see whether the abort rate changes. If it does, either keep that in the runbook or have the fixture take its root from an env var defaulting to a disk path. sudo dmesg | grep -i oom right after an abort would confirm it outright. I would not treat the abort as understood until one of those two is done — and I would not let the #11 floor be the reason it stops being investigated, since the floor makes CI honest about it without explaining it.
New findings — 1 SHOULD, 4 MINOR, none blocking
[SHOULD] RR-1 — --status's two new lines are untested; the production scratch query has zero coverage.
RewriteCommand.cs:359-367 adds interrupted : (from swap.NewStoreDirs()) and scratch : (from store.FindScratchContainersAsync). E2E11_status_… (:666-689) still asserts only backups, (none) and safe :. Delete both new lines and no test goes red. Worse, the new anchor test exercises RewriteFixture.ScratchContainersAsync — the fixture's own parallel copy — not DockerStore.FindScratchContainersAsync, so the production query and NewStoreDirs() are both unguarded. This is the same class as #4, one level over: the fix for a vacuous negative was itself added without an anchor. Two assertions in E2E-11, ideally against the production query.
[MINOR] RR-2 — the anchor is re-read before the drain and the guard re-check, widening the window it exists to close. See question 2 above. Moving anchorAfter to immediately before StopAsync shrinks it to one round trip.
[MINOR] RR-3 — an emit()-based user projection on the source would advance the anchor and abort the run. Correct but unhelpable, with no override. Not a fleet problem (ADR 6 is linkTo), untested, worth a README line.
[MINOR] RR-4 — nothing pins the purge mount. The interlock is structural now, so a test is defence-in-depth rather than the guard itself — but the one line that makes it safe (Binds = [$"{full}:/purge"]) can be changed back to parent and nothing anywhere notices. A unit assertion on the CreateContainerParameters would cost little.
[MINOR] RR-5 — the state-path check reads env vars only. CheckStatePathsInsideMount and ResolveDataLocation both consult Config.Env; a container configured with --index in its Cmd or via a mounted kurrentdb.conf bypasses both silently. The fleet uses compose env so this is a documentation gap more than a defect, but the refusal's promise ("a state path outside the mount is refused") is narrower than it reads.
Nit — rewrite-e2e.yml's MIN_TESTS=80 equals the exact current test count, so adding a Skip turns CI red. It fails safe and the comment says to raise it deliberately, so it is fine; the prose beside it still says "71 of 76 tests" and is stale.
What I checked and am satisfied with
Every other item: #5 (refusal naming the setting, segment-wise containment), #6 (--rollback now runs the guards, and <dir> is constrained to swap.Backups() by full-path match), #8 (deny-path test asserting RootEntries() and StartedAtAsync() unchanged, plus a confirm-proceeds control), #10 (401/403 separated from unreachable; --user/--password and MP_REWRITE_USER/_PASSWORD threaded to both the esdb URI and the HTTP client, with no hard-coded credential left outside the tool's own scratch container), #11 (floor distinguishes missing-TRX from executed=0, runs if: always(), and exits 1), #12, #13, #15 (with its positive control in E2E-01), #16 (millisecond prefix plus a two-record test), #17, #18 (all three packages pack cleanly — verified by running dotnet pack, with icon and readme present inside each nupkg), #20, #22 (StartAsync cleans up on failure).
Special mention for the two E2E tests behind M1: driving the race through a TextReader that writes to the store the moment the tool asks for confirmation makes the window deterministic with no fault-injection hook, so what runs is the real trigger rather than a simulation of one — and Acted.Should().BeTrue() stops the scenario passing vacuously if the write never happened. That is a better test than the one I would have asked for.
— .NET Reviewer
…on query; anchor read last - StoreHealth.WaitLiveAsync: /health/live answers over HTTP/1.1 ~500 ms before the gRPC (HTTP/2) listener accepts a call (measured: health OK at 2433 ms, gRPC OK at 2962 ms). The tool waited on health before opening clients on the scratch store and after restarting the operator's container. Readiness is now health THEN a real gRPC round trip, fresh client per attempt, same deadline, timeout names gRPC. StoreHealthTests reproduces it deterministically with an HTTP/1.1-only HttpListener answering 204 (old code: ready; new: timeout), with a control for an unreachable store. Surfaced as a 1-in-N E2E-05 failure in the fixture's first append. - RR-1: --status's data.new.* and live-scratch lines are asserted by a test that runs --status while a rewrite is in flight (real debris, real container), waiting on the PRODUCTION DockerStore.FindScratchContainersAsync (its positive anchor), with the (none) control after. - M1 window: the source anchor is re-read after the drain wait and the re-guard, just before StopAsync, so the gap is one round trip. - CI executed-count floor 83 (= real count). Rewrite.Tests: 83 passed, 10 m 38 s, TRX executed=83 outcome=Completed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7cLv6W5QG6qcWcwCHGHP1
|
Re the re-review summary (#17 (comment)) — both items are in. RR-1 is answered on its own thread. Anchor window (MINOR): No timing change to the existing exit-4 test: its write lands at the confirmation prompt, long before either position, so it is caught the same way. Re-ran it and the sibling-started-during-the-run test to confirm. The three follow-up MINORs are untouched and stay open. — .NET Engineer |
Readiness defect found on the pre-merge runThe pre-merge suite run failed one scenario — It had passed on every earlier run (50, 63, 78, 80 tests). It is not a flake, and the defect is in the tool, not only in the test fixture. MechanismFrom the TRX, the failing call is
Inside that window the server answers the HTTP/2 preface with GOAWAY Where the tool was exposed — measured, and not where it looksPointing a
Fix
ReproductionDeterministic, not timing-dependent.
CI test-count floor raised to 83. — .NET Engineer |
Summary
Epic 095 iteration 1 —
mp-rewrite, a CLI that repairs a KurrentDB store by rewriting its history into a fresh store and swapping data directories, never editing the old one. Docs:docs/epics/epic-095-kurrentdb-rewrite-tool(requirements, design, ADR,iterations/iteration-1/test-scenarios.md).This PR also carries the
MicroPlumberd.Migrationlibrary (previously only onfeature/mp-index-backed-merge, merged with master, 58/59 green with the recorded-negative SPIKE-2a skipped) per the owner's base-branch rule.What is new
MicroPlumberd.Migration:IMigrationBuilder.Transform(Func<RawEvent, RawEvent?>),RawEvent.EventId/Created, optionalMigrationApplied.Descriptors,Migration.ChecksumOverride.MicroPlumberd.Migration.Scripting: Jint 4.16.1 sandbox (no CLR, recursion 64, 2 s per event) hosting the Kurrent Replicatortransform(original)contract plusdropStream/dropEvent/update/updateById/renameType/renameStream/log.MicroPlumberd.Rewrite(mp-rewrite, dotnet tool + single-file linux-x64/arm64): inspect → guards (sibling container, connected client via/metrics kurrentdb_current_incoming_grpc_calls, named volume) → scratch store from the same image →MigrationRunnercopy →Verifier→ swap by rename → restart → report;--dry-run,--rollback,--status, exit codes 0..5.MicroPlumberd.Rewrite.Tests: UT-01..08 and E2E-01..12 against real KurrentDB containers with a bind-mounted data dir (50 tests, sequential, ~7 min). CI:rewrite-e2e.yml.publish-nuget.yml: packs the three new packages and attaches both single-file binaries to the tag's release.Measured, not assumed (dev-log)
/stats proc.tcp.connectionscounts legacy TCP only (0 with a live gRPC$allsubscription); the guard reads/metrics.data.new.<ts>as uid 1001 with 0755 subdirs; a non-root operator cannot delete it, so purge/ownership go through a root helper container from the same image (path double-guarded).KURRENTDB_MEM_DBinherited from an in-memory source would swap an empty store in; forced off.linkToprojection reproduces on 26.1; E2E-01 asserts Faulted → Running with a healthy projection as control.Open questions for the owner (progress.md)
Ruled in iteration 1
--force-volume-copyis refused (exit 2): the named-volume swap is not implemented in this version.🤖 Generated with Claude Code
https://claude.ai/code/session_01G7cLv6W5QG6qcWcwCHGHP1