Skip to content

Commit eb4e33c

Browse files
roed314claude
andcommitted
Close two edges left by the re-review: unloaded cache files, sortless ordering
1. reload() only loads countsfile and statsfile under stats.saving, but stats_fresh was read off the filenames alone. So on a table without saving, both files were accepted, neither was loaded or swapped, and meta_tables.stats_valid was still set true -- leaving the live cache rows, which describe the data being replaced, eligible to be served. Reachable by toggling saving off before a reload, or simply by reloading from a process using the base saving=False setting after counts were recorded. Freshness now asks whether both companions are in the swap as well as whether both files were named, which is what actually decides what goes live. The partial-file and manual reload_final_swap cases stay conservative as before. A reload handed cache files it will not load now says so rather than dropping them silently; it is left as a warning rather than an error, since rejecting outright would change an accepted call into a failure mid-release-candidate. 2. _metafile_order_state maps a SQL-null sort to None, and _generate_sorted_ids reads sort=None as "use the configured sort". A metafile with id_ordered=true, out_of_order=false and sort=NULL therefore numbered the _tmp relation by the sort it was replacing, after which reload_meta installed no sort and _set_ordered recorded an ordered table: exactly the id_ordered-with-no-sort state the audit reports as an error. reload now refuses that combination, with a message saying what to do instead, before anything is rebuilt or swapped -- so the live table is untouched and no _tmp or _resort relation is left behind. The check reads the metafile whether or not this reload renumbers, since the non-resorting path installs the same impossible row through reload_meta. Only the incoherent combination is refused: a metafile that drops the sort and the id_ordered claim together still loads. Regression tests for both, plus the positive controls (a partial cache pair is still not trusted; a sortless metafile that also clears id_ordered still loads). Each new test was checked to fail against a39070f. Full suite green (1405 passed) over three clean-database runs, ruff clean, docs clean under -W. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a39070f commit eb4e33c

5 files changed

Lines changed: 265 additions & 31 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -448,11 +448,22 @@ hardening standalone use; the highlights:
448448
beside changed data) now mark the table invalid, in the same transaction as
449449
the swap. A `metafile` no longer installs a `stats_valid = true` describing
450450
the database it was exported from. Without this a count cached before such a
451-
write was still served afterwards, defeating the enforcement above. The rule
452-
is deliberately conservative in one place: `resort()` leaves the rows
453-
unchanged, so the caches would still be accurate, but on a table without
454-
`saving` nothing rebuilds them and it ends invalid. *Migration:* run
451+
write was still served afterwards, defeating the enforcement above. A
452+
`countsfile`/`statsfile` pair counts as fresh only when it was actually
453+
loaded and swapped in, which needs `stats.saving`; a reload that is handed
454+
cache files it will not load now says so rather than dropping them silently.
455+
The rule is deliberately conservative in one place: `resort()` leaves the
456+
rows unchanged, so the caches would still be accurate, but on a table
457+
without `saving` nothing rebuilds them and it ends invalid. *Migration:* run
455458
`refresh_stats()` after a `resort()` on such a table.
459+
- **A `metafile` claiming to be ordered must install a sort.** `reload` now
460+
refuses one carrying `id_ordered = true`, `out_of_order = false` and no
461+
sort: there is nothing for ascending `id` to follow, so the flags assert an
462+
invariant that cannot hold. Previously the rows were numbered by the sort
463+
the file was *replacing* and the table went live in exactly the state the
464+
audit reports as an error. The check runs before anything is rebuilt or
465+
swapped, so a rejected reload leaves the live table untouched. *Migration:*
466+
give such a metafile a sort, or let it record `out_of_order = true`.
456467
- **`scripts/audit_id_order.py`**, a read-only check that streams each
457468
`id_ordered` table in sort order and reports whether its ids actually
458469
increase, since the flag can drift. Each table is audited in a read

