Skip to content

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

Description

@Yaraslaut

The gap

morph has executor doubles for the delivery order of continuations, but nothing that substitutes for the worker side of an async job. Every async-job test therefore runs a real thread pool and waits on wall-clock time.

What exists:

double where what it controls
DeterministicExecutor examples/common/testkit/strand_interleaver.hpp, tests/test_support.hpp the order in which queued continuations are delivered
StepExecutor tests/test_support.hpp stepping continuation delivery
InlineExecutor tests/test_support.hpp running a continuation inline

All of these sit on the callback side. None of them lets a test say "the background job has now run, and not before".

What it costs today

examples/ledger/tests/test_ledger_reports.cpp has to poll:

/// Polls @p model's `GetReportStatus` for @p jobId until it leaves `Pending`,
/// or until the hard iteration cap is reached.
/// ... the real `ThreadPoolExecutor` and sleeps between polls.
status = model.execute(ledger::GetReportStatus{.jobId = jobId});
if (status.status != ledger::ReportStatus::Pending) { break; }
std::this_thread::sleep_for(std::chrono::milliseconds(10));

Three properties follow, none of them good:

  • Wall-clock cost on every run, paid whether or not anything is wrong.
  • A flakiness budget: the iteration cap is a guess about how slow a machine may be. This repository has already been bitten by exactly that shape — morph#147 was a pumpUntil budget that was fine on CI runners and failed deterministically on a slower machine.
  • Unobservable intermediate states. "Submitted, still Pending, worker has not run yet" cannot be asserted deterministically; the test can only sample and hope.

examples/ledger/tests/test_report_job_poller.cpp shows the same shape from the other side — REQUIRE_FALSE(pumpUntil([] { return false; }, 120ms)) to assert a non-event, which is 120 ms of sleeping per assertion.

What is wanted

An IExecutor that queues tasks and runs them only when told:

ManualExecutor worker;
// ... submit the job ...
CHECK(status == Pending);       // asserted, not sampled: the worker has not run
CHECK(worker.runOne());
CHECK(status == Done);          // no sleep, no cap, no flakiness budget

It should also pick up tasks posted by a task, so a chained job runs to completion rather than stranding its own continuation. Not thread-safe by design — a test reasoning about exact task ordering drives it from one thread.

This is roughly 30 lines. A version was written as part of #154 (now closed) and can be lifted from that branch; it does not depend on anything else in that PR.

Where it belongs

The testkit, not include/morph/. Per #129, background jobs live at the App/server layer, so it is the App layer's tests that need to drive a worker deterministically. examples/common/testkit/ alongside the existing doubles is the natural home; tests/test_support.hpp if a framework-level test wants it too.

Context

Split out of #129, which was closed on the architectural point that models should not know about the framework. This part survives that decision: moving background jobs to the App layer relocates the testing gap rather than closing it. Related: #160 (ledger's own background job, which is the concrete case).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: ladderSubsystem: laddertriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions