Skip to content

Stop serving cached statistics while they are marked invalid - #141

Open
roed-math wants to merge 5 commits into
roed314:mainfrom
roed-math:rc3-stats-validity
Open

Stop serving cached statistics while they are marked invalid#141
roed-math wants to merge 5 commits into
roed314:mainfrom
roed-math:rc3-stats-validity

Conversation

@roed-math

@roed-math roed-math commented Aug 4, 2026

Copy link
Copy Markdown

Second of the rc3 stabilization PRs: B3 (cache validity) and B5
(ANALYZE) of the review.

Stacked on #140. The diff contains its commit too; merge that first and
this shrinks to its own.

The bug

Write paths called _break_stats, but read paths queried the cache tables
regardless. Reproduced against PostgreSQL 18 on rc2:

stats.count({"flag": True}, record=True)   # 67, cached
table.update({"flag": True}, {"flag": False}, restat=False)
stats.quick_count({"flag": True})          # still 67; the true answer is 0

The fix

stats_valid becomes an invariant rather than a hint: a cached count or
statistic is served only when the same PostgreSQL snapshot says the live
table's caches are valid, and every path that changes the data states what it
left true in the transaction that changed it.

1. The gate, and where it is decided

Every lookup that would serve a cached answer reports a miss while the flag is
false: quick_count, quick_count_distinct, _quick_statistic. Those three
are what make count, max, min and sum recompute instead of returning a
stored value.

They test the flag in the same SELECT that reads the cached row:

SELECT count FROM <table>_counts
 WHERE cols = %s AND values = %s AND split = %s
   AND EXISTS (SELECT 1 FROM meta_tables WHERE name = %s AND stats_valid)

Not self.table._stats_valid, which is a copy taken when the table object was
built. A deployment runs several webserver processes: one of them committing a
restat=False write moves the row and not the other processes' copies, so
before this every other process went on serving the counts it had cached. A
rollback moves the copy and not the row. And a flag query followed by a cache
query is two snapshots with room for a write to commit in between, so both
readings go into one statement. The attribute survives as advisory only —
nothing rests on it.

count() on an empty query now answers from meta_tables.total for the same
reason. That is a single-row metadata lookup, not a scan, and the total is
maintained rather than cached, which is why it stays exempt from the gate. With
a suffix it reads the suffixed counts row instead: the maintained total
describes the live table and says nothing about a _tmp copy.

_break_stats and _restore_stats issue their UPDATE unconditionally now
(shared _set_stats_valid) — a transition skipped because the local copy
already said so is skipped on the strength of a value that may describe neither
the database nor the present. That UPDATE's row lock is also what orders a
refresh against writers, so a live refresh_stats claims the row at the
start instead of only restoring it at the end. Overlapping work is then
serialized into one of the two acceptable outcomes — the write is in the data
the refresh reads, or its invalidation lands after the refresh commits — rather
than a write committing mid-rebuild and having its invalidation overwritten by
a refresh that had already counted some families without it.

2. Which paths make the transition

The row-level paths always did. The replacement paths did not: they could swap
new data in while keeping the old counts and stats relations and a true flag,
which the new gate would then happily serve. _swap_in_tmp and
reload_final_swap now take the intended state and write it inside the
transaction that does the renames
, so the flag and the relations cannot come
apart:

operation leaves stats_valid
insert_many, upsert, update, delete, copy_from, in-place update_from_file false, then true if saving and restat refreshed the caches
non-inplace update_from_file, rewrite true iff saving and restat — exactly when the rebuilt _tmp counts and stats are swapped in with the data
reload, reload_all true iff saving, both cache companions are in the list actually being swapped, and either restat or both a countsfile and a statsfile were supplied
staged commit, staged_force_swap false — the staged counts and stats tables are empty, not refreshed
reload_revert false
refresh_stats() on the live table true

reload returns a ReloadPlan — the exact swap list, the intended
stats_valid, the resolved ordered — and reload_all, which runs every
reload before any swap, passes it straight through instead of rebuilding the
list from the input filenames. Those two are not the same list: a saving
table's reload readies both cache companions whatever files it was given, so
a folder with no _counts.txt yielded a swap naming only the search table,
stranding the refreshed companions under _tmp and leaving the old live ones
paired with new data. _reload_stats_valid takes the swap list too and
requires both companions to be in it, so the flag cannot outrun the swap.
Nothing inherits the old live table's flag, which was a fact about data that is
no longer there — including a metafile's own stats_valid column, which is
overruled by what the swap actually did (the file records what was true of the
table it was exported from, and cannot know whether these relations were
rebuilt). reload_revert invalidates because a backup carries no validity bit
of its own — meta_tables has one row and it stayed with the live name — and
recounts the total for the same reason.

