Skip to content

fix(runtime): put the fork path in the bucket id, not just in the counter - #105

Merged
maverox merged 1 commit into
mainfrom
work/fork-bucket-distinct
Sep 3, 2026
Merged

maverox merged 1 commit into
mainfrom
work/fork-bucket-distinct

Conversation

@maverox

@maverox maverox commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Numbering fork sequences per path, in #100, stopped a cascade in which one extra spawn early in a request renumbered every fork after it and re-addressed everything beneath them. That part was right and stands. What went with it was a property the per-correlation counter had been giving for free, because the bucket id was still composed from the sequence alone:

bucket_id = "{parent}::fork-{seq}"

Every path's sequence starts at one, so two different fork sites under the same parent both minted root::fork-1. Measured across that change:

before (8341ca6):  site_a = root::fork-1   site_b = root::fork-2
after  (on main):  site_a = root::fork-1   site_b = root::fork-1

What the collision costs

A bucket is the scorer's statement that a region of work is unordered. unordered_distinct_lineage excuses a value divergence between two events whose buckets differ, and reads a shared bucket as a claim that the two are ordered with respect to each other. So a collision stops a genuine race between two detached tasks from being excused, and it arrives as a divergence — the same class of false report per-path numbering exists to prevent, reached from the other direction. It is latent in hyperswitch today, where the two fork sites sit on different flows.

The change

Put the path in the id as well as in the counter. The sequence stays local to the callsite, which is what keeps a fork elsewhere in the request from renumbering this one; the id now also carries which callsite it came from, which is what keeps two callsites apart.

The key is stable_callsite_hash, the FNV-1a already used for callsite addresses: deterministic across runs and across toolchains, which a DefaultHasher is not, so a recording and its candidate compute the same value. task_id is composed the same way for consistency.

Nothing parses a bucket id. Checked workspace-wide: every ::fork- occurrence outside the composition site is either a .contains() or a test literal that needs only the marker. The one reader outside the layer is is_fork_region; its doc comment named the old shape and now says the shape belongs to the layer.

Verification

just verify exits 0 unpiped on 1.96.0 — 55 test blocks, zero failures. MSRV cargo +1.85.0 check -p deja-runtime -p deja-context --all-targets exits 0.

The new test fails on the unmodified parent, for the right reason, so this carries its own proof rather than an argument:

assertion `left != right` failed
  left: "root::fork-1"
 right: "root::fork-1"
mutation new test cascade test eviction test
site key is a constant FAILED ok ok
restored ok ok ok

That mutation is the discriminating one: it breaks only the new test, so the test is about the path key specifically and not about forking in general.

Why #100 was green

Both tests written with #100 assert properties that change added — a fork elsewhere must not renumber this one, and counters are evicted when the owning span closes. Neither asserts the property it inherited: that two sibling forks are distinct. The lost guarantee was not in the deleted lines, so neither the diff nor the mutation matrix showed it — every mutation run there broke a property that had already been written down. Worth carrying forward when a keying or numbering scheme changes: the properties the old scheme guaranteed incidentally are the ones nothing asserts.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PYTPJrzSTR1CWbAjouucTs

…nter

Numbering fork sequences per path stopped a cascade in which one extra
spawn early in a request renumbered every fork after it. It also removed
a property the per-correlation counter had given for free, because the
bucket id was still composed from the sequence alone and every path's
sequence starts at one. Two different fork sites under the same parent
therefore both minted root::fork-1 and collided.

A bucket is the scorer's statement that a region of work is unordered.
unordered_distinct_lineage excuses a value divergence between two events
whose buckets differ, and reads a shared bucket as a claim that the two
are ordered with respect to each other. So a collision means a genuine
race between two detached tasks stops being excused and arrives as a
divergence — the same class of false report the per-path change was made
to prevent, reintroduced from the other side.

Put the path in the id. The sequence stays local to the callsite, which
is what keeps a fork elsewhere in the request from renumbering this one,
and the id now also carries which callsite it came from, which is what
keeps two callsites apart. The key is the stable callsite hash already
used for addresses: FNV-1a over the path, deterministic across runs and
toolchains, so a recording and its candidate compute the same value
where a DefaultHasher would not.

Nothing parses a bucket id. The only reader outside the layer is
is_fork_region, which tests for the ::fork- marker and is unaffected;
its doc comment named the old shape and now says the shape belongs to
the layer.

The test that comes with this fails on the unmodified parent for the
right reason, reporting root::fork-1 on both sides. The two tests
written with the per-path change pass throughout, on the parent and
here, because each asserts a property that change added rather than one
it inherited; that is why the collision reached main behind a green
suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYTPJrzSTR1CWbAjouucTs
@maverox
maverox merged commit 1838093 into main Sep 3, 2026
10 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