Skip to content

testkit: a worker-side executor double, and ledger's report tests driven by it - #222

Merged
Yaraslaut merged 1 commit into
masterfrom
fix/161-worker-executor-double
Aug 24, 2026
Merged

testkit: a worker-side executor double, and ledger's report tests driven by it#222
Yaraslaut merged 1 commit into
masterfrom
fix/161-worker-executor-double

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #161.

What changed

examples/common/testkit/step_executor.hppStepExecutor, an IExecutor that queues posted tasks and runs them only on runOne()/runAll(). Substituted for the ThreadPoolExecutor a model or App would otherwise own, it turns a submit-then-poll job into an exact sequence: submit, assert still pending, run the worker, assert done. runAll() picks up tasks a running task posts (so a chained job runs to completion) and is bounded, so a self-reposting task fails loudly instead of hanging. Seven self-tests in ladder_common_tests.

ledger::LedgerModel gains the constructor its own _reportExecutor comment has always claimed a caller could use — "a shared_ptr<IExecutor> … so a caller can substitute a different executor (a MainThreadExecutor, a deterministic double) without this class changing shape." Until now there was no way to. Null is rejected at construction rather than deferred to a dereference inside execute(SubmitReport).

test_ledger_reports.cpp converts three of its four report jobs, and adds two cases that were not expressible before.

The issue is mis-framed on one point, and I'd rather say so than ship around it

#161 lists morph::testing::StepExecutor (tests/test_support.hpp) among the doubles that "sit on the callback side" and concludes "none of them lets a test say 'the background job has now run, and not before'."

That double already does exactly that. An IExecutor has no callback side — what it drives is decided entirely by what it is plugged into, and plugged in as _reportExecutor it is the worker-side double the issue asks for. The real gap is reachability: tests/test_support.hpp is private to morph_tests' own translation units and has no include path from examples/.

So this PR mirrors it rather than designing a new ManualExecutor: same name, same API, same rationale, under morph::ladder::testkit — following the duplication DeterministicExecutor already does from that same header, for that same reason. ManualExecutor from the closed #154 and StepExecutor are the same class; I kept the name already in the tree.

What I deliberately did not convert

One case keeps a real ThreadPoolExecutor: "SubmitReport returns immediately; GetReportStatus transitions Pending to Done". It is the only coverage of two things:

  • the default-constructed model — the constructor the bridge registry actually uses. Every converted case goes through the injecting constructor, so without this one nothing covers the default wiring at all.
  • the worker on a genuinely different thread. execute(SubmitReport) is written on the premise that nothing from the caller's stack frame survives into the task — not its DataMapper, and in particular not morph::session::current(), a thread-local. Under StepExecutor the task runs inline, where the test's ScopedPrincipal is still installed: a worker that wrongly reached for the caller's session would pass every converted case and fail only here.

It still carries the retry loop, and its doc comment now says why it is paying for it.

Mutation-checked, not asserted

Mutation Pre-change suite Converted suite
_reportExecutor->post(task) → inline call (no deferral at all) all 44 assertions / 5 cases pass 20 assertions / 4 cases fail
finishReportJob made a no-op fails (after burning the 1 s cap per call site) fails immediately, 12 assertions / 5 cases
finishReportJob settles the oldest job row, not its own fails, 5.51 s, symptom names the wrong thing (countFor(2026,2) == 1) fails, 1.55 s, incl. "Running one report job settles that job and no other"
monthly period filter disabled fails only the timezone case (3 assertions), confirming that conversion did not hollow it out

The first row is the point of the change: SubmitReport computing the entire report synchronously on the caller's thread was invisible to the old tests.

Two cases exist only because of the double:

  • "A submitted report stays Pending until its worker actually runs" — polled three times, it stays Pending with no result body until runOne(). Against a real pool this can only be sampled.
  • "Running one report job settles that job and no other" — two jobs outstanding, settled one at a time. A completion writing the wrong row was untestable here before.

Timing — measured, and smaller than the issue implies

Interleaved runs of the two binaries on the same machine, steady state:

before (5 cases): 1.28  1.29  1.29 s
after  (7 cases): 1.54  1.54  1.57 s

On the five cases common to both: 1.223 s → 1.197 s, about 26 ms. The suite as a whole is longer, because it now does more.

An instrumented pollUntilSettled shows why the win is so small:

PROBE pollUntilSettled slept=15
PROBE pollUntilSettled slept=7
PROBE pollUntilSettled slept=9
PROBE pollUntilSettled slept=7
PROBE pollUntilSettled slept=12

So roughly 2 s of sleep_for calls did disappear — but they had been overlapping the worker's real work (a report job spends ~70–150 ms mostly acquiring a pooled DataMapper), and only the last partial 10 ms slice per call site was ever wall-clock. "No longer sleeps" is worth ~26 ms here, not seconds. What the conversion buys is the determinism and the two assertions above. I'd rather report that than dress up a 2% number.

