Skip to content

Take over a stopped PHP MySQL or MariaDB database in recorded steps - #536

Merged
rmcdaniel merged 90 commits into
rust-325-postgres-takeoverfrom
rust-325-mysql-takeover
Oct 20, 2026
Merged

rmcdaniel merged 90 commits into
rust-325-postgres-takeoverfrom
rust-325-mysql-takeover

Conversation

@rmcdaniel

Copy link
Copy Markdown
Member

Part of #325: the backup-first in-place upgrade gate. Stacked on #533 (which sits on #532, #529 and #523); review those first. Implements the MySQL and MariaDB part of the design in #517. With this PR, all three backends have a takeover.

Why MySQL is different

MySQL and MariaDB commit DDL implicitly, so the takeover cannot be one transaction. The design also considered LOCK TABLES, but it does not work here: under it, MySQL 8.0 refuses to create or drop any table the session does not hold (ERROR 1100 … was not locked with LOCK TABLES), so the native relations could not be added. The takeover therefore uses the design's other option: a session check plus a step journal.

Takeover

upgrade-takeover now also accepts DB_CONNECTION=mysql or mariadb. It runs under native's bootstrap lock (GET_LOCK('dw-native-bootstrap:'…)), so neither a native node nor a second takeover can run meanwhile.

  1. Verify. The takeover refuses with upgrade_source_in_use while any other session uses the database. Seeing other accounts' sessions needs the PROCESS privilege; without it the takeover refuses with upgrade_source_sessions_unverifiable. The inventory and fingerprint are then read in one consistent snapshot, and the takeover refuses unless they match DW_UPGRADE_BACKUP_FINGERPRINT. Service calls and external payloads are refused here, before anything is written.

  2. Mark. The first write is native's own marker table, with one row ('rust-takeover:<layout>', <completed steps>, <fingerprint digest>). From then on:

    • PHP refuses the database, since its fence is the table's existence;
    • native startup refuses it with native_takeover_incomplete.
  3. Fence. The session check runs again, and the fingerprint is recomputed with the marker set aside, so nothing a PHP session wrote before the marker can be missing from the backup.

  4. Convert. Each step records its completion in the row:

    • native relations and the workflow_tasks poll index;
    • incarnation adoption, whose rows commit in the same transaction as the step record;
    • dropping the PHP incarnation table, the registration token, the service-call deadline column with its index, and the queue delivery reference;
    • for Laravel's mariadb driver, the backup-hold owner from uuid to char(36).

    DDL steps check what is left to do, so repeating an interrupted step is harmless.

  5. Finish. The migration ledger row and native's final marker ('rust-development', 4, <checksum>) commit together. Native's startup verification (marker, ledger and full catalog) then runs.

A rerun with the same digest resumes after the last recorded step; a different digest is refused. A takeover killed between creating the marker table and writing its row is resumed too: the layout is read again with the marker set aside, and the fence step verifies the fingerprint.

The inventory, fingerprint and catalog read now accept that fenced state (mysql_inventory, mysql_digest, fenced_source_catalog). The layout conversion shares the incarnation adoption with SQLite and PostgreSQL.

Checks

  • MySQL qualification tests (runtime::mysql::tests::qualification_takeover_*, run by the mysql-execution jobs on MySQL 8.0 and MariaDB 10.11):
    • A PHP 2.9.7 database, rebuilt from the recorded release deltas, is converted and opened by native, and a second takeover is refused.
    • The frozen layout, and on MariaDB the mariadb-driver layout, are taken over.
    • These are refused before any write, with the catalog and every row unchanged: a wrong digest, a malformed digest, another open session, a write after the backup, a service call, and an external payload.
    • A real takeover process is SIGKILLed at four boundaries: between the marker table and its row, after the fence, after the incarnation rows, and before the final commit. Each time the marker fences PHP, native refuses the database, and a wrong digest is refused on resume. The rerun with the right digest completes, and native opens the result.
  • Upgrade source cohort: the MySQL jobs now take over a restored copy of the published-PHP source, start native on it and resume the originals, as the SQLite and PostgreSQL jobs do. The cohort's steps are renamed for what they now do.
  • Local results:
    • The 4 new tests pass on MySQL 8.0.46. All 5, including the mariadb-driver case, pass on MariaDB 10.11.19.
    • All 15 MySQL qualification tests, the 11 PostgreSQL qualification tests and the SQLite lib tests (129) pass.
    • The cohort passed with both the default (PHP 2.8.0 layout) and the PHP 2.9.7 sources on MySQL 8.0 and MariaDB 10.11, and with the default source on SQLite and PostgreSQL. In each run, the leased activity completed on its original attempt, the stale task got 409 task_not_leased, the timer fired after its deadline, both workflows completed and PHP refused with php_database_refused.

