Skip to content

Test suite accumulates workflow-run files in the repo's .nodus/, and LocalWorkflowStore rescans all of them — the suite destabilizes its own later runs #380

Description

@Masterplanner25

Summary

LocalWorkflowStore.list_runs() reads and parses every file in
.nodus/workflow_framework/runs/ on every call, the directory grows without
bound, and the test suite writes into the repo's own copy of it. So the suite
degrades its own future runs: tests that race a sweep against a deadline start
failing intermittently, pass on re-run, and pass in isolation.

This is already known as a symptom — CLAUDE.md documents it, and
test_task_graph.py::test_worker_death_detected_by_sweeper was hand-immunized
with a temp SQLite store and a comment explaining why. What is not recorded is
that the threshold is roughly half what those notes say, and that nothing
stops the directory growing.

Measured

list_runs() against a store of N files (this repo, 2026-08-15):

files list_runs()
299 (the repo, as found) 540 ms
300 (synthetic) 304 ms
1,000 906 ms
3,000 3,223 ms
10,000 13,459 ms

Linear, ~1.3 ms per file.

The number that matters: test_worker_death_detected_by_sweeper documents a
500 ms sweep interval, and its comment attributes past timeouts to "670+"
accumulated files adding ">2s per scan". At 299 files a single scan already
costs 540 ms — over that interval on its own. CLAUDE.md repeats the 670 figure.
Both understate it by about 2x, so failures appear well below the count anyone is
watching for.

Why the directory grows

src/nodus_lang_workflow/runner.py:854 defaults the store to a CWD-relative
path:

LocalWorkflowStore(root=os.path.join(".nodus", "workflow_framework"))

Anything that runs a workflow with the repo root as CWD writes there — including
pytest tests/. Retention is terminal_max_age_days = 30.0
(store.py:366), which prunes nothing within a session, or a week, of test runs.

Most workflow tests are careful and use tempfile roots. At least
tests/test_server.py uses the default store (self.server.service.workflow_runner.store),
so each suite run adds files that every later run pays for.

Observed consequences

Three intermittent failures during one session on 2026-08-15, all passing on
re-run and in isolation:

  • test_server.py::test_workflow_runs_endpoint_supports_wait_retry_and_cursor_filters
    (at 244 files)
  • the doc-gate runtime block at docs/guide/workflows-and-tasks.md:51
    (at 2,434 files)
  • test_scheduler_fairness.py::test_long_running_task_rotates_with_budget,
    which CLAUDE.md already lists as flaky under load

rm -rf .nodus/workflow_framework/runs fixed the tree both times it was tried.
The directory was back to 299 files after roughly three suite runs.

Why it is worth fixing rather than documenting

A flaky suite teaches people to re-run rather than read failures, which is the
same failure mode as a check that cannot fail: the signal is there and nobody
trusts it. The current state also makes the flakes look unrelated — a server
endpoint test, a doc block, and a scheduler fairness test have nothing obvious in
common.

There is a second, non-test consequence: an embedder or operator using the local
backend in a long-lived process has the same unbounded scan. 10,000 completed
runs is not an unreasonable number for a workflow engine, and that is a 13-second
list_runs().

Fix direction

Two halves, separable:

  1. Stop the suite writing to the repo. Point the default store at a temp
    root for the whole test session (a conftest.py fixture, or the same
    temp-SQLite treatment test_worker_death_detected_by_sweeper already applies
    individually). A test that does not opt into a store should not leave state in
    the working tree. .nodus/ is gitignored, so this is invisible until it
    bites.

  2. Bound the store's cost. Options, roughly in order of effort:

    • cap by count as well as age (terminal_max_run_files), pruning oldest
      terminal runs on write;
    • keep an index file so list_runs() does not stat-and-parse every entry;
    • have the sweeper query only non-terminal runs, which is all it needs.

(1) removes the flakes. (2) is what makes the local backend honest for anything
long-lived — worth deciding whether the local store is meant for that at all, or
whether SQLite is the answer for a real deployment and the local store should
document itself as a dev convenience.

Reproduction

import os, time
from nodus_lang_workflow.store import LocalWorkflowStore
s = LocalWorkflowStore(root=os.path.join('.nodus', 'workflow_framework'))
n = len(os.listdir(os.path.join('.nodus', 'workflow_framework', 'runs')))
t0 = time.perf_counter(); s.list_runs(); t1 = time.perf_counter()
print(f'{n} files -> {1000*(t1-t0):.0f} ms')

