Skip to content

perf(stages)!: build the level chain once at freeze - #4

Merged
illia1f merged 3 commits into
mainfrom
pref/stage-chain-levels
Aug 7, 2026
Merged

perf(stages)!: build the level chain once at freeze#4
illia1f merged 3 commits into
mainfrom
pref/stage-chain-levels

Conversation

@illia1f

@illia1f illia1f commented Aug 6, 2026

Copy link
Copy Markdown
Owner

A level used to be an object built per next call: N stages meant N allocations, and a retry re-allocated everything below it. A level holds nothing belonging to one call, so it never needed building per call.

Each plan now builds a chain of LevelEntry<TResponse> delegates once, when the dispatch map freezes. The request, the provider, and the token reach a level as arguments, so one chain serves every dispatch of its request type.

  • Per-dispatch allocation no longer grows with the chain. Five stages cost what no stages cost, and a repeated next call allocates nothing of RequestFlow's.
  • Resolution is unchanged: every entry into a level resolves its stage, and the bottom level the handler. Stage instances stay the container's, on the lifetime they were registered with.
  • NoResultBridge recovers the Task<NoResult> a void stage hands back from its own next call, so a running pass-through level crosses the bridge free.
  • docs/lifetimes.md covers registering after BuildServiceProvider: the map gets a plan the provider has no descriptor for, and the dispatch fails on the container's own "No service for type".

Two commits ride along: an ignored Directory.Local.props for per-machine settings, which is where the benchmarks' InternalsVisibleTo lives, and TreatNoTestsAsError, so a test leg whose runner fails to load stops exiting green.

BREAKING CHANGE: IContinuation<TResponse> and IContinuation are gone. A stage takes Continuation<TResponse> or Continuation, both readonly structs over the frozen chain. The calls on next do not change, so migrating a stage means editing one parameter type. Its tests take more: a struct cannot be substituted, so build a real one with Continuation<TResponse>.Over(rest). Invoking a default Continuation throws InvalidOperationException.

illia1f added 3 commits August 7, 2026 00:16
The root props file imports it when it exists, and .gitignore keeps it
out of the repo. It carries the InternalsVisibleTo the benchmarks need to
reach ChainBuilder and LevelEntry<TResponse>, which the packed assemblies
should not name.
A leg whose runner fails to load reports no tests and exits zero, so the
run reads as green with a target left unexercised. tests.runsettings sets
TreatNoTestsAsError, and the test props point every test project at it.
A level was built per next call, so a dispatch through N stages allocated
N objects and a retry allocated the levels it re-entered. A level holds
nothing belonging to one call, so building it per call, or per dispatch,
buys nothing.

Each plan now builds its chain of LevelEntry delegates when the dispatch
map freezes, bottom-up from the handler. The request, the provider, and
the token reach a level as arguments, so one chain serves every dispatch
of its request type. Five stages allocate what no stages allocate, and a
repeated next call allocates nothing of RequestFlow's. Stage instances
are still the container's, on the lifetime they were registered with.

Resolution is unchanged: a level resolves its stage on every entry, and
the bottom level the handler. So is the shape a void stage runs under,
which the freeze still records per level. NoResultBridge now recovers the
Task<NoResult> a void stage hands back from its own next call, so a
running pass-through level crosses the bridge free instead of wrapping a
task per level.

Also documents what a registration made after BuildServiceProvider does.
The registry is per collection and the map freezes off it, so the map
gets a plan the provider has no descriptor for, and the dispatch fails on
the container's own "No service for type". docs/lifetimes.md says to
register before the provider is built.

BREAKING CHANGE: IContinuation<TResponse> and IContinuation are gone. A
stage takes Continuation<TResponse> or Continuation, readonly structs
over the frozen chain. The calls on next do not change, so a stage
migrates by editing one parameter type. Its tests take more: a struct
cannot be substituted, so build a real one with
Continuation<TResponse>.Over(rest) or Continuation.Over(rest), which runs
a delegate where the levels below would be. Invoking the default value of
either struct throws InvalidOperationException, since it has no chain
behind it.
@illia1f
illia1f merged commit 34013cd into main Aug 7, 2026
1 check passed
@illia1f
illia1f deleted the pref/stage-chain-levels branch August 7, 2026 07:12
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