testkit: a worker-side executor double, and ledger's report tests driven by it - #222
Merged
Merged
Conversation
…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>
This was referenced Aug 23, 2026
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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.
Closes #161.
What changed
examples/common/testkit/step_executor.hpp—StepExecutor, anIExecutorthat queues posted tasks and runs them only onrunOne()/runAll(). Substituted for theThreadPoolExecutora 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 inladder_common_tests.ledger::LedgerModelgains the constructor its own_reportExecutorcomment has always claimed a caller could use — "ashared_ptr<IExecutor>… so a caller can substitute a different executor (aMainThreadExecutor, 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 insideexecute(SubmitReport).test_ledger_reports.cppconverts 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
IExecutorhas no callback side — what it drives is decided entirely by what it is plugged into, and plugged in as_reportExecutorit is the worker-side double the issue asks for. The real gap is reachability:tests/test_support.hppis private tomorph_tests' own translation units and has no include path fromexamples/.So this PR mirrors it rather than designing a new
ManualExecutor: same name, same API, same rationale, undermorph::ladder::testkit— following the duplicationDeterministicExecutoralready does from that same header, for that same reason.ManualExecutorfrom the closed #154 andStepExecutorare 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:execute(SubmitReport)is written on the premise that nothing from the caller's stack frame survives into the task — not itsDataMapper, and in particular notmorph::session::current(), a thread-local. UnderStepExecutorthe task runs inline, where the test'sScopedPrincipalis 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
_reportExecutor->post(task)→ inline call (no deferral at all)finishReportJobmade a no-opfinishReportJobsettles the oldest job row, not its owncountFor(2026,2) == 1)The first row is the point of the change:
SubmitReportcomputing the entire report synchronously on the caller's thread was invisible to the old tests.Two cases exist only because of the double:
Pendingwith no result body untilrunOne(). Against a real pool this can only be sampled.Timing — measured, and smaller than the issue implies
Interleaved runs of the two binaries on the same machine, steady state:
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
pollUntilSettledshows why the win is so small:So roughly 2 s of
sleep_forcalls did disappear — but they had been overlapping the worker's real work (a report job spends ~70–150 ms mostly acquiring a pooledDataMapper), 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_testsbuilt and run:ctest -L ladder -LE stress→ 177/177 passed.scripts/check_spec_citations.sh→ clean.-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 ladderon 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 isinclude/morphonly, so the Docs job is unaffected — the new symbols carry full@param/@return/@throwsdocs regardless.🤖 Generated with Claude Code