‎DataManagement.md‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,15 @@ and the staged swap — decide the flag in one place, in the same transaction as
142142
the swap. The table ends up valid only when the counts and stats that go live
143143
were built from the data that goes live with them: either recomputed here
144144
(`restat`, on a table with `saving` on) or supplied whole by the export
145-
(`reload` given **both** `countsfile` and `statsfile`). Anything else — a
146-
`restat=False` write, a partial cache set, companions that were merely cloned
147-
or left in place beside replaced rows — is marked invalid. A `metafile`
148-
carries a `stats_valid` of its own describing the database it was exported
149-
from; it does not vouch for caches the reload did not install, and is
150-
overridden to false with a message when it disagrees.
145+
(`reload` given **both** `countsfile` and `statsfile`, and loading them —
146+
which also needs `saving`, since that is what puts the companions in the
147+
swap; a reload handed cache files it will not load says so rather than
148+
dropping them silently). Anything else — a `restat=False` write, a partial
149+
cache set, companions that were merely cloned or left in place beside
150+
replaced rows — is marked invalid. A `metafile` carries a `stats_valid` of
151+
its own describing the database it was exported from; it does not vouch for
152+
caches the reload did not install, and is overridden to false with a message
153+
when it disagrees.
151154

152155
Bulk paths also run PostgreSQL's own `ANALYZE`, which is a different thing from
153156
psycodict's statistics: a freshly loaded relation has no planner statistics
@@ -197,6 +200,15 @@ tell that it did not move a sort key. A staged write is not allowed to rebuild
197200
inside the staged context, but it carries that fact through the commit, so the
198201
swapped-in table is marked out of order and `resort()` afterwards puts it right.
199202

203+
A `metafile` replaces the order flags and the sort together, so a reload
204+
carrying one numbers the rows by the sort *that file* installs, not the one it
205+
is replacing. A metafile asking for `id_ordered = true` with
206+
`out_of_order = false` and no sort is refused: there would be nothing for
207+
ascending `id` to follow, so the flags would assert an invariant that cannot
208+
hold. The check runs before anything is rebuilt or swapped, so the live table
209+
is left untouched. To remove a sort, let the metafile record
210+
`id_ordered = false` as well.
211+
200212
`scripts/audit_id_order.py` checks the flag against reality: it streams each
201213
`id_ordered` table in sort order and reports `OK`, `MISMATCH` or an error
202214
without modifying anything. Each table is audited in a read transaction of its