3. "May this be served" is not "is this row here"

Cache maintenance chooses between INSERT and UPDATE/DELETE by asking
whether the row it is about to write already exists. Routed through the public
lookups, that question now answers "missing" for a row that is physically
there — on every invalid table, which is every table the maintenance runs on.
Each recorder was leaving a second row behind under a key the rest of the code
takes to identify at most one. The plainest case is a write that clears the
flag and then maintains the total, duplicating the {} row it was maintaining.

_record_count, _record_count_distinct and _record_statistic now use
private physical lookups (_cached_count, _cached_count_distinct,
_cached_statistic) that ignore the flag, and _record_statistic updates in
place rather than always inserting. Sending rows down the update branch for the
first time also turned up _record_count_distinct naming a column stats that
has always been called stat.

What is deliberately not gated

Corrected from the first version of this description, which claimed all six
lookups were gated. Three lookups are, and the checked-in changelog and
DataManagement.md describe the rest. The line is whether a miss costs one
bounded query or a rebuild:

  • the empty-query total, maintained on every write and so exact (now read
    from meta_tables, per §1);
  • a suffixed table: a _tmp or _oldN copy carries its own caches, built
    or loaded with its data, and stats_valid says nothing about them;
  • the _status / status / extra_counts inventory, which reports what
    the cache contains rather than answering a question about the data —
    refresh_stats uses it to discover what to recompute, so gating it would
    make an invalid table forget which statistics it is supposed to have;
  • _has_stats / _has_numstats, which decide whether a whole statistics
    family needs computing, and null_counts, whose fallback is one full
    count per search column. Gating those made column_counts, numstats and
    null_counts rebuild on every call with nothing to converge on, since only
    refresh_stats restores the flag — measured on the LMFDB, four minutes of
    downstream suite became over forty-five, mostly inside null_counts over
    nf_fields and friends;
  • the physical lookups above, by construction.

The gap that leaves, stated plainly: column_counts, numstats and
null_counts can still report a value recorded before an unrefreshed write.
Closing it needs freshness per statistic rather than one flag per table, which
is a metadata format change; refresh_stats() is the remedy meanwhile. It is
pinned by a strict=True xfail, so a future per-statistic change turns it
green rather than going unnoticed.

B5: ANALYZE

