Repository source: docs/plans/test_run_history_retention_plan_v1.md
Test run history retention
Progress
- [ ] Decision: pick retention model (rolling-window column vs. append-only
log table) and retention size (X runs / Y days / both)
- [ ] Schema change (new column or new table + migration for the existing
`state/test_run_history.sqlite`)
- [ ] Write path: record each run's result into the retained history, not
just the current-state counters
- [ ] Retention/pruning: enforce the X-run or Y-day cutoff so the table
can't grow unboundedly
- [ ] Expose the retained history (at minimum a `getHistory($testName)`
method; optionally surface it in `printRunSummary()`'s report)
- [ ] Unit tests mirroring `TestRunHistoryStoreTest.php`'s existing style
(schema migration, write, prune, read)
- [ ] Full-suite dry run against a scratch copy of the real
`state/test_run_history.sqlite`, same as done for the recovery-window
change
Not started — this file is a plan only, written per user request after estimating the effort in conversation.
Context
tests/Support/TestRunHistoryStore.php (test_run_results table) is a
snapshot table, not a log: one row per test name, PRIMARY KEY
test_name, upserted on every run. It currently tracks, per test, only:
last_status,last_run_at,last_changed_atconsecutive_fail_count/consecutive_pass_count(current streak only)first_failed_at(start of the current fail streak, cleared on recovery)recovered_at(start of the current pass streak, cleared if it fails again)
This is enough to classify each run (new_failure, long_standing_failure,
recovered, recently_recovered — see the recovery-highlight-window work
in this same file) but it cannot answer "what did this test do 3 days ago"
or "how often has this test flaked over the last N runs" — once a streak
ends, everything about it is gone except the counters.
The user asked how much effort it would take to retain real history (X runs or Y days) instead. This plan captures that estimate so it can be picked up later without re-deriving it.
Options
Option A — rolling window column (bounded, no new table)
Store the last N {run_at, status} pairs as a JSON array in the existing
test_run_results row, shifting out the oldest entry each time a new one
is appended.
Row count never grows past the current 605 (one per test) — no pruning job needed beyond capping the JSON array length.
Supports "last X runs" cleanly. Does not cleanly support "last Y days" (a day cutoff would have to look inside the JSON blob).
Querying requires app-level JSON decode; no real SQL filtering across tests (e.g. no cheap "which tests failed more than twice in the last 10 runs" query).
Estimate: ~1–1.5 hours.
Option B — append-only log table (proper history)
New table, e.g.:
CREATE TABLE test_run_log (
test_name TEXT NOT NULL,
run_at TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE INDEX idx_test_run_log_test_name_run_at ON test_run_log (test_name, run_at);
One row inserted per test per run, alongside the existing
test_run_results upsert (that summary table stays as-is and keeps
driving the fast classification logic — no need to touch it).
Breakdown:
New table + migration for the existing DB file — reuse the same
CREATE TABLE IF NOT EXISTS/PRAGMA table_infoguard pattern already used for theconsecutive_pass_count/recovered_atmigration. ~15 min.Write path: one
INSERTper test per run, wrapped in a transaction (605 inserts/run is trivial for SQLite, but batching avoids 605 separate fsyncs). ~20 min.Retention/pruning — pick one or both:
Count-based: keep only the last X rows per
test_name. NeedsROW_NUMBER() OVER (PARTITION BY test_name ORDER BY run_at DESC)(SQLite ≥ 3.25, confirm the PHP build's bundled SQLite supports window functions) and delete anything ranked past X.Day-based:
DELETE FROM test_run_log WHERE run_at < :cutoff. Trivial.Whichever is chosen, run it every full-suite run (same place
pruneStaleEntries()runs today).
~30 min, plus the decision above.
A way to actually use the data — right now nothing would read it. At minimum a
getHistory(string $testName, int $limit): arraymethod. Anything shown in the CLI report (e.g. a flakiness indicator, a per-test timeline) is a real UX decision, not just plumbing — budget separately. ~30–90 min depending on how much is surfaced.Tests mirroring the existing
TestRunHistoryStoreTest.phpstyle (insert, prune-by-count, prune-by-day, read). ~30–45 min.
Estimate: ~2–3 hours, not counting open-ended report/UX work in step 4.
Storage growth (Option B only — Option A is bounded by construction)
This environment's history shows very frequent runs — the "76 runs since 2026-09-25" counters observed in conversation imply roughly one full-suite run every 15–20 minutes. At 605 tests/run that's ~45–60k rows/day. A 30-day retention window would land around 1.3–1.8M rows — likely low tens of MB on SQLite, not alarming, but a hard row-count cap is worth adding regardless of the day cutoff, in case run frequency increases later.
Recommendation
Unless calendar-day queries ("show me what happened last week") are actually wanted, Option A (rolling window column) is cheaper, simpler, and structurally can't grow unboundedly. Option B is the right call only if day-based retention or cross-test SQL queries over history are genuinely needed.
Open questions for whoever picks this up
Runs, days, or both? (Changes which option applies and the pruning SQL.)
If days: what's Y? If runs: what's X?
Does the report (
printRunSummary()) need to display anything from this history, or is "retain it for later querying" enough on its own?