Cohort fixes in this stack

Not covered yet

  • Nested payload columns the inspection does not scan yet.
  • Takeover with external payloads or service calls.
  • Multi-node qualification.

The report keeps stored_representation_compatibility_verified: false.

🤖 Generated with Claude Code

durable-workflow-ops and others added 30 commits October 20, 2026 03:22
Five transitions run Avro work while holding the global transition lock:
the rejected-signal command, signal arguments applied by a resumed task,
cooperative cancellation requests, task repair and fenced deregistration.
They shared the single codec permit with describe, query and listing
reads, so under read load a transition waited on the lock for unrelated
decodes. A per-call-site probe on the CPU deadline-health cell measured
0.58 to 0.80 s of such waiting in a cancellation request.

These transitions now take a separate transition_codec permit. Reads keep
signal_codec. Encodings, values and refusals are unchanged.

New tests hold the read permit and require a cancellation request, a
signal-resumed completion, a fenced deregistration and a range repair to
finish within one second. They time out with the old wiring.

Part of #325.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The worker protocol requires poll_status "leased" on a workflow task
claim, and published PHP (2.5.14 and 2.8) reports "leased" and "empty"
for workflow, activity and query polls alike. Native reported "task" and
"timeout". It now reports the same two values. The wake hint
task_queue_changed and every refusal status are unchanged.

The cross-kind poll contract, which accepted either claim status, now
requires "leased".

Part of #325.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each held native poll used to repeat its full claim every 50 ms: a
transaction under the global transition lock that read the registration,
deleted expired poll receipts and selected candidates. With no work, idle
cost grew by about 0.035 cores per waiting poll, two-thirds of it in MySQL,
and every probe queued behind, and delayed, real transitions.

A held poll now claims under the lock on its first probe. Afterwards it
claims only when a lock-free read finds something it could claim or must
refuse: an eligible task or query, a receipt for its own poll request, or a
removed, moved or replaced registration. The read runs at once when a commit
on the same node wakes the poll, and otherwise every 50 ms, which is how a
poll still observes other server processes. A false positive costs one empty
claim, and a commit racing a read is seen on the next read.

New tests hold the transition lock from outside the runtime and check that
idle workflow, activity and query polls still end at their deadline (each
fails with the previous probing), and that a held poll still claims a task
or query, and refuses a removed registration, committed by another node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The published PHP SQLite reference shares its database file with its
maintenance loop and sometimes refuses a request with its documented HTTP 503
backend_lock_pressure reply: retryable, nothing committed, worker mutations
fenced by task and attempt. Probe runs failed on these refusals for polls,
worker operations and a Rust SDK actor's deregistration.

- Parity probes now build their SDK clients through parityClient(), whose
  default transport is the SDK's own Psr18Transport over Guzzle with an
  innermost retry middleware. The transport keeps every capability the SDK
  checks (bounded requests and payload transfer), unlike the earlier
  wrapper. Observed-transport stacks add the same middleware innermost, so
  recording middleware sees only the final reply.
- lockPressureOperationRetryDelay retries any non-poll request only on
  status 503, reason backend_lock_pressure and retryable true, within 15
  retries, 30 s of waiting and the request's own timeout. Polls keep the
  stricter rule that no task was handed out. Bodies that cannot be replayed
  are never retried. Retries are reported once on stderr.
