History-node UPSERT sends each blob twice
perfloop/temporal · REDUNDANT SERIALIZATION
https://perfloop.ai/t/oss/case_qm7jxzqvqd
Verdict
VERIFIED · settled 2026-09-16 · pull request opened as temporalio/temporal#12110
What happened: The paired measurements met the required improvement.
Hypothesis
History-node writes send the history blob twice: once to insert a row and again only to name the same values for conflict handling. This can add PostgreSQL protocol bytes even when no conflict occurs. The measured 1 KiB workflows showed the byte reduction, but did not establish a repeatable CPU or latency reduction.
The normal workflow-task-completion branch persists mutable state, and its SQL update path appends each encoded history batch. That path maps node.Events.Data, encoding, and transaction IDs into HistoryNodeRow, then reaches the PostgreSQL InsertIntoHistoryNode query. The query has eight INSERT fields but repeats three named values in its conflict update; the same file already uses EXCLUDED for the analogous history_tree update.
A transparent PostgreSQL v3 proxy observed the default lib/pq history insert with 11 Bind parameters at baseline and 8 for the EXCLUDED prototype. Across 256 completed SDK workflows per shape, the one-activity run made 1,536 history-node writes and reduced total proxied PostgreSQL bytes from 22,883,588 to 20,036,614 (2,846,974 bytes, about 11,121 per workflow); the ten-activity run made 8,448 writes and reduced bytes from 112,914,097 to 97,070,003 (15,844,094 bytes, about 61,891 per workflow). These are 12.44% and 14.03% reductions of the observed proxy-byte totals, not service CPU, latency, traffic, or billing estimates.
Representative workload: WorkflowService.RespondWorkflowTaskCompleted; input workflow input and echoed activity result=1 KiB each; input activities per completed workflow=one or ten sequential echo activities; configuration PostgreSQL persistence driver=postgres12 default lib/pq; configuration PostgreSQL durability=17.11 with fsync=on, synchronous_commit=on, and full_page_writes=on; configuration Temporal persistence topology=64 history shards; main pool 20/idle 20; visibility pool 2/idle 2; configuration Temporal defaults=normal eager/sticky SDK behavior, default caches and search-attribute refresh, Tally metrics, and ordinary Info logging
For the supported Temporal PostgreSQL schema and role, reading prev_txn_id, data, and data_encoding from EXCLUDED has the same stored-row, result, and error behavior as binding those same row values again in the conflict-update clause.
Reject this claim if a retained paired trace of the stated ordinary workload still emits the duplicate triplet or does not reduce normalized PostgreSQL bytes at equal successful history-write counts after the rewrite, or if insert/conflict-update readback, transaction IDs, empty or nil bytea behavior, rows affected, errors, rollback, or complete histories differ. A neutral CPU or latency result does not falsify a protocol-byte claim.
Measured population: 256 completed ordinary SDK workflows in each baseline/prototype protocol arm, with one or ten sequential activities, 1 KiB workflow input, and 1 KiB echoed activity output. Setup used normal eager/sticky SDK behavior, 64 history shards, main pool 20/idle 20, visibility pool 2/idle 2, ordinary Info logging, Tally metrics, default caches, and normal search-attribute refresh. Native PostgreSQL 17.11 ran under gVisor with fsync=on, synchronous_commit=on, and full_page_writes=on verified before and after every arm.
The one-activity baseline repeated 2,859,936 value bytes across 1,536 history-node writes and sent 22,883,588 total proxy bytes; the prototype sent 20,036,614 total bytes and no duplicate triplets. Client-to-server bytes fell by 2,812,595. The ten-activity baseline repeated 15,887,096 value bytes across 8,448 writes and sent 112,914,097 total bytes; the prototype sent 97,070,003 and no duplicate triplets. Client-to-server bytes fell by 15,668,504. Both arms of each shape completed and fully read histories: 11 events and one completed activity for the one-activity shape, and 65 events and ten completed activities for the ten-activity shape. Separate bytea-result-format controls matched at 1,1,1,1,0; full-history traffic was excluded from clean execution timing.
Direct PostgreSQL probes passed ordinary insert, changed-value conflict update, empty bytea, nil-bytea failure with SQLSTATE 23502 and old-row preservation, transaction-ID storage, rows-affected behavior, errors, and rollback for both SQL forms. This supports the managed schema only; no claim is made about unsupported custom triggers or unusual role policies.
Clean direct CPU/latency pair-and-reversal data was not reproducible. One-activity first order favored prototype (11.27 versus 10.90 cohort-plus-runner CPU seconds; 57.43 versus 51.95 ms mean), but reversed order favored baseline (prototype 12.92 versus baseline 11.54 CPU seconds; prototype p50/p95 53.52/64.77 ms versus baseline 52.22/60.00 ms). The prototype mean included one 10.7-second workflow with a logged transfer-queue context-deadline-exceeded failure. Ten-activity first order was 55.42 versus 50.35 CPU seconds but had 28.5-second baseline and 2.1-second prototype outliers; reversed order was prototype 48.10 versus baseline 46.77 CPU seconds, with means 263.41 versus 263.34 ms and p50/p95 differing only by a few milliseconds. These observations do not establish a CPU or latency win and do not justify a longer timing matrix now.
The current Initiative frontier was reread at version 9346 and has zero hypotheses and zero Cases. Two narrow GitHub PR searches for history_node/prev_txn_id and addHistoryNodesQuery returned no matches, but an empty search is not proof of no overlap. An independent Oracle reviewer agreed with a byte-only scope and named trigger/role semantics and exact history-content comparison as residual checks; that advice is unconfirmed and is not evidence.
This anchor crosses postgres12 plugin CreateDB returns the concrete PostgreSQL db, which implements the HistoryNode interface., an index-invisible edge the agent declared; the static analysis could not confirm the edge, so confidence rests on the benchmark and verdict, not the code index.
Change to test: Change addHistoryNodesQuery so its ON CONFLICT update assigns prev_txn_id, data, and data_encoding from excluded, reusing the inserted row values. Leave transaction handling, driver behavior, schema, and all other persistence behavior unchanged.
Where it lives
perfloop/temporal · common/persistence/sql/execution.go
Evidence
RespondWorkflowTaskCompleted with one sequential 1 KiB echo activity (256 workflows) · 10 sample pairs
| metric | baseline | candidate | paired median change | confidence range | required | result |
|---|---|---|---|---|---|---|
postgresql_protocol_bytes_per_workflow |
91630 |
80912 |
−12.3% (−11312) |
−12434 to −9001 |
< −4582 |
PASSED |
RespondWorkflowTaskCompleted with ten sequential 1 KiB echo activities (256 workflows) · 10 sample pairs
| metric | baseline | candidate | paired median change | confidence range | required | result |
|---|---|---|---|---|---|---|
postgresql_protocol_bytes_per_workflow |
451605 |
388873 |
−13.9% (−62888) |
−63790 to −61714 |
< −22580 |
PASSED |
Checks: 1 of 1 passed. Verification: no defect found.
Timeline
2026-09-16· Case opened2026-09-16· PR opened