Skip to content

Fingerprint a stopped PHP source database for backup proof - #523

Merged
rmcdaniel merged 45 commits into
mainfrom
rust-325-upgrade-fingerprint
Oct 21, 2026
Merged

rmcdaniel merged 45 commits into
mainfrom
rust-325-upgrade-fingerprint

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Part of #325: the backup-first in-place upgrade gate. First implementation slice of the takeover design in #517.

Change

upgrade-fingerprint is a new read-only development command (DW_RUST_EXPERIMENTAL=1, same database settings as upgrade-inspect). It prints a digest of a stopped PHP source database's complete logical contents, so the stopped source can be compared with its backup restored into a separate database. Takeover will check the same digest again before writing anything.

  • Coverage. The digest covers the qualified source catalog and every row of every table in primary-key order (every column when a table has no key). Each value is rendered by the database itself in one fixed form and length-framed before hashing:
    • SQLite: quote(), with 17-digit reals;
    • PostgreSQL: quote_nullable() under fixed TimeZone, DateStyle, IntervalStyle, extra_float_digits and bytea_output;
    • MySQL: QUOTE() in UTC, with binary columns as hex.
  • Reading. One read snapshot, through the same source checks as inspection: the SQLite read-only mount and journal checks, and the catalog qualification and ownership refusal on every backend. MySQL refuses a catalog that changes during the read.
  • Output. Only the digest, the per-table row counts and the elapsed time. Every compatibility, backup, quiescence and takeover flag stays false.
  • Limits. Sequence and auto-increment counters are not yet in the digest. External payload objects are outside the database; the design refuses takeover while references exist.

The SQLite source checks are factored out of inspect_sqlite_cli unchanged, as readonly_source and open_sqlite_source.

MySQL catalog fix

The MySQL restore check found a defect in the existing inspection. After a mysqldump restore, MySQL 8.0 prints each column as CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, where PHP's migrations produced COLLATE utf8mb4_unicode_ci. The schema is the same, since a collation names its character set. But the catalog check compared the text, so upgrade-inspect refused every MySQL installation restored from a dump as unqualified. The live catalog, the frozen native catalogs and the PHP 2.8.0 variant deltas now all drop a character set that the following collation names (mysql::canonical, with a unit test).

Test race fix

The interrupted-bootstrap tests on all three backends waited for the child's receipt file to exist and then read it. std::fs::write creates the file before writing, so the parent could read it empty, as #508's MySQL job did (left: "", right: "dirty-partial"). The child now writes beside the receipt and renames it into place. The SQLite test and, as CI runs them, the MySQL (10) and PostgreSQL (7) qualification tests pass locally.

Tests

  • Unit (SQLite). A byte copy fingerprints equal, and so do rows inserted in another order. Each altered value changes it, including NULL against 0 and text against blob. Restoring the original values restores the original digest. The source file is unchanged by fingerprinting. An owned or unknown catalog is refused.
  • Upgrade source cohort (all three backends). After the existing inspections, the cohort fingerprints the published PHP 2.8.0 source twice. It then backs it up and restores it into a separate database: mysqldump --single-transaction into a new MySQL database, pg_dump -Fc and pg_restore into a new PostgreSQL database, or a file copy for SQLite. The restored copy must fingerprint equal, and one changed row in it must change the digest. The existing before/after source snapshot still has to match. Cleanup drops the restored database.
  • Local checks. The cohort script passed locally against published PHP 2.8.0 sources on SQLite, PostgreSQL 17, MySQL 8.0 and MariaDB 10.11 (MySQL after the fix below). The SQLite upgrade unit tests and, as CI runs them, the MySQL library tests (10) and execution suite (277) pass.

🤖 Generated with Claude Code