- The Rust SDK actor retries its deregistration on the same refusal, under
  the same bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The storage audit lists the takeover gates, and the inspection commands keep
their backup, quiescence, compatibility and takeover flags false. This note
proposes what turns each one true:

- a fingerprint of the database's complete logical contents, compared
  between the stopped source and a restored backup, then checked again
  inside the takeover's own exclusive transaction before the marker is
  written;
- per-backend writer exclusion and atomicity, with a marker step journal
  where DDL commits implicitly;
- resumption of an interrupted takeover from the marker, refusal of a
  different fingerprint, and the per-backend upgrade fixtures and checks.

It records what is not yet known, such as MySQL's exclusion method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the October 20 profile host, lock-free reads every 50 ms cut MySQL's
idle cost at 24 waiting polls only from about 0.53 to 0.46 cores, and the
native process stayed near 0.28 cores. The cost is the read rate, 20 per
second per poll, not the lock.

A held poll now reads 50 ms after a probe and then at doubling intervals up
to 250 ms. A same-node wake still reads at once and starts again from 50 ms,
so same-node work is unaffected, and work committed by another server
process is observed within 250 ms. No wait runs past the poll's deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The task read now sits next to the task claim in store.rs and the query
read next to the query claim in queries.rs, inside their existing Store
impls. poll_readiness.rs keeps only the read pacing. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parity jobs write Rust test output to evidence files, so a failed
cargo test left only its exit code in the step log. An ERR trap now prints
the panics and the failed test names from those files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rmcdaniel and others added 28 commits October 20, 2026 08:32
chore: prepare Server 2.9.8 and Helm 0.1.184
This probe retries lock-pressure poll refusals itself and records each
retry, while a recording proxy outside the client logs every refused poll.
Retrying below the SDK hid those retries from the probe, so the contract
found a refused poll without its recorded retry. The probe keeps its plain
client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no lock wait, a writer that had just closed could still hold its lock
when the preparation opened the file, and the preparation refused it as in
use. A two-second wait covers that; a file another process keeps open is
still refused.

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

The upgrade source author now also writes a third original: a workflow that
opens a signal wait, then receives its declared signal under published
PHP, whose resume task stays unclaimed. After the takeover, native must
deliver that signal from PHP's rows: the resumed task keeps the original
history and the signal's PHP-written arguments, and the workflow
completes. The resume accepts the three originals in any order.

The payload inspection now also checks the codec-tagged payloads of
workflow commands, signal records and updates.

Takeover refuses a source holding rows of a published PHP feature native
does not implement yet (runtime credentials, message streams, service
definitions, update validation tasks, worker sessions, build ID rollouts
and search attribute definitions), rather than leaving them unread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Give codec work under the transition lock its own permit
The upgrade source author adds a fourth original whose worker stops after
claiming its workflow task. PHP's lease on that task expires after the
takeover. Native must then redeliver the same task as its next attempt,
refuse the expired PHP attempt's completion, and complete the workflow on
its original history.

Part of #325.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…volution-518

Allow replay-compatible workflow definition upgrades and explain routing
Two alternating PHP/native pairs on a second dedicated host repeat the
morning's matched comparison on main after #516. Native meets or beats PHP
in every window, with no drift within any run. PHP's own capacity at 1/s
differs between the two hosts, which the observations record.

Part of #325.

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

Retry the PHP reference's lock-pressure refusals in parity probes
…try-safety

Prepare Server 2.9.9 entry-safety release
Record two repeated matched PHP/native pairs after #516
…e 2.9.7 cohort

Public history now lifts each command's principal onto its event, and worker
task history has none, so the recovered terminal resumption compares the
fields both views share. The SQLite PHP 2.9.7 upgrade cohort steps set the
job's resource scope like their neighbours; the cleanup step failed without
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resume a PHP-accepted signal after takeover and refuse unsupported features
Prepare a PHP SQLite source in WAL mode for the upgrade commands
@rmcdaniel
rmcdaniel merged commit 93319a7 into rust-325-postgres-takeover Oct 20, 2026
8 of 9 checks passed
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