Rebuild in resort(), stop row-level writes from resorting, drop finalize_changes - #143
Rebuild in resort(), stop row-level writes from resorting, drop finalize_changes#143roed-math wants to merge 7 commits into
Conversation
…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.
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.
Unqualified DDL and DML went wherever search_path happened to point, while catalog inspection was a mixture of hard-coded 'public' and no filter at all. With a relation of the same name in two schemas the answers came from both: _column_types unioned their columns (or raised "Type mismatch"), an index or constraint in the other schema counted as present, _all_tablenames listed the name twice, and table_sizes reported only public whatever the session was using. PostgresDatabase now takes schema="public", validates it as an identifier once in the constructor, and pins search_path to it in _configure_session -- which runs for the first connection and for every replacement, so a reconnect cannot come back pointing somewhere else. It is deliberately not part of _connect_kwargs: psycopg.connect has no such parameter, and passing it there would reach the driver. Every catalog query is then filtered to that schema, binding it as a value rather than interpolating it: _table_exists, _all_tablenames, _index_exists, _list_indexes, _relation_exists, _constraint_exists, _list_constraints, _column_types, _relation_columns, refresh_tables' column discovery, the read-only and knowls capability probes, _grantees, the legacy-extras check, table_sizes, tablespaces, _check_tmp_leftovers' two probes, the metadata bootstrap's existing-table set, _approx_most_common and dbdiff's column reader. _schema_relations and _approx_most_common previously asked the server with current_schema(); they now bind the same value as everything else, so there is one notion of which schema this is. The userdb.users grant probe keeps its own schema: that one is deliberately about a different schema, not about this database's.
…ize_changes resort() was a disabled no-op: the old implementation renumbered every id with an in-place UPDATE, which stalls replication and leaves the rows in their old physical order, so the point of id-ordering -- sequential disk reads -- was never achieved. It now rebuilds. The table is dumped without ids and reloaded; reload assigns ids 1..N in sort order via a new private _generate_sorted_ids, which is a physical rebuild (INSERT ... SELECT ... ORDER BY into a fresh table that replaces the original) rather than an in-place UPDATE. Everything else -- the primary key, indexes, constraints, grants, counts/stats companions, ANALYZE and the _oldN backup -- comes from reload's one replacement path, so resort() adds almost no orchestration of its own. reload and non-inplace update_from_file call _generate_sorted_ids directly rather than public resort(), so there is no recursion, and the renumber now runs before the keys are rebuilt (it replaces the table, so the table must have no pkey/indexes at that point). Because a resort is a full-table rebuild, it is no longer a side effect of a small write. resort=True on insert_many, update, copy_from and in-place update_from_file raises, pointing at resort(); resort=False is unaffected. rewrite defaults resort to the replacement path only, and a staged table's in-place writes drop the request. An in-place update that changes a sort key now records out_of_order rather than pretending it could reorder. finalize_changes() was a documented public no-op; it is removed. The write methods already leave total, the order flag and stats_valid correct on return, which test_write_invariants now checks for every path. scripts/audit_id_order.py is a read-only check that streams each id_ordered table in sort order (server-side cursor, constant client memory) and reports whether the ids actually increase, since the flag can drift and must not be trusted blindly during the production audit.
…keeping Four correctness problems in the reviewed head, none of them changing the architecture the PR set out: resort() stays an explicit dump/rebuild/swap, row-level writes still reject resort=True, replacement writes may establish order while building their replacement, finalize_changes stays removed, and the audit stays read-only and streaming. 1. scripts/audit_id_order.py could not start: it imported a configured `db` object the package does not export. main() now builds a PostgresDatabase from the usual configuration, and takes one as an argument so the tests audit their own fixture connection instead of opening a second one. Its named-cursor read also left a transaction open, so a successful multi-table run held one MVCC snapshot until exit, and a table that errored left the connection in an aborted transaction -- every table after it then reported InFailedSqlTransaction rather than being audited. Each table now audits in a read transaction of its own, closed on every exit path (including the early mismatch return) without masking the audit's own error. id_ordered with no configured sort is an error rather than an OK obtained by ordering on id itself. 2. update_from_file overloaded `resort` for two different facts: whether the caller wants a rebuild, and whether the input touches a sort key. Since the sort-column check ran only when resort was None, an explicit resort=False suppressed the _break_order bookkeeping as well as the rebuild -- so an in-place or unrebuilt replacement update of a sort column, a rewrite(inplace=True) (whose default forces resort=False), and every staged write (the staged wrapper forces it too) left out_of_order = false standing. On a staged sort-key rewrite that false travelled through _staged_commit into the live meta_tables row, which search code is allowed to act on by replacing ORDER BY <sort> with ORDER BY id. sort_changed is now computed from the file's columns, independently of the option: resort=False means do not rebuild, not pretend the sort was untouched. rewrite(inplace=True, resort=True) raises before func runs over the table rather than after. 3. _generate_sorted_ids built its INSERT ... SELECT column list from self.search_cols, but under reload(adjust_schema=True) the _tmp relation comes from the file header: an added column was omitted from the copy and loaded as NULL, a removed one failed with an undefined column, and a metafile changing the sort got ids assigned by the sort it was replacing and then metadata claiming the new one. The helper now reads the target relation's own columns, takes the sort to number by as an argument (reload passes the one the metafile will install), and validates the relation has an id, has columns besides id, and has every sort column -- before the first destructive statement, so a file and a metafile that disagree leave the relation as it was. 4. update_from_file, rewrite and reload with restat=False swapped changed data in beside cloned, partial or untouched counts and stats while leaving stats_valid true, so a count cached before the write was still served after it -- defeating the enforcement added in the preceding PR. The decision now lives in one place, _set_stats_validity, called inside the same transaction as the update or the swap: valid only when the caches live were built from the data live with them (recomputed here, or supplied whole by an export carrying both countsfile and statsfile). A metafile no longer installs a stats_valid describing the database it came from. Regression tests for all four, in test_id_order_audit, test_write_invariants, test_staged_writes, test_resort and test_stats_validity; each new test was checked to fail against the reviewed head. Full suite green (1397 passed), ruff clean, docs build clean under -W. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed 1.
|
…istic resort() renumbers the rows without changing them, so the cached counts and statistics would still be accurate afterwards; but it goes through the replacement path, which can only tell that the caches match when it rebuilt them, and it rebuilds them only with `saving` on. On a table that does not save statistics a resort therefore ends with stats_valid false and needs a refresh_stats() to serve cached counts again. That is the safe direction (a miss recomputes; a false true serves a wrong answer), so leave the behavior alone and make it a documented, tested quantity rather than a surprise: a note in resort()'s docstring and in the CHANGELOG, and a pair of tests pinning both halves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rectly The test reloaded with resort=False and a comment saying why: renumbering selected the pre-reload table's columns, which the narrowed relation adjust_schema had built no longer had. roed314#143 now reads the target relation's columns from the catalog, so the default (resort, since the file carries no ids) works, and the test pins the interaction from this side by checking that the ids were renumbered in sort order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… 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>
|
Both re-review edges closed in 1. Cache files that were never loaded no longer count as freshConfirmed exactly as described. With Freshness now asks what is actually going live, not what was named on the call: loaded_both_caches = (
countsfile is not None
and statsfile is not None
and self.stats.counts in tables
and self.stats.stats in tables
)
stats_fresh = loaded_both_caches or (self.stats.saving and restat)Membership in On your open question — reject or ignore: I left it ignored but loud. 2. A metafile claiming order with no sort is refusedAlso confirmed: the reload numbered the rows by the sort being replaced, then installed
One thing beyond the literal ask: the check does not depend on resorting. The metafile is read whenever one is supplied, not only when this reload renumbers, because the non-resorting path installs the same impossible row through I went with the validation rather than a sentinel default on Only that combination is refused — a metafile that drops the sort and the Tests and validationSix new tests. Four fail against Full suite green (1405 passed, 36 skipped, 1 xfailed) over three consecutive clean-database runs, |
|
GPT signed off. |
Fourth of the rc3 stabilization PRs: B0, B1, B2, B4, B6.
Stacked on #140–#142; the diff contains their commits too.
B1 —
resort()rebuilds instead of renumbering in placeresort()was a disabled no-op:print("resorting disabled"); return None. Theimplementation below that early return renumbered every id with an in-place
UPDATE, which stalls replication and — the part that matters — leaves the rowsin their old physical order, so the whole point of id-ordering (sequential
primary-key reads) was never achieved.
It now rebuilds. The table is dumped without ids and reloaded; reload assigns
ids
1..Nin sort order via a new private_generate_sorted_ids, a physicalrebuild (
INSERT ... SELECT ... ORDER BYinto a fresh table that replaces theoriginal), not an in-place
UPDATE. Everything else — primary key, indexes,constraints, grants, counts/stats companions,
ANALYZE, and the_oldNbackup— comes from
reload's one replacement path, soresort()adds almost noorchestration of its own. The ids change, and this is documented on the
method and in DataManagement.md.
test_resort.pychecks the rows end up in sort order on disk (viactid,not just logically) — an in-place renumber would fail that — plus contiguous ids
from 1, the backup, rebuilt indexes, the already-ordered short-circuit,
force=, descending sorts, the empty table, and that reload does not recurseback into public
resort().Avoiding recursion
reload and non-inplace
update_from_filecall_generate_sorted_idsdirectly,never public
resort(). The renumber moved to before the keys are rebuilt,since it replaces the table and the table must have no pkey/indexes at that
point.
B2 — row-level writes no longer resort
Per your decision,
resort=Trueraises (pointing atresort()) oninsert_many,update,copy_fromand in-placeupdate_from_file;resort=Falseis unaffected. The replacement operations that rebuild the wholetable anyway —
reload,rewrite, non-inplaceupdate_from_file— stillestablish order.
rewrite'sresortnow defaults to the replacement path only(so an in-place rewrite doesn't trip the guard), and a staged table's in-place
writes drop the request. An in-place update that changes a sort key records
out_of_orderrather than pretending it reordered.B4 —
finalize_changes()removedIt was a documented public no-op. The write methods already leave
total, theorder flag and
stats_validcorrect on return — whichtest_write_invariants.pynow pins for every path (total exact even without stat saving; the B0 order-flag
rules per write; stats invalidation and restoration).
B6 — id-order audit
scripts/audit_id_order.py: read-only, streams eachid_orderedtable in sortorder through a server-side cursor (constant client memory) and reports
OK/MISMATCH/ error.--allor named tables.test_id_order_audit.pyincludes atable that lies about being ordered, to prove the audit catches what the flag
hides.
Not done here, by design
Track J (auditing and rebuilding the 46 production tables) is operational and
yours; this PR ships the tooling it needs. The runbook is in the handoff.
Full suite: 1361 passed, 36 skipped, 1 xfailed. Ruff and the
-Wdocs buildclean.
🤖 Generated with Claude Code