durable-workflow-ops and others added 3 commits October 20, 2026 08:28
The takeover design (#517) proves a backup by comparing a fingerprint of
the stopped source with the same fingerprint of the backup restored into a
separate database. This adds the read-only upgrade-fingerprint command.

The digest covers the qualified source catalog and every row of every
table in primary-key order, each value rendered by the database itself in
one fixed form: SQLite quote() with 17-digit reals, PostgreSQL
quote_nullable() under fixed time zone, date, interval, float and bytea
settings, and MySQL QUOTE() in UTC with binary columns as hex. Values are
length-framed before hashing. It reads in one snapshot through the same
read-only source checks as inspection, and reports only the digest, the
per-table row counts and the elapsed time. Every compatibility, backup,
quiescence and takeover flag stays false.

The SQLite source checks are factored out of upgrade-inspect unchanged.
Unit tests cover equality for a byte copy and for rows inserted in another
order, a change for each altered value including NULL against 0 and text
against blob, a return to the original digest when values are restored, an
unchanged source file, and refusal of an owned or unknown catalog. The
upgrade source cohort now fingerprints the published PHP source twice,
restores its backup into a separate database (mysqldump, pg_dump and
pg_restore, or a file copy for SQLite), requires an equal digest there, and
requires one changed row in the restored copy to change it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PHP 2.8.0 MySQL source restored from mysqldump prints each column as
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, where the source printed
COLLATE utf8mb4_unicode_ci. The schema is the same, since a collation names
its character set, but the catalog check compared the text, so inspection
refused every installation restored from a dump as unqualified. The live
catalog, the frozen native catalogs and the published PHP variant deltas
now all drop a character set that the following collation names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
durable-workflow-ops and others added 2 commits October 20, 2026 09:17
The SQLite, PostgreSQL and MySQL interruption tests wait for the child's
receipt file to exist and then read it. std::fs::write creates the file
before writing it, so a parent can read it empty: #508's MySQL job failed
with left "" right "dirty-partial". The child now writes beside the
receipt and renames it into place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upgrade inspection knew only the frozen layout and PHP 2.8.0, so a source
on the current release, Server 2.9.7 with Core 2.7.2, was refused as an
unknown catalog: Core added workflow_tasks.queue_delivery_reference.

- Deltas are now chained: each recorded release layout is the previous one
  plus its delta. rust/upgrade/php-2.9.7 records the 2.9.7 delta for
  SQLite, MySQL, MariaDB and PostgreSQL, captured from real published-image
  databases.
- On MariaDB, an installation using Laravel's separate mariadb connection
  stores the backup hold owner as uuid. mariadb-driver.json records that,
  and every release layout is also tried with it.
- Inspection and fingerprint reports name the matched layout
  (source_layout), which takeover will use to choose its conversion.
- The upgrade source cohort gains a labeled run against published 2.9.7 in
  the MySQL/MariaDB, PostgreSQL and SQLite jobs, with separate state,
  evidence, containers and databases, and checks the reported layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
durable-workflow-ops and others added 7 commits October 20, 2026 10:18
Published PHP stores encoded values in history payloads as bare strings
beside their codec and wraps them into {codec, blob} envelopes when it
reads them (ExternalPayloadEnvelopeService::historyPayload). It also lifts
the command's principal to each public history event and keeps only the
neutral fields of an activity failure. Native stores the envelope itself,
so history written by PHP read differently through native: command
payloads and activity arguments came back as strings, and principals were
missing.

Native now applies the same rules to every history event it returns, in
the public history and in worker task history. Only bare strings are
wrapped, so native's own envelopes are unchanged; an unsupported codec or a
PHP external reference is left as stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
upgrade-takeover implements the SQLite part of the takeover design (#517).
In one BEGIN EXCLUSIVE transaction it:

- runs the inventory, queue and payload inspections and requires their
  qualified results, refusing external payloads and service calls;
- recomputes the fingerprint and refuses unless it equals the operator's
  verified backup (DW_UPGRADE_BACKUP_FINGERPRINT);
- adds the native relations, converts the recorded PHP release layout
  (PHP's registration incarnations and tokens copied into native's, PHP-only
  columns and table dropped), records the native migration and runs
  native's own startup verification.

A refusal or interruption leaves the PHP file unchanged; a committed
takeover is a database native opens and PHP 2.5.14+ refuses.

The SQLite upgrade source cohort now takes over a copy of the stopped
published-PHP source, starts native on it and resumes the originals: the
PHP-leased activity completes with its original attempt, the PHP-completed
workflow task is refused as stale, the PHP-scheduled timer fires at its
original deadline, both workflows complete on their original history, and
PHP refuses the upgraded file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Control history now presents each event's command principal as PHP does.
These tests rebuild control events from worker events, so the projection
includes the principal, null for their unauthenticated commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MySQL inspection and fingerprint now open their source through one
helper that detects the backend and bounds lock waits and statements. Each
backend's fingerprint builds its row select and closes its connection
through shared helpers, keeping only its own value rendering. The cohort
script uses [[ ]] tests, names its tool argument and refuses an unknown
backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	rust/src/runtime/upgrade/fingerprint.rs
upgrade-takeover now also accepts DB_CONNECTION=pgsql. One read-committed
transaction takes native's bootstrap lock, locks every public table in
ACCESS EXCLUSIVE mode (refusing with upgrade_source_in_use when a session
still holds one after the 5 s lock wait), runs the inspections and the
fingerprint against the operator's verified backup digest, adds the native
relations, converts the recorded PHP release layout, records the native
migration and runs native's startup verification before committing.

The layout conversion and ledger record are now shared by SQLite and
PostgreSQL through the Backend trait. Inspection and fingerprint reads can
run inside the takeover's transaction, and PostgreSQL fingerprint reads are
bounded by the client deadline instead of the 30 s statement limit.

A takeover killed before its commit leaves nothing committed. On SQLite it
leaves a rollback journal that read-only inspection refuses; a takeover
rerun with the same digest lets SQLite roll it back, and the fingerprint
check then proves the file is the backed-up one. Both backends have a test
that SIGKILLs a real takeover process just before its commit and reruns it.

The upgrade source cohort takes over a restored copy of the published-PHP
PostgreSQL source too, resumes the originals under native and checks PHP's
refusal, as it does for SQLite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
durable-workflow-ops and others added 11 commits October 20, 2026 11:42
MySQL and MariaDB commit DDL implicitly, and LOCK TABLES refuses to create
or drop tables it does not hold, so their takeover cannot be one
transaction. Under native's bootstrap lock, upgrade-takeover now:

- refuses while any other session uses the database (which needs the
  PROCESS privilege to see), then verifies the inventory and fingerprint
  in one consistent snapshot against the backup digest;
- writes native's marker with a rust-takeover:<layout> row holding the
  digest. PHP refuses the database from then on, and native startup
  refuses it with native_takeover_incomplete;
- checks the sessions and the fingerprint again with the marker set
  aside, then runs each conversion step, recording its completion in the
  row. Row changes commit with their step; DDL steps check what is left
  to do, so repeating an interrupted one is harmless;
- commits the migration ledger with native's final marker.

A rerun with the same digest resumes after the last recorded step; a
different digest is refused. Laravel's mariadb driver layout, whose
backup hold owner is a uuid, is converted too.

The upgrade source cohort now takes over a restored copy on every
backend, and its steps are named for what they do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The runner's own user copies the backup, and the cohort's containers run as
uid 1000, so the copy is made writable like the source file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Like the restored backup, the runner's user makes the copy that the uid
1000 takeover and native containers write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ust-325-postgres-takeover

# Conflicts:
#	scripts/conformance/server-parity/upgrade-source-profile.sh
The cohort's mysql backend runs on MariaDB too, where native reports the
takeover backend as mariadb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The source timer fires 60 s after it was authored. On a slow host the
cohort reaches the resume after that, so the timer workflow's task is
ready before the resumed activity workflow's and was claimed first. The
resume now accepts each original workflow's task in either order and
checks the same histories, deadline and stale attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHP Server keeps SQLite in WAL mode, and inspection, fingerprint and
takeover need the rollback journal, so a default installation could not
be upgraded. upgrade-prepare-sqlite, run once PHP is stopped, opens the
file as its only connection and switches it to the rollback journal:
SQLite checkpoints every committed WAL frame into the file first, so the
stored rows are unchanged. It refuses with upgrade_source_in_use while
another process has the file open, and confirms the header and that no
WAL, shared-memory or journal file remains. A journal left by a crashed
writer is rolled back the same way, and a source already in rollback
mode is left byte-identical.

The upgrade source cohort now runs its SQLite source in PHP's default
WAL mode and prepares it after PHP stops, as an operator does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
durable-workflow-ops and others added 8 commits October 20, 2026 17:27
Resume a PHP-accepted signal after takeover and refuse unsupported features
Prepare a PHP SQLite source in WAL mode for the upgrade commands
Take over a stopped PHP MySQL or MariaDB database in recorded steps
Take over a stopped PHP PostgreSQL database backup-first
Take over a stopped PHP SQLite database backup-first
Recognize the current PHP 2.9.7 layout and report the matched layout
@rmcdaniel

Copy link
Copy Markdown
Member Author

The whole backup-first takeover stack is now on this branch. #539 merged into #538, then down through #536, #533, #532 and #529. The stack top's parity run 39466257207 passed all 13 jobs on ffeccc9a3.

dcfc25251 adds only current main (9c10bd0eb: #499 and #510, query poll requests). That code shares no migration, schema or upgrade path with this stack, and its own parity run 39453102751 passed. The merged tree passed the SQLite execution suite (286 tests) and the upgrade lib tests (44) locally. I cancelled the duplicate Shared Server fixtures runs for the intermediate merges and for dcfc25251 (run 39485476286). This head still needs PHPUnit and Sonar before it can merge.

durable-workflow-ops and others added 2 commits October 20, 2026 20:41
Sonar flagged a blocking process API and blocking file reads inside async
tests. The SQLite takeover kill test now supervises its child on a blocking
thread, the WAL copy and original read use Tokio's file API, and the SQLite
upgrade cohort uses [[ for its backend test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

rmcdaniel commented Oct 20, 2026 •

Copy link
Copy Markdown
Member Author

Sonar's gate failed on three findings in the stack's new test code. Two were blocking APIs inside async tests: the SQLite takeover kill test's child process, and file reads and copies. The third was a test in the SQLite upgrade cohort script. a7bd4d07c supervises the child on a blocking thread, uses Tokio's file API, and switches to [[. The upgrade lib tests passed locally (44). fdf4d7b00 also merges main (3785364cb, #545 database TLS), which changes no Rust, parity script or parity workflow. I cancelled its duplicate Shared Server fixtures run 39494091522; the stack's qualification remains run 39466257207.

@rmcdaniel

Copy link
Copy Markdown
Member Author

d7dd2a86a merges main again (870eb3be3, #556 Python trust bundles for the SDK direction cohorts), which changes no native code. I cancelled its duplicate parity run; the stack's qualification remains run 39466257207.

@rmcdaniel
rmcdaniel enabled auto-merge October 20, 2026 21:35
@rmcdaniel
rmcdaniel disabled auto-merge October 20, 2026 22:29
@rmcdaniel

Copy link
Copy Markdown
Member Author

bd29547df merges main again (49a56f5c0, #551 namespace update). On SQLite, the native lib suite (136, including the upgrade tests) and the execution suite (287) pass locally on the merged head.

I turned auto-merge off until the Shared Server fixtures run for this head (39508342128) passes. The stack's last full pass, 39466257207, predates the Sonar fixes in a7bd4d07c. Those fixes changed the SQLite takeover kill test and upgrade-source-profile.sh, and only the parity jobs run that script. I'll merge once this run passes.

@rmcdaniel
rmcdaniel enabled auto-merge October 21, 2026 00:37
@rmcdaniel

Copy link
Copy Markdown
Member Author

The Shared Server fixtures run for bd29547df, 39508342128, passed all 13 jobs. It covers the stack after the Sonar fixes, including upgrade-source-profile.sh.

9c2f55047 merges main (#543 terminate and worker listing, #549, #550 search attribute registry, #552) without conflicts. On the merged head, the native lib suite (140, with the upgrade tests) and the SQLite execution suite (294) pass locally.

I cancelled the duplicate parity run for the merged head; 39508342128 stays the stack's qualification. Auto-merge is back on.

@rmcdaniel

Copy link
Copy Markdown
Member Author

265686405 merges main (#548) without conflicts. On the merged head, with a forced crate rebuild, the native lib suite (140) and the SQLite execution suite (295) pass locally. The duplicate parity run is cancelled; 39508342128 stays the qualification.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Updated with main after #557 merged (b7d631c; the merge only brings #557's monotonic identifiers and #560's CI change; Rust unit tests pass locally on the merge). I cancelled this head's Shared Server fixtures run 39534264085 to free runners for the required checks: run 39508342128 already passed all 13 jobs on 2656864, and #557 passed its own full run. Main's post-merge fixtures run covers the combination.

@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel
rmcdaniel merged commit 9e90d19 into main Oct 21, 2026
9 of 14 checks passed
@rmcdaniel
rmcdaniel deleted the rust-325-upgrade-fingerprint branch October 21, 2026 03:17
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