Run pytest tests/ -q a few times from the repo root and watch the count climb.

Affected versions

v4.1.1 and current main. The CWD-relative default and the full-directory scan
both predate v4.

Activity

  1. Masterplanner25 commented on Aug 15, 2026

    @Masterplanner25
    OwnerAuthor

    Part 1 landed in #381 (72fc8f8) — the accumulation is stopped. Part 2 (bounding the store's cost) stays open here.

    Verified by counting rather than by reasoning:

    repo runs/ after
    full pytest tests/ — before 41 files
    full pytest tests/ — after 0
    nodus_gate --all — before files added
    nodus_gate --all — after 0

    The first attempt was wrong and is worth recording. Pointing the default store at a temp root for the whole session — the obvious reading of "use a temp store" — broke 26 tests. Several build a project directory, chdir into it, and assert the default runner wrote under that root. CWD-relative behaviour is the documented contract and those tests are right to check it; the defect was never that files get written, only that they outlive the session. So the fix is cleanup, not redirection: a session fixture removes what the run added and leaves anything that was already there.

    The doc gate needed its own fix — it runs blocks in-process with the repo as CWD, so every workflow example left a file. It now uses a throwaway store via NODUS_WORKFLOW_STORE_ROOT and restores the environment afterwards. That variable also gives a host with a read-only or ephemeral working directory somewhere to put run state.

    I identified the sources rather than guessing: each run file records its workflow_name, which maps back to test_cyclic_workflow.py, test_goal_dsl.py, test_workflow_dsl.py, test_workflow_unification.py and others. Patching them one at a time is the pattern that made #353 recur four times, so the fix sits at the session level.

    The corrections shipped too. CLAUDE.md and the comment in test_task_graph.py both said "670+ files cause >2s per sweep". Failures actually start around 400 — 299 files already cost 540 ms against a 500 ms sweep interval. CLAUDE.md now carries the measured table and tells the next reader to check the directory before blaming a flake on timing.

    One observation logged in the PR rather than swept up: test_workflow_dsl.py::test_workflow_resume_api_accepts_payload_and_event_metadata failed once during this work and passed on re-run and in isolation. Same family — an HTTP server test sharing the default runner. Not caused by this change (default_store_root() returns the old value when the variable is unset, asserted by a test; the fixture runs after every test finishes), but it suggests the shared default runner in server tests is worth a look alongside part 2.

  2. Masterplanner25 commented on Aug 15, 2026

    @Masterplanner25
    OwnerAuthor

    Part 2 landed in #385 (4875b5b), closing this.

    files list_runs() before → after expire_wait_timeouts() before → after
    300 304 ms → 60 ms — → 48 ms
    3,000 3,223 ms → 863 ms 6,600 ms → 579 ms
    10,000 13,459 ms → 3,840 ms — → 2,591 ms

    It was never the parsing, which is why profiling before changing anything was worth the ten minutes. The fix directions listed in this issue — an index file, or querying only non-terminal runs — both assumed the cost was reading and decoding JSON. On 3,000 records, 1.7 s of 4.2 s was in nt.mkdir and 1.6 s in nt.stat; the actual file reads were under a second.

    • _runs_root() re-created the store's own directory on every call, and _run_path() calls it once per record — 6,000 mkdir syscalls to list 3,000 runs.
    • _load_run_unlocked() did an os.path.exists before an open that already reports a missing file. Two syscalls where one does, and a race besides.
    • expire_wait_timeouts() re-read every record it had just been handed, for every run rather than only the waiting ones — terminal runs cannot expire, so the sweep was doing two full passes to find, usually, nothing.

    An index would have added a consistency problem on top of a store that already has an unexplained persistence race (#376), for less benefit than deleting redundant syscalls.

    The count cap is opt-in. max_terminal_runs prunes oldest-first on write, as suggested, but defaults to off: run records are history a host may rely on, and silently deleting them is not a call this store should make on anyone's behalf. Live runs are never pruned regardless of count.

    Still linear, so SQLiteWorkflowStore remains the answer for genuinely long-lived deployments — the local store's docstring now says so with the numbers rather than "will degrade linearly".

    Tests assert the syscall behaviour, not elapsed time: a timing assertion would be flaky on shared CI and would not identify which of the three regressed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions