Repository navigation
Write a workflow poll's binding and answer in one upsert (#1063) - #1166
Merged
Merged
Conversation
remember_workflow_poll wrote the binding row and then the answer row with two sequential upserts inside the claim transaction, after bind_workflow_poll already holds the binding's lock. Both now go in one QUERY_CACHE_PUT_PAIR statement (ON CONFLICT on PostgreSQL/SQLite, ON DUPLICATE KEY UPDATE on MySQL/MariaDB; the key is the table's only unique key), dropping one locked round trip per remembered poll answer. Keys, JSON values and the two retention rules are unchanged: the binding keeps result_ttl, a workflow lease's answer keeps 3600 s and any other answer keeps the binding's ttl. Each expiry is still the clock read just before the statement that writes the row plus that row's ttl; one statement now writes both rows, so they share that one reading. The sequential writer read the answer's clock after the binding's round trip, so its answer could expire up to one whole second later when that round trip crossed a second boundary. The pair can only leave the answer at the earlier of those values, which the sequential writer also produces whenever no boundary is crossed, and the binding still never outlives the answer. The common backend qualification (SQLite, PostgreSQL, MySQL) compares the pair statement with the two sequential upserts for every existing-row combination, checks a neighbouring key is untouched, that a rolled-back transition restores committed rows exactly or leaves none, and that the caller issues one statement whose two expiries differ by exactly the two ttls' difference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Member
Author
|
Decision (General, 2026-11-12): accepted. This reverses #1099's deferral of the paired cache write. The only semantic change is that a remembered poll answer can now expire up to one second earlier, when the old code's second clock read crossed a second boundary. An earlier-expiring cache answer only causes a recompute, never a wrong answer, and the binding still never outlives the answer. One fewer locked |
|
This was referenced Nov 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Part of #1063 (Native: drop locked statements whose answers the transition already has). This PR covers the one checklist item that is still open: writing a poll's binding and result cache rows in one two-row upsert (
639b6854in the report).Background
The server agent's saved branch
wip/server/20261112-0922/feat/server-1063-transition-reads(780d0aae) turned out to be the #1099 branch exactly.git cherry origin/mainmarks all 13 of its commits as already upstream (merged asa5498678, released in Native 3.3.1). Nothing from it still needed rebasing or finishing. That branch also contains2e11e8f1, which reverted this item in #1099. This PR starts again from currentmain(6e1d1028, which includes #1016 and #980) and adds the pair writer back, with a different design for the expiry origin.Change
remember_workflow_pollremembers a workflow poll's answer. It used to run twoQUERY_CACHE_PUTupserts one after the other inside the claim transaction: first the binding row ({"task_kinds":…}), then the result row. It now runs a singleQUERY_CACHE_PUT_PAIR:INSERT … VALUES ($1,$2,$3),($4,$5,$6) ON CONFLICT ("key") DO UPDATE SET value=excluded.value,expiration=excluded.expirationON DUPLICATE KEY UPDATE value=VALUES(value),expiration=VALUES(expiration).keyis the table's only unique key, so this behaves exactly like the existing single-rowQUERY_CACHE_PUT.Statement dropped: one locked
dw_query_cacheupsert for every remembered workflow poll answer. That covers leased claims (store.rs), carried claims (carried_polls.rs), update-validation leases and empty ortask_queue_changedanswers. The caller test measures 2 → 1 statements.Why it's safe
Same rows. Keys, JSON values and retention rules are unchanged. The binding keeps
result_ttl. A workflow lease's answer is kept for 3600 s, and any other answer for the binding's ttl. The qualification compares the pair statement with the two sequential upserts byte for byte: same value and same expiration for both rows. It does this for each existing-row state (none, binding only, result only, both) and for escaped and non-ASCII JSON. It also checks that a neighbouring key is left alone.Locking and ordering. Every caller has already taken the binding row's lock through
bind_workflow_poll(QUERY_CACHE_LOCK) earlier in the same transaction. The pair statement writes the rows in the same order as before (binding, then result). No new lock order is introduced, and duplicates of the same request ID remain serialized on the binding.Rollback. Both rows are written in the caller's transaction. The qualification commits old rows, rolls back a pair write, and checks that both committed rows come back with the same value and expiry, or that no row exists where there was none.
Expiry origin. This is why Reduce Native poll and completion database work #1099 reverted the item. Each expiry is still
epoch()+ that row's ttl, read just before the statement that writes the row, the same as each of PHP's twoputs reads its own clock. The difference is that one statement now writes both rows, so they share one clock reading. The old sequential writer read the result's clock after the binding's round trip. Expiration is stored in whole seconds, so the two writers differ only when that round trip crosses a second boundary. In that case the sequential writer stored the answer's expiry one second later.binding expiry ≤ result expirystill holds, which is the invariantassert_workflow_poll_binding_expiry_for_testguards.The caller test pins this down. It runs one statement, checks each expiry falls within
[before+ttl, after+ttl], and checks thatresult − binding == kept − ttlexactly. That last check fails if the two rows ever drift onto different clock readings or swap retentions.Reviewer decision needed: the issue currently says the pair writer stays deferred "to preserve the binding and result writes' separate expiration-time origins". With a single statement, those origins can't be kept apart. This PR's position is that the sub-second shift described above is not a contract change. If you disagree, close this PR and the item stays deferred. Nothing else depends on it.
No schema, protocol or capability change. No benchmark speedup is claimed.
Tests
Run locally in the CI toolchain image (
mirror.gcr.io/library/rust@sha256:ba81bc3e…, Rust 1.99.0), withCARGO_BUILD_JOBS=2and--cpus=2 --memory=8g. Throwaway databases ran on a private docker network. I tested the commit on6e1d1028and then rebased it ontod041f5ba; that commit only changesdocker-compose.published.yml, and the Rust tree is identical.cargo test --locked --offline --all-targets(lib, main,execution,upgrade_inspect, examples)--lib runtime::postgres::tests::qualification_ -- --ignored--test execution(DW_EXECUTION_POSTGRES_URL)--lib runtime::mysql:: -- --ignored --skip runtime::mysql::tests::bootstrap_child--test execution(DW_EXECUTION_MYSQL_URL, 3 shards like CI's sharding)qualification_poll_cache_single_statement_writes, which runs the newqualify_workflow_poll_pair_writeson the real database, passed on PostgreSQL and MySQL. On SQLite,sqlite_poll_cache_writes_use_one_statement_and_rollbackruns it.*
upgrade_inspectneeds CI's mount profile (DW_UPGRADE_TEST_READONLYas a read-only alias of the writable dir). It failed once because my local setup was wrong, then passed with the correct alias.** In the first PostgreSQL lib run, 36 passed and
qualification_concurrent_bootstrap_and_reopenhit a55P03lock timeout under host I/O contention. It passed when run alone, and on a full rerun all 37 passed. Separately,qualification_poll_transaction_countsprints adebug_assertpanic ("claimed run history sequence changed before its first page") yet still reports ok. It does the same on unmodifiedmain, so it is not related to this change.*** Two
backup_holdtests start the server binary. My first sharded run didn't mount it, so they failed; with it mounted, both passed.Local environment notes: the host was I/O-saturated by other jobs. To keep the run time reasonable, the throwaway PostgreSQL ran with
fsync=offandsynchronous_commit=off, and MySQL ran on tmpfs withinnodb_flush_method=nosync, doublewrite off andinnodb_flush_log_at_trx_commit=2. These settings only affect server crash durability, which none of these tests exercise. The CI matrix (including MariaDB 10.11, PostgreSQL 17 and MySQL 8.4) still needs to run on this PR.🤖 Generated with Claude Code