Skip to content

Rebuild in resort(), stop row-level writes from resorting, drop finalize_changes - #143

Open
roed-math wants to merge 7 commits into
roed314:mainfrom
roed-math:rc3-resort-rebuild
Open

Rebuild in resort(), stop row-level writes from resorting, drop finalize_changes#143
roed-math wants to merge 7 commits into
roed314:mainfrom
roed-math:rc3-resort-rebuild

Conversation

@roed-math

Copy link
Copy Markdown

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 place

resort() was a disabled no-op: print("resorting disabled"); return None. The
implementation below that early return renumbered every id with an in-place
UPDATE, which stalls replication and — the part that matters — leaves the rows
in 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..N in sort order via a new private _generate_sorted_ids, a physical
rebuild
(INSERT ... SELECT ... ORDER BY into a fresh table that replaces the
original), not an in-place UPDATE. Everything else — 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. The ids change, and this is documented on the
method and in DataManagement.md.

test_resort.py checks the rows end up in sort order on disk (via ctid,
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 recurse
back into public resort().

Avoiding recursion

reload and non-inplace update_from_file call _generate_sorted_ids directly,
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=True raises (pointing at resort()) on
insert_many, update, copy_from and in-place update_from_file;
resort=False is unaffected. The replacement operations that rebuild the whole
table anyway — reload, rewrite, non-inplace update_from_file — still
establish order. rewrite's resort now 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_order rather than pretending it reordered.

B4 — finalize_changes() removed

It was a documented public no-op. The write methods already leave total, the
order flag and stats_valid correct on return — which test_write_invariants.py
now 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 each id_ordered table in sort
order through a server-side cursor (constant client memory) and reports OK /
MISMATCH / error. --all or named tables. test_id_order_audit.py includes a
table 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 -W docs build
clean.

🤖 Generated with Claude Code

roed314 added 4 commits August 4, 2026 17:52
…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.
@read-the-docs-community

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

Copy link
Copy Markdown

…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>
@roed-math

roed-math commented Aug 5, 2026

Copy link
Copy Markdown
Author

Pushed 7eb9eb7 (plus a documentation follow-up in a39070f), addressing the four correctness problems from the review. The architecture is unchanged: resort() is still an explicit dump/rebuild/swap, row-level writes still reject resort=True, replacement writes still establish order while building their replacement, finalize_changes() stays removed, and the audit stays read-only and streaming.

1. scripts/audit_id_order.py was unrunnable, and left transactions open

main() did from psycodict import db, which the package does not export, so both documented commands failed before auditing anything. It now builds a PostgresDatabase() from the usual PSYCODICT_CONFIG / config.ini path, and takes a database as an argument so the tests audit their own fixture connection rather than opening a second one.

The named-cursor read also never ended its transaction. A successful multi-table run held one MVCC snapshot from the first table until process exit, and after a PostgreSQL error the connection stayed in an aborted transaction, so every table audited afterwards reported InFailedSqlTransaction instead of being audited: one broken table turned a whole --all run into a wall of false errors. Each table now audits in a bounded read transaction, ended on every exit path (including the early MISMATCH return, which leaves the streaming generator suspended and so only closes the cursor if the audit closes it deliberately). Cleanup never masks the audit's own error, and the connection is left IDLE.

id_ordered with no configured sort is now ERROR rather than an OK obtained by ordering on id itself, which would have passed by construction.

2. resort=False no longer means "the sort was not touched"

update_from_file used one variable for two facts, and the sort-column check ran only when resort is None, so an explicit resort=False suppressed _break_order() along with the rebuild. That reached further than it looks: rewrite(inplace=True) forces resort=False, and the staged wrapper forces it on every staged write. So a staged sort-key rewrite left staged._out_of_order false, _staged_commit carried that false through _staged_swap_in, and the replacement went live asserting that ORDER BY id was equivalent to the configured sort when it was not — something search code is allowed to act on.

sort_changed is now computed from the file's columns independently of the option. resort=False means do not rebuild; a write that touches a sort column records the broken ordering either way, on the in-place path, on the unrebuilt replacement path (where the flag survives the swap, which only ever clears it via _set_ordered), and through a staged commit. Since rewrite dumps every search column and func may return anything, an unrebuilt rewrite always marks the table out of order. rewrite(inplace=True, resort=True) now raises before func runs over the table rather than after it and the dump.

3. _generate_sorted_ids follows the relation it is rebuilding

It built its 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 insert into the _resort clone and came back NULL for every row; a removed column was still named and failed with an undefined column; and a metafile changing the sort got ids assigned by the sort it was replacing, followed by metadata claiming the new one.

The helper now reads the target relation's own columns from the catalog, and takes the sort to number by as an argument — reload passes the sort the metafile will install, not the one it is losing. Before the first destructive statement it checks that the relation has an id, has columns besides id (an id-only target is refused with an explanation instead of INSERT INTO t (id, ) SELECT ...), and has every intended sort column, so a data file and a metafile that disagree leave the relation as it was rather than half rebuilt. The pre-index placement, the physical INSERT ... SELECT ... ORDER BY, the backup behaviour and the cleanup on failure are unchanged.

4. Changed data never goes live vouched for by caches that describe the old rows

update_from_file(..., restat=False) invalidated nothing on either path, rewrite inherited that, and reload(..., restat=False) could swap changed search data in beside cloned or untouched counts and stats while keeping stats_valid = true. A count cached before such a write kept being served, which is exactly what #141 set out to stop.

The decision now lives in one place, _set_stats_validity, called in the same transaction as the update or the swap. The table ends up valid only when the caches that go live were built from the data that goes live with them: recomputed here (restat, with saving on) or supplied whole by an export carrying both countsfile and statsfile. Everything else — a partial cache set, companions merely cloned or left in place — is marked invalid. A metafile carries a stats_valid describing the database it was exported from; reload_meta replaces the whole meta_tables row, so that value is now overridden with a message rather than installed over caches this reload never built. total is untouched: it is maintained separately on every write and stays exact.

Tests

Regression tests for all four, in test_id_order_audit.py, test_write_invariants.py, test_staged_writes.py, test_resort.py and test_stats_validity.py — behaviour through the public methods, not just the private helpers. Each new test was run against the reviewed head (5072dd6) to confirm it fails there: 5 for the audit, 6 for the order bookkeeping, 5 for the schema-driven rebuild, 6 for stats validity. The remaining new tests pin behaviour that had to be preserved — the constant-memory named cursor, a resorted replacement still being marked ordered, an export carrying its own caches staying valid, and a failed swap leaving no validity behind.

Full suite green (1397 passed, 36 skipped, 1 xfailed) over three consecutive runs on PostgreSQL 18, ruff check clean, and sphinx -W --keep-going clean. CHANGELOG.md and DataManagement.md updated for the two behaviour changes.

One place the stats rule is deliberately pessimistic

resort() renumbers the rows without changing them, so its cached counts would still be accurate — but it goes through the replacement path, which can only tell the caches match when it rebuilt them, and it rebuilds them only with saving on. So a resort on a table without saving now ends stats_valid = false and wants a refresh_stats(). That is the safe direction (a miss recomputes; a false true serves a wrong answer), so I left the behaviour alone and made it a documented, tested quantity instead: a note in resort()'s docstring and the changelog, plus tests pinning both halves. Say the word if you would rather resort() carried "the data is unchanged" through to the swap and kept the flag.

Note for the stack: #144, #145 and #146 sit on top of this branch and will want a merge from it.

…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>
roed-math pushed a commit to roed-math/psycodict that referenced this pull request Aug 5, 2026
…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>
@roed-math

Copy link
Copy Markdown
Author

Both re-review edges closed in eb4e33c. I reproduced each one against a39070f first, so these are confirmed rather than inferred.

1. Cache files that were never loaded no longer count as fresh

Confirmed exactly as described. With saving off, a reload given both cache files left the live counts describing the old data and marked them valid:

before: cached count of {"flag": True} = 67
after reload of a 5-row search file (saving=False, both cache files given):
  stats_valid = True   quick_count = 7   truth = 2

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 tables is the load-and-swap condition, so this covers the saving=False case you found and stays conservative for partial files and for a manual reload_final_swap(). Same probe now reads stats_valid = False, quick_count = None, count = 2.

On your open question — reject or ignore: I left it ignored but loud. reload now prints that the files are not being loaded and that the existing caches are being marked invalid, rather than dropping them on the floor. Raising instead would turn a call that is accepted today into a failure mid-release-candidate, and I would rather not do that to a downstream caller on my own initiative. Say the word and I will make it a hard error with a validation test instead.

2. A metafile claiming order with no sort is refused

Also confirmed: the reload numbered the rows by the sort being replaced, then installed sort = None, id_ordered = True, out_of_order = False — the state the audit reports as an error.

reload now raises before anything is rebuilt or swapped:

<metafile> claims id_ordered = true and out_of_order = false but installs no sort, so there is nothing for ascending id to follow. Give the metafile a sort, or let it record out_of_order = true.

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 reload_meta — a search file carrying ids resorts nothing and still reached it. There is a test for that path specifically.

I went with the validation rather than a sentinel default on _generate_sorted_ids. sort=None meaning "use the configured sort" is documented and is what every internal caller wants; the incoherent input is a property of the metafile, so rejecting it where the metafile is read keeps the helper's contract simple and fails earlier.

Only that combination is refused — a metafile that drops the sort and the id_ordered claim still loads, with a test pinning it.

Tests and validation

Six new tests. Four fail against a39070f (the two fixes, plus the no-resort path and an audit-level statement of the invariant); two are positive controls that pass on both and must keep passing — a partial cache pair still not trusted, and a sortless metafile that also clears id_ordered still loading.

Full suite green (1405 passed, 36 skipped, 1 xfailed) over three consecutive clean-database runs, ruff check clean, sphinx -W --keep-going clean. CHANGELOG.md, DataManagement.md and the reload docstring updated for both behaviours.

@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