That probe also turned up a flakiness margin worth recording separately, filed as its own issue rather than folded in here.

Verification: what I measured vs. what CI covers

Measured locally — Debug/Ninja, -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++, -DMORPH_LADDER_RUNGS=ledger:

  • ladder_common_tests + ladder_ledger_tests built and run: ctest -L ladder -LE stress177/177 passed.
  • All four mutations above, each built and run.
  • scripts/check_spec_citations.sh → clean.
  • Both changed TUs compile under -Weverything -Werror (read off the ninja command line, not assumed).

Left to CI — every other rung, GCC, the sanitizer legs (asan/tsan/ubsan), the WASM leg, coverage, and the ladder-wide ctest -L ladder on CI hardware. I did not build the full ladder locally and am not claiming a result for it.

No spec change: this touches no include/morph/**, so the spec-sync gate does not apply. Doxygen's input is include/morph only, so the Docs job is unaffected — the new symbols carry full @param/@return/@throws docs regardless.

🤖 Generated with Claude Code

…ven by it

Closes #161.

examples/common/testkit/step_executor.hpp adds StepExecutor: an IExecutor
that queues posted tasks and runs them only on runOne()/runAll(). Substituted
for the ThreadPoolExecutor a model or App would otherwise own, it turns a
submit-then-poll job into an exact sequence -- submit, assert still pending,
run the worker, assert done -- with no sleep, no retry cap, and no flakiness
budget.

It is a deliberate mirror of morph::testing::StepExecutor
(tests/test_support.hpp), same name and same API, duplicated for the reason
DeterministicExecutor is already duplicated from that header: it is private to
morph_tests' own translation units and has no reachable include path from
examples/. That reachability gap, not the absence of the semantics, is what
left every ladder async-job test spinning a real pool -- worth stating plainly
because #161 reads the other way, listing StepExecutor among the doubles that
"sit on the callback side". An IExecutor has no callback side; what it drives
is decided by what it is plugged into.

LedgerModel grows the constructor its own _reportExecutor comment has always
claimed a caller could use ("so a caller can substitute a different executor
... without this class changing shape"). Until now there was no way to.

test_ledger_reports.cpp converts three of its four report jobs and keeps ONE
on a real ThreadPoolExecutor, deliberately: that case is the only coverage of
the default-constructed model -- the constructor the bridge registry actually
uses -- and the only one where the worker runs on a genuinely different
thread, with the caller's thread-local session context absent. A worker that
wrongly reached for morph::session::current() would pass every converted case
and fail only there.

Two cases are new, and neither was expressible before:

- "A submitted report stays Pending until its worker actually runs" -- the
  negative assertion a real pool cannot support.
- "Running one report job settles that job and no other" -- two jobs
  outstanding at once, settled one at a time.

Mutation-verified rather than asserted. Replacing _reportExecutor->post(task)
with an inline call -- SubmitReport computing the whole report synchronously
on the caller's thread -- leaves the pre-change suite entirely green (44
assertions, 5 cases, all passed) and fails 20 assertions across 4 cases of the
converted one. Three further mutations (finishReportJob made a no-op;
finishReportJob settling the oldest job row instead of its own; the monthly
period filter disabled) each fail the converted suite, the last of them only
in the timezone case, which confirms the conversion did not hollow that case
out.

Measured, and smaller than #161 implies: the sleeps were costing about 26 ms,
not the seconds the issue's framing suggests. [reports] runs 1.28-1.29 s
before and 1.54-1.57 s after -- but the after has two more cases; on the five
cases common to both it is 1.223 s -> 1.197 s. An instrumented run shows
pollUntilSettled slept 7-15 times per call site, so ~2 s of sleep() calls did
disappear; they had simply been overlapping the worker's real work, and only
the last partial 10 ms slice was ever wall-clock. What the conversion actually
buys is the determinism and the two assertions above, not speed.

Local verification: Debug/Ninja, /opt/homebrew/opt/llvm/bin/clang++, ledger
rung only. ladder_common_tests and ladder_ledger_tests built and run
(ctest -L ladder -LE stress, 177/177), plus scripts/check_spec_citations.sh.
Both changed translation units compile under -Weverything -Werror (verified
from the ninja command line). Every other rung, the sanitizer legs and the
WASM leg are left to CI. Doxygen's input is include/morph only, which this
change does not touch, so the Docs job is unaffected; the new symbols carry
full @param/@return/@throws docs regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.28571% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
examples/ledger/src/models/ledger_model.cpp 33.33% 1 Missing and 1 partial ⚠️
examples/common/testkit/step_executor.hpp 95.83% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit 3e86159 into master Aug 24, 2026
33 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.

No worker-side executor double: async-job tests spin a real thread pool and sleep between polls

1 participant