‎psycodict/table.py‎

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2412,15 +2412,20 @@ def reload(
24122412
24132413
- ``searchfile`` -- a string, the file with data for the search table
24142414
- ``countsfile`` -- a string (optional), giving a file containing counts
2415-
information for the table.
2415+
information for the table. Loaded only when the table saves
2416+
statistics; otherwise it is reported and ignored.
24162417
- ``statsfile`` -- a string (optional), giving a file containing stats
2417-
information for the table.
2418+
information for the table. Same condition as ``countsfile``.
24182419
- ``indexesfile`` -- a string (optional), giving a file containing index
24192420
information for the table.
24202421
- ``constraintsfile`` -- a string (optional), giving a file containing constraint
24212422
information for the table.
24222423
- ``metafile`` -- a string (optional), giving a file containing the meta
2423-
information for the table.
2424+
information for the table. It replaces the sort along with the
2425+
order flags, so the ids are numbered by the sort it installs; one
2426+
claiming ``id_ordered`` and not ``out_of_order`` while installing
2427+
no sort is refused, since ascending id would have nothing to
2428+
follow.
24242429
- ``resort`` -- whether to sort the ids after copying in the data.
24252430
Only relevant for tables that are id_ordered. Defaults to sorting
24262431
when the searchfile does not contain ids.
@@ -2471,6 +2476,18 @@ def reload(
24712476
(self.stats.counts, _counts_cols, False, countsfile),
24722477
(self.stats.stats, _stats_cols, False, statsfile),
24732478
])
2479+
elif countsfile is not None or statsfile is not None:
2480+
# Only a saving table loads its companions, so these are
2481+
# dropped on the floor. Say so: silently ignoring a file the
2482+
# caller named looks like it worked, and the table keeps
2483+
# whatever counts it already had.
2484+
print(
2485+
"Warning: %s does not save statistics (stats.saving is "
2486+
"False), so the counts/stats files given are not loaded "
2487+
"and the existing cached statistics are left in place; "
2488+
"they are marked invalid, since they describe the data "
2489+
"being replaced." % (self.search_table,)
2490+
)
24742491
addedid = None
24752492
with DelayCommit(self, silence=True):
24762493
for table, cols, header, filename in tabledata:
@@ -2498,19 +2515,37 @@ def reload(
24982515
# Renumber ids in sort order before the keys are built: the
24992516
# renumber replaces the _tmp table, so it must run while that
25002517
# table still has no primary key or indexes to rebuild.
2518+
#
2519+
# A metafile replaces the order flags *and* the sort, so what it
2520+
# will install decides both whether to renumber and what to
2521+
# renumber by -- resort_sort, not the sort currently configured,
2522+
# since numbering the rows by a sort the table is about to stop
2523+
# having would install ids that satisfy nothing while claiming
2524+
# to be ordered. Read whether or not this reload renumbers, so
2525+
# that the check below also covers a metafile installed without
2526+
# one.
25012527
resort_sort = None
2502-
if resort:
2503-
if metafile:
2504-
id_ordered, out_of_order, resort_sort = self._metafile_order_state(
2505-
metafile, sep=sep
2528+
if metafile:
2529+
meta_ordered, meta_out_of_order, resort_sort = self._metafile_order_state(
2530+
metafile, sep=sep
2531+
)
2532+
if meta_ordered and not meta_out_of_order and not resort_sort:
2533+
# Ascending id cannot follow a sort the table does not
2534+
# have. Left alone this numbers the rows by the sort
2535+
# being replaced and then installs the combination the
2536+
# id-order audit reports as an error. Raised inside
2537+
# the DelayCommit, before anything is rebuilt or
2538+
# swapped, so the live table is untouched.
2539+
raise ValueError(
2540+
"%s claims id_ordered = true and out_of_order = false "
2541+
"but installs no sort, so there is nothing for "
2542+
"ascending id to follow. Give the metafile a sort, "
2543+
"or let it record out_of_order = true." % (metafile,)
25062544
)
2507-
resort = id_ordered and not out_of_order
2508-
# resort_sort, not the sort currently configured: the
2509-
# metafile replaces that one, and numbering the rows by
2510-
# a sort the table is about to stop having would install
2511-
# ids that satisfy nothing while claiming to be ordered.
2512-
elif not self._id_ordered: # this table doesn't need to be sorted
2513-
resort = False
2545+
resort = bool(resort) and meta_ordered and not meta_out_of_order
2546+
elif resort and not self._id_ordered:
2547+
# this table doesn't need to be sorted
2548+
resort = False
25142549
if resort:
25152550
self._generate_sorted_ids(suffix=suffix, sort=resort_sort)
25162551
ordered = True
@@ -2549,14 +2584,26 @@ def reload(
25492584

25502585
# The replacement's caches agree with its data in exactly two
25512586
# cases: the export supplied both of them alongside the search
2552-
# file, or they were recomputed here from what was loaded.
2553-
# Otherwise the counts and stats that go live are the ones
2554-
# already there (untouched, since they are not in ``tables``)
2555-
# or an empty or reused ``_tmp`` clone -- in neither case a
2556-
# description of the rows now in the table.
2557-
stats_fresh = (countsfile is not None and statsfile is not None) or (
2558-
self.stats.saving and restat
2587+
# file *and* they were loaded and are being swapped in with it,
2588+
# or they were recomputed here from what was loaded. Otherwise
2589+
# the counts and stats that go live are the ones already there
2590+
# (untouched, since they are not in ``tables``) or an empty or
2591+
# reused ``_tmp`` clone -- in neither case a description of the
2592+
# rows now in the table.
2593+
#
2594+
# Asking for membership of ``tables`` rather than just for the
2595+
# filenames is the point: the companions are only loaded under
2596+
# ``stats.saving``, so on a table without it both files are
2597+
# accepted and silently ignored, and trusting the filenames
2598+
# alone marked the live caches -- which still describe the old
2599+
# data -- as agreeing with the new.
2600+
loaded_both_caches = (
2601+
countsfile is not None
2602+
and statsfile is not None
2603+
and self.stats.counts in tables
2604+
and self.stats.stats in tables
25592605
)
2606+
stats_fresh = loaded_both_caches or (self.stats.saving and restat)
25602607
if final_swap:
25612608
self.reload_final_swap(tables=tables,
25622609
metafile=metafile,

‎tests/test_resort.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,113 @@ def test_a_metafile_sort_may_be_a_direction_pair(db, two_col_table, tmp_path):
379379
from test_id_order_audit import audit
380380

381381
assert audit.audit_table(db, name)[0] == "OK"
382+
383+
384+
def _leftover_relations(table):
385+
"""
386+
Any _tmp or _resort relation belonging to this table.
387+
"""
388+
name = table.search_table
389+
return [
390+
t for t in table._all_tablenames()
391+
if t.startswith(name) and (t.endswith("_tmp") or t.endswith("_resort"))
392+
]
393+
394+
395+
def test_a_metafile_may_not_claim_order_without_a_sort(db, two_col_table, tmp_path):
396+
"""
397+
``id_ordered = true`` with ``out_of_order = false`` and no sort describes
398+
an invariant nothing can satisfy: there is no sort for ascending id to
399+
follow. Left alone the reload numbers the rows by the sort the metafile is
400+
*replacing* and then installs the combination the id-order audit reports as
401+
an error, so refuse it up front.
402+
"""
403+
name = two_col_table.search_table
404+
before = list(two_col_table.search({}, projection=["n", "label"], sort=[["n", 1]]))
405+
was_out_of_order = two_col_table._out_of_order
406+
path = _reload_file(
407+
tmp_path / "d.txt",
408+
["n", "label"],
409+
["integer", "text"],
410+
[[str(i), "l%d" % i] for i in ROW_ORDER],
411+
)
412+
metafile = _metafile_with_sort(two_col_table, tmp_path, "\\N")
413+
414+
with pytest.raises(ValueError, match="no sort"):
415+
two_col_table.reload(path, metafile=metafile, resort=True)
416+
417+
# nothing was installed, rebuilt or left half-built
418+
table = db[name]
419+
assert table._sort_orig == ["n"]
420+
assert table._id_ordered is True
421+
assert table._out_of_order is was_out_of_order
422+
assert list(table.search({}, projection=["n", "label"], sort=[["n", 1]])) == before
423+
assert _leftover_relations(table) == []
424+
425+
426+
def test_the_refusal_does_not_depend_on_resorting(db, two_col_table, tmp_path):
427+
"""
428+
The renumbering is only half of it: without one, reload_meta installs the
429+
same impossible row. A search file carrying ids resorts nothing, and the
430+
metafile must still be refused.
431+
"""
432+
name = two_col_table.search_table
433+
path = str(tmp_path / "d.txt")
434+
two_col_table.copy_to(path)
435+
metafile = _metafile_with_sort(two_col_table, tmp_path, "\\N")
436+
437+
with pytest.raises(ValueError, match="no sort"):
438+
two_col_table.reload(path, metafile=metafile, resort=False)
439+
440+
table = db[name]
441+
assert table._sort_orig == ["n"]
442+
assert _leftover_relations(table) == []
443+
444+
445+
def test_a_metafile_may_drop_the_sort_when_it_drops_the_ordering(db, two_col_table, tmp_path):
446+
"""
447+
The coherent way to remove a sort: stop claiming to be id_ordered. Only
448+
the impossible combination is refused, not every sortless metafile.
449+
"""
450+
from psycodict.base import _meta_tables_cols
451+
452+
name = two_col_table.search_table
453+
path = str(tmp_path / "d.txt")
454+
two_col_table.copy_to(path)
455+
metafile = tmp_path / "meta.txt"
456+
two_col_table.copy_to_meta(str(metafile))
457+
fields = open(metafile).read().rstrip("\n").split("|")
458+
fields[_meta_tables_cols.index("sort")] = "\\N"
459+
fields[_meta_tables_cols.index("id_ordered")] = "f"
460+
fields[_meta_tables_cols.index("out_of_order")] = "f"
461+
with open(metafile, "w") as F:
462+
F.write("|".join(fields) + "\n")
463+
464+
two_col_table.reload(path, metafile=str(metafile), resort=True)
465+
466+
table = db[name]
467+
assert table._sort_orig is None
468+
assert table._id_ordered is False
469+
assert table.count() == len(ROW_ORDER)
470+
471+
472+
def test_reload_never_installs_a_state_the_audit_calls_an_error(db, two_col_table, tmp_path):
473+
"""
474+
The invariant behind the refusal, stated in the audit's own terms.
475+
"""
476+
from test_id_order_audit import audit
477+
478+
two_col_table.resort()
479+
name = two_col_table.search_table
480+
assert audit.audit_table(db, name)[0] == "OK"
481+
482+
table = db[name]
483+
path = str(tmp_path / "d.txt")
484+
table.copy_to(path)
485+
metafile = _metafile_with_sort(table, tmp_path, "\\N")
486+
with pytest.raises(ValueError):
487+
table.reload(path, metafile=metafile, resort=True)
488+
489+
# Had the metafile gone in, the table would be id_ordered with no sort,
490+
# which the audit reports as an error rather than as an ordering.
491+
assert audit.audit_table(db, name)[0] == "OK"

‎tests/test_stats_validity.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,3 +548,57 @@ def test_resort_of_a_saving_table_stays_valid(db, counted):
548548
assert stats_valid_in_meta(table) is True
549549
assert table.stats.count(QUERY, record=True) == 67
550550
assert table.stats.quick_count(QUERY) == 67
551+
552+
553+
def test_cache_files_are_not_trusted_when_they_are_not_loaded(db, counted, tmp_path):
554+
"""
555+
Only a table with ``saving`` on loads its counts and stats companions, so
556+
on one without it both files are accepted and quietly ignored: the live
557+
caches stay exactly as they were, describing the data being replaced.
558+
Reading freshness off the filenames alone therefore marked caches for the
559+
old rows as agreeing with the new ones.
560+
"""
561+
name = counted.search_table
562+
searchfile = tmp_path / "data.txt"
563+
countsfile = tmp_path / "counts.txt"
564+
statsfile = tmp_path / "stats.txt"
565+
counted.copy_to(
566+
str(searchfile), countsfile=str(countsfile), statsfile=str(statsfile)
567+
)
568+
# Keep the three header lines and five rows, so the cached 67 is no
569+
# longer the answer to anything.
570+
lines = open(searchfile).read().split("\n")
571+
with open(searchfile, "w") as F:
572+
F.write("\n".join(lines[:3] + lines[3:8]) + "\n")
573+
574+
counted.stats.saving = False
575+
counted.reload(
576+
str(searchfile),
577+
countsfile=str(countsfile),
578+
statsfile=str(statsfile),
579+
restat=False,
580+
)
581+
582+
table = db[name]
583+
assert table.count() == 5
584+
truth = table._execute(
585+
SQL("SELECT count(*) FROM {0} WHERE flag").format(Identifier(name))
586+
).fetchone()[0]
587+
assert truth != 67
588+
assert stats_valid_in_meta(table) is False
589+
assert table.stats.quick_count(QUERY) is None
590+
assert table.count(QUERY) == truth
591+
592+
593+
def test_a_partial_cache_pair_is_still_not_trusted(db, counted, tmp_path):
594+
"""
595+
The rule needs *both* companions: one file leaves the other an empty
596+
clone, which is not the cache the data was exported with.
597+
"""
598+
name = counted.search_table
599+
searchfile = tmp_path / "data.txt"
600+
countsfile = tmp_path / "counts.txt"
601+
counted.copy_to(str(searchfile), countsfile=str(countsfile))
602+
603+
counted.reload(str(searchfile), countsfile=str(countsfile), restat=False)
604+
assert stats_valid_in_meta(db[name]) is False

0 commit comments

Comments
 (0)