PostgreSQL's planner statistics are a different thing from psycodict's, and a
bulk-loaded relation has none until autovacuum reaches it — so it is costed as
though it were tiny. New _analyze helper, called on the _tmp copies before
the swap
(the catalog entry follows the relation through a rename, and the
swap's lock window stays as short as it was). All four replacement paths —
reload, rewrite, non-inplace update_from_file, staged commits — funnel
through _swap_in_tmp, so that is one call. copy_from analyzes the live table
it loaded into.

Tests

tests/test_stats_validity.py, 55 cases (14 in the first version, 41 added
here). 33 of the 41 fail on the head they were written against; the other
eight are the row-level paths re-checked (already safe, and kept that way), the
swap-failure rollback, which has nothing to observe until the flag is written
with the renames, and the two reload_all cases that were already correct. The
new ones:

  • Two handles. A second PostgresDatabase on its own connection caches a
    count / distinct count / max / min / sum, the first handle writes with
    restat=False, and the second must stop serving it — with its own
    _stats_valid asserted still true, so the test is about the row and not the
    attribute. Plus the same for the empty-query total.
  • Stale local false. A invalidates, B refreshes, A writes again: the second
    write must clear the row even though A's copy was already false.
  • Rollback. A refresh inside an outer DelayCommit that then raises: the
    object is left saying true, the row says false, and the stale row the
    rollback restored is still not an answer.
  • Refresh/write race. Two connections and a thread, hooked at the very
    start of the rebuild — later than that and the total's own UPDATE has
    already taken the row lock, and the test would pass either way. Asserts the
    writer does not commit mid-rebuild and that the final flag obeys the
    serialization.
  • reload_all. With neither cache file, with only counts, and with only
    stats, under both restat and sequential_swap: nothing left under _tmp,
    the flag saying what the swap did, and the value served afterwards being the
    rebuilt one rather than the pre-reload cache. Plus the restat=False
    counterpart, the both-files case that was already correct, and the plan
    itself.
  • Every replacement path, both directions: in-place and swapping
    update_from_file(restat=False), rewrite(restat=False),
    reload(restat=False), reload_revert, a staged commit, and the row-level
    paths re-checked; then the restat=True swap and reload ending valid from
    an invalid start
    and serving the rebuilt value, the metafile interaction,
    and a swap that fails between the renames and the flag, where neither
    commits.
  • Physical rows. Direct SQL counting rows per logical key after updating
    the maintained total while invalid, re-recording a count, a distinct count
    and each of max/min/sum after a restat=False change, and a refresh_stats
    on an invalid saving table — one row each, holding the recomputed value
    rather than two that happen to agree.

Full suite: 1352 passed, 35 skipped, 1 xfailed. Ruff and the -W docs
build clean.

One thing I did not fix

add_stats replaces the counts rows whose keys it reinserts, not the whole
(cols, split) family, so when two families share a cols key and the data
changed between them, the earlier family's orphaned rows survive and
column_counts reports them: 42 groups summing to 70 where the truth is 32
groups over 60 rows
, in the reproduction I worked up.

Split out as #148 rather than fixed here. It predates this PR, it is about
add_stats not owning the rows it replaces rather than about the flag, and the
fix wants real design work on family bookkeeping: counts rows record neither
which family wrote them nor a threshold, and two existing tests pin why the
obvious family-wide delete is wrong (test_range_count_survives_add_stats,
test_threshold_families_share_rows). refresh_stats repairs it, and the
reload and refresh pipelines do not produce the state in the first place. The
issue records the one question worth settling before 1.0.0: whether the accepted
fix can avoid a new column on the counts relation.

(An earlier revision of this description also listed add_column /
drop_column as leaving stale counts behind. That was wrong: drop_column
already deletes the counts and stats rows whose cols or constraint_cols
mention the column, in the transaction that drops it, and add_column adds a
column that is NULL in every row, which makes no cached count wrong —
null_counts computes any column missing from the counts table on the fly.)

🤖 Generated with Claude Code

…random edge cases

Four independent defects found reviewing rc2, each with regression tests that
fail on rc2 and pass here.

max_id/min_id formatted their table argument into the statement as text.  A
name needing quotes was a syntax error and a name carrying its own statement
ran it; both compose an Identifier now.  While there, the empty sentinel is
documented: max_id returns -1, and 0 is a real id, which is what random() got
wrong below.

_approx_most_common read reltuples from a hard-coded public.nf_fields but read
frequencies from the owning table, so every table other than nf_fields got its
own frequencies scaled by an unrelated row count.  The row count now comes from
the table the statistics are about, looked up by name in the current schema,
and the column type goes through column_type_sql rather than being concatenated.

update_from_file's logging default was a shared dictionary literal that the
method wrote logid and aborted into.  Consecutive default calls saw the
previous call's values and a caller's dictionary came back modified.  An AST
scan of the package confirms this was the only mutable default anywhere that is
actually mutated, so nothing else needed changing.

random() raised IndexError from random.choice([]) when pick_first found no
values, reported a table whose only row has id 0 as empty, and discarded rows
whose projection was falsy -- a table of zeros exhausted maxtries and raised
"Random selection failed!".  random_sample returned None for an unrecognized
mode, which reads like an empty result, and reseeded the global random module
when asked for a repeatable sample.
@read-the-docs-community

read-the-docs-community Bot commented Aug 4, 2026

Copy link
Copy Markdown

Write paths called _break_stats, but read paths queried the cache tables
regardless, so a count cached before a restat=False write kept being served
afterwards.  Reproduced against PostgreSQL 18: a query counted at 67, every
matching row then changed so that none satisfy it, and quick_count still
answered 67.

Every lookup that would serve a cached answer now goes through one predicate,
_may_use_cache, and reports a miss while stats_valid is false -- quick_count,
quick_count_distinct, _quick_statistic, _has_stats, _has_numstats and
null_counts, which between them are what make count, max, min, sum,
column_counts and numstats recompute rather than return a stored value.

Three things are deliberately outside the rule, and the predicate's docstring
says why: the empty-query total, which is maintained on every write and stays
exact; a suffixed table, whose caches are its own and which stats_valid says
nothing about; and the _status/status/extra_counts inventory, which reports
what the cache contains rather than answering a question about the data --
refresh_stats uses it to discover what to recompute, so gating it would make an
invalid table forget what statistics it is supposed to have.

The flag had no way back to true.  _restore_stats is the counterpart of
_break_stats, called at the end of refresh_stats inside the same transaction
that rebuilt the caches, so a refresh that fails part-way leaves the table
marked invalid rather than claiming a cache it does not have.  A suffixed
refresh does not touch the live flag.

Separately, bulk paths now run PostgreSQL's own ANALYZE, which is a different
thing from psycodict's statistics: a bulk-loaded relation has none until
autovacuum reaches it and the planner costs it as though it were tiny.  The
_tmp copies are analyzed before the swap and outside its transaction, since
the catalog entry follows the relation through a rename -- one call in
_swap_in_tmp covers reload, rewrite, non-inplace update_from_file and staged
commits, which all funnel through it.  copy_from analyzes the live table.

The existing statistics fixtures insert rows, which invalidates, and then
assume a usable cache; they now say so with _restore_stats, which is true of
them (nothing is cached yet and the total is maintained by the insert) and is
the state a freshly loaded table is in.
@roed-math
roed-math force-pushed the rc3-stats-validity branch from 055373a to 798fdd9 Compare August 4, 2026 23:03
The gate roed314#141 added was decided by a Python attribute, and only the row-level
write paths maintained the flag it read, so the invariant it advertised did not
hold: a second process kept serving what it had cached, and a replacement path
could swap new data under old caches without clearing anything.

Database-authoritative, and atomic.  quick_count, quick_count_distinct and
_quick_statistic now carry

    AND EXISTS (SELECT 1 FROM meta_tables WHERE name = %s AND stats_valid)

into the SELECT that reads the cached row, rather than consulting
self.table._stats_valid.  That attribute is a copy taken when the table object
was built: another process's restat=False write moves the row and not the copy,
which is precisely the deployment this is for, and a rolled-back transaction
moves the copy and not the row.  One statement rather than a flag query
followed by a cache query, because two statements are two snapshots and a write
can commit between them.  count() on an empty query likewise answers from
meta_tables.total, a single-row metadata lookup rather than the stale copy;
with a suffix it reads the suffixed counts row instead, since the maintained
total describes the live table.

_break_stats and _restore_stats now issue their UPDATE unconditionally, through
a shared _set_stats_valid: a transition skipped because the local copy already
said so is a transition skipped on the strength of a value that may describe
neither the database nor the present.  That UPDATE's row lock is also what
orders a refresh against writers, so a live refresh_stats now claims the row at
the start instead of only restoring it at the end.  Overlapping work is then
serialized into one of the two acceptable outcomes -- the write is in the data
the refresh reads, or its invalidation lands after the refresh commits --
rather than a write committing mid-rebuild and having its invalidation
overwritten.

Every replacement path makes the transition, with the rename.  update_from_file
(both forms), rewrite, reload, reload_all, reload_revert and the staged swaps
could all change the live data while leaving the flag true.  _swap_in_tmp and
reload_final_swap now take the intended state and write it inside the
transaction that does the renames, so the flag and the relations cannot come
apart; _reload_stats_valid states the rule for reload once, since reload_all
defers its swaps to a second pass.  A metafile's own stats_valid is overruled
by what the swap actually did -- the file describes the table it was exported
from, and cannot know whether these relations were rebuilt.  reload_revert
invalidates, because a backup carries no validity bit of its own, and recounts
the total for the same reason.

Cache maintenance asks a different question.  _record_count,
_record_count_distinct and _record_statistic chose between INSERT and
UPDATE/DELETE by calling the public lookups, which under the new gate report a
miss for a row that is physically present -- on every invalid table, which is
every table they run on.  Each recorder was leaving a second row under a key
the rest of the code takes to identify at most one; the plainest case was a
write clearing the flag and then duplicating the {} total row it maintains.
They now use private physical lookups (_cached_count, _cached_count_distinct,
_cached_statistic) that ignore the flag, and _record_statistic updates in place
rather than always inserting.  Exercising that branch for the first time also
turned up _record_count_distinct's update naming a column stats that has always
been called stat.

tests/test_stats_validity.py grows 30 cases: two-handle staleness for counts,
distinct counts and max/min/sum and for the total; a stale local false not
skipping an invalidation; a rolled-back restore; a threaded refresh/write race;
one per replacement path in both directions, the metafile interaction, and a
swap failing between the rename and the flag; and physical-row counts after
each recorder.  Every one of them fails on 798fdd9.

Full suite: 1342 passed, 35 skipped, 1 xfailed.  Ruff and the -W docs build
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Author

Pushed 3deb822, which works through the follow-up review. The PR description
above has been rewritten to match what the code now does; the short version of
what changed since 798fdd9:

The gate is the database's. quick_count, quick_count_distinct and
_quick_statistic carry AND EXISTS (SELECT 1 FROM meta_tables WHERE name = %s AND stats_valid) into the SELECT that reads the cached row, instead of
consulting self.table._stats_valid. That attribute was a copy taken when the
table object was built, so in a multi-process deployment every process except
the writer went on serving what it had cached — the review's point, and the one
that mattered most. Two statements would be two snapshots, hence the single
one. count() on an empty query now reads meta_tables.total for the same
reason (a single-row metadata lookup, not a scan), and with a suffix it reads
the suffixed counts row rather than the live table's total.

_break_stats / _restore_stats issue their UPDATE unconditionally now, and
that UPDATE's row lock is what serializes a refresh against writers: a live
refresh_stats claims the row at the start rather than only restoring it at
the end, so an overlapping write either lands in the data the refresh reads or
leaves the table invalid afterwards. It can no longer commit mid-rebuild and
have its invalidation overwritten.

Every replacement path states what it left true, in the transaction that does
the renames.
_swap_in_tmp and reload_final_swap take a stats_valid=
argument (defaulting to the conservative answer) and write it with the swap.
update_from_file in both forms, rewrite, reload, reload_all,
reload_revert and the staged swaps all now go through it; _reload_stats_valid
states the reload rule once, since reload_all defers its swaps to a second
pass. Nothing inherits the old live flag, and a metafile's own stats_valid
is overruled by what the swap actually did. reload_revert invalidates (a
backup has no validity bit of its own) and recounts the total, which was stale
after a revert for the same reason.

Cache maintenance asks whether the row exists, not whether it may be served.
_record_count, _record_count_distinct and _record_statistic use private
physical lookups that ignore the flag. Through the gated ones they saw "missing"
for rows that were physically there — on every invalid table, which is every
table they run on — and each left a duplicate under a key the rest of the code
takes to identify at most one. The clearest case was a write clearing the flag
and then duplicating the {} total row it maintains. Sending rows down
_record_count_distinct's update branch for the first time also surfaced the
stats = %s / stat typo you flagged; fixed.

Docs. DataManagement.md and the changelog now describe the same contract
as the code, including a table of what each operation leaves the flag at, and
the column_counts / numstats / null_counts limitation stays explicit and
pinned by a strict=True xfail rather than left implicit.

Tests. 31 new cases in tests/test_stats_validity.py (45 total): two
independent database handles for counts, distinct counts, max/min/sum and the
total; a stale local false not skipping an invalidation; a rolled-back
restore; a threaded refresh/write race; one per replacement path in both
directions plus the metafile interaction and a swap failing between the renames
and the flag; and direct-SQL row counts per logical cache key after each
recorder. I checked each against 798fdd9 rather than assuming: 25 of the 31
fail there. The other six are the row-level paths re-checked (already safe, and
kept that way) and the swap-failure rollback, which has nothing to observe until
the flag is written with the renames.

Full suite 1342 passed / 35 skipped / 1 xfailed, run twice; ruff and the -W
docs build clean.

Two things still not fixed, both noted at the bottom of the description: the
add_stats family-replacement staleness carried over from the first round, and
add_column / drop_column, which change the schema without touching the flag
so counts keyed on a dropped column linger. Both are the same shape of defect as
each other and different from this one. Happy to take either.

🤖 Generated with Claude Code

The last place deciding "is this already recorded here" with the gated lookup.
It is reached from refresh_stats, which has just claimed the table's row and
marked it invalid, so quick_count reported every extra count missing and each
one was recounted -- harmless, since the refresh had also just deleted them
all, but it is the answer-validity gate answering an existence question, which
is the confusion the rest of this commit series takes apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Author

Correction to my comment above: I listed add_column / drop_column as leaving counts keyed on a dropped column behind. That is wrong, and I should have checked before writing it. drop_column already deletes the counts and stats rows whose cols or constraint_cols mention the column, inside the transaction that drops it, and add_column adds a column that is NULL in every row, so no cached count becomes wrong (null_counts computes any column missing from the counts table on the fly). Nothing to fix there; the description has been corrected.

That leaves one known gap rather than two, and it is blocked rather than deferred: add_stats cannot delete the stale rows of its own family because counts rows record no threshold, so a family-wide delete would also remove a different-threshold family's legitimately shared rows (test_threshold_families_share_rows) as well as the one-off range counts (test_range_count_survives_add_stats). Distinguishing them needs a column the counts table does not have. refresh_stats empties the counts table before replaying, so the stale row survives a direct add_stats re-run but not a refresh.

reload_all runs every reload with final_swap=False and swaps in a second pass,
and that second pass built each swap's list of relations from the files it had
found in the input folder.  That is not the list the reload prepared.  A saving
table's reload readies both cache companions whatever files it was given --
creating any missing _tmp copy and, under restat, refreshing both from the
loaded data -- so a folder without a _counts.txt yielded a swap naming only the
search table.  The refreshed companions stayed under _tmp, the old live ones
stayed live describing data that had just been replaced, and the validity
transition added earlier in this branch then marked that pairing valid, because
it was computed from restat rather than from what was being swapped.  The
mixed case is the same: one file present, one companion swapped, one stranded.

reload now returns a ReloadPlan -- the exact base names whose _tmp copies belong
at the live names, the stats_valid the swap should write, and the resolved
ordered -- and reload_all passes it straight to reload_final_swap.  Preparation
and finalization can no longer drift, because there is only one derivation.
_reload_stats_valid additionally takes that list and requires both companions to
be in it, so the flag cannot outrun the swap even if some future caller does
assemble a list by hand; and reload_all now passes ordered through, which it
never did.

Ten new cases: reload_all with neither cache file, with only counts and with
only stats, under both restat and sequential_swap, asserting that nothing is
left under _tmp, that the flag says what the swap did, and that the value served
afterwards is the rebuilt one rather than the pre-reload cache; the restat=False
counterpart, which may keep old caches but must not call them valid; the
both-files case that was already correct; and the plan itself.  Eight of the ten
fail on 3c4c225.

Full suite: 1352 passed, 35 skipped, 1 xfailed.  Ruff and the -W docs build
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Author

Pushed 4a3f905 for the reload_all blocker. You were right, and the validity transition I added in the previous round is what made it dangerous rather than merely untidy.

reload_all runs every reload with final_swap=False and swaps in a second pass, and that pass rebuilt each swap's list of relations from the files it had found in the folder. That is not the list reload prepared: a saving table's reload readies both cache companions whatever files it was given, creating any missing _tmp copy and, under restat, refreshing both from the loaded data. So a folder without a _counts.txt produced a swap naming only the search table — refreshed companions stranded under _tmp, old live ones still live and describing data that had just been replaced, and stats_valid set true because it was computed from restat rather than from what was actually being swapped. The mixed case is the same with one companion instead of two.

reload now returns a ReloadPlan (the exact swap list, the intended stats_valid, the resolved ordered) and reload_all passes it straight to reload_final_swap, so there is one derivation rather than two that can drift. _reload_stats_valid also takes that list now and requires both companions to be in it, so the flag cannot outrun the swap even if some future caller does assemble a list by hand — that is the acceptance criterion stated as code rather than as a convention. reload_all also now passes ordered through, which it never did.

Ten new cases, all through reload_all rather than PostgresTable.reload: neither cache file, only counts, and only stats, each under both restat and sequential_swap, asserting nothing is left under _tmp, that the flag matches what the swap did, and that the value served afterwards is the rebuilt one and not the pre-reload cache; the restat=False counterpart, which may keep old caches but must not call them valid; the both-files case that was already correct; and the plan itself. Eight of the ten fail on 3c4c225.

Full suite 1352 passed / 35 skipped / 1 xfailed; ruff and the -W docs build clean. DataManagement.md and the changelog describe the plan contract.

@roed314

roed314 commented Aug 5, 2026

Copy link
Copy Markdown
Owner

GPT signed off.

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.

2 participants