Skip to content

fix: delete retained payloads after pruning commits - #911

Merged
rmcdaniel merged 6 commits into
mainfrom
fix/789-retention-rollback-20261101
Nov 1, 2026
Merged

rmcdaniel merged 6 commits into
mainfrom
fix/789-retention-rollback-20261101

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Retention could delete provider bytes, fail on a later provider operation or database update, and roll back references to objects that were already gone. Record deletion intentions with released references, commit pruning, then remove provider bytes. Failed cleanup remains inspectable and retryable, including after namespace deletion using the saved storage policy.

Use one retained-source inventory across collection, namespace cleanup and retry fencing. It covers committed run/stream state, instance memo and labels, all schedule fields and pre-run schedule history, service catalogs, target-owned dispatch snapshots and validation tasks. Canonical object locks precede the scan and backup hold; new ownership preserves bytes. Typed already-absent custom-provider deletion settles replay. Namespace deletion and explicit cleanup report HTTP207 while provider work remains; the command reports pending work with a nonzero exit.

Merged through the protected queue as fa3fc2f2f6b8935ea4a6eea47f6a9221e2adafff and delivered in PHP Server2.11.8, with successful image and matching chart publication.

Validation:

  • Exact Security acceptance and maintainer review; all required PR/merge-group checks pass. Full hosted feature/Nexus/corpus3548tests/75657assertions,0errors/failures,51backend skips and6deprecations; TLS refusals pass.
  • Eight complete local integration files197/2685, all52controlled DB interleavings enabled and zero skips. Boundary class28/293 on each of SQLite,MySQL8,MariaDB through both drivers and PostgreSQL17. Complete release/selector contracts141/1629; style passes17changedPHP files. The namespace query ceiling remains unchanged.
  • Actual published-image retention acceptance: success reclaims both objects; first/second provider failure keeps committed pruning and converges pending cleanup; a real later SQL failure preserves references and both bytes. Fresh-process recovery after namespace deletion converges2pending/2objects tozero using persisted policy.
  • Published ownership/import/poll/signing controls also pass. Every installed cell verifies369Server files and2259committed Core2.15.12 files before/after,12Core class origins, and the declared generated provenance. No app/package source overlay; local installed scopeamd64/PHP8.3.36/SQLite3.46.1.

This delivers the rollback-integrity repair in #789. Its original SDK/Nexus ingress/recovery, caller-completion409 and pre-kill evidence remain open in that issue. No Cloud deployment is claimed.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Early Security protocol/source review for exact head 152dea51db8ed49d0f3e15e1e9b8e8c8cb823e56: the committed-prune/outbox direction fixes the confirmed rollback-integrity defect, but its generic lost-ack recovery claim has one concrete extension-contract gap.

A durable outbox must replay delete() when the provider removed the object but the acknowledgement or DB row deletion was lost. The public Workflow\\V2\\Contracts\\ExternalPayloadStorageDriver::delete() contract currently says only to delete previously persisted bytes; unlike put(), it does not require idempotency or require an already-absent object to succeed. #911 retries that method after lost acknowledgement.

I exercised an isolated allowed RuntimeExternalPayloadStorageDriver on this exact head. Its first delete() removed the synthetic object and threw a lost-ack error; its second call reported that the object no longer existed. Exact result: two provider attempts and one durable runtime_external_payload_deletions row still pending after retry. The synthetic counterfactual passed 1 test / 4 assertions because it asserted that observed non-convergence. Fixture SHA-256: 9188d813c73e74047696b516a63ee6cf5e991971c2806eb7609b7995c874a4ea.

Beside it, the PR test test_lost_cleanup_acknowledgement_is_retried_without_restoring_pruned_references passed 1 test / 7 assertions because the built-in local driver already treats deletion of a missing object as success. That proves the implementation for the built-in path, not the public custom-driver contract.

Required contract correction for this PRs stated recovery guarantee: make replay-safe deletion explicit in the public driver contract (an already-absent object succeeds), document the compatibility requirement for custom drivers, and add a custom-driver behavioral control that proves a lost acknowledgement converges without restoring pruned references. If the contract is intentionally narrower, the API/status/docs must state that non-idempotent custom drivers can remain pending and require operator recovery rather than claiming generic durable retry.

This is not a hold on unrelated work, backend qualification, Rust, RCs, or publication. I found no evidence that the new outbox loses database references before commit, and the existing built-in path passed. Reviewed runtime file SHA-256: 135d16b60eb96017c9edd6152775f7374fb2e084f02ec2fab75d87c146a1a716; PR rollback test SHA-256: 96d5278a55bb01c482bf35346534c2f05db3fa8cb3ec74015b9ff656d05388a6.

@rmcdaniel

Copy link
Copy Markdown
Member Author

A second exact-head boundary is confirmed for the same new eventual-cleanup contract. On 152dea51, I deleted a synthetic namespace whose provider deletion fails after the database transaction commits. The API returned HTTP 200 with status: deleted and deleted.runtime_external_payload_references: 1, exposed no external_payload_cleanup field, removed the namespace row, retained one durable deletion intention, and left the provider object readable.

That response was accurate about the namespace/catalog deletion but silent about unfinished provider-data deletion. Before #911, a provider failure kept the namespace and returned an error; #911 intentionally changes that to committed deletion plus retry. The namespace-delete response now needs the same truthful pending/failed/blocked cleanup evidence as the retention and explicit cleanup paths, with a non-success-complete signal such as HTTP 207 while cleanup remains pending. Otherwise an operator cannot distinguish complete deletion from eventual cleanup using the destructive request result.

Isolated fixture: 1 test / 7 assertions passed, SHA-256 8f960a617ea0880ea24e5b868ccb0f1ed8d331e4cc9f9e101688872b3435956e. It used only synthetic bytes and an in-memory database; no production/provider operation occurred.

Please add the namespace response/status regression alongside the in-progress namespace recovery controls. This is scoped to #911s changed behavior and does not hold unrelated work or continuing backend qualification.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Exact-head follow-up on 3bacb518bc0668a5a4f958b26bfb11285a8d516a: the new legacy-reference fence is incomplete for schedule state.

I staged two pending deletions, then created a retained workflow_schedules row whose memo contained a structurally valid durable-workflow.v2.external-payload-reference.v1 for one object, with no registry row to model legacy/imported state. After retryPending(), both provider objects were gone, both deletion intentions were cleared, and the schedule row still retained the now-broken reference. The isolated counterfactual passed 1 test / 6 assertions because it asserted that observed deletion.

The cause is concrete in the retry path: retainedReferences() scans only WorkflowSchedule::action, while this same class already recognizes spec, action, memo, search_attributes, visibility_labels, recent_actions, and buffered_actions as reference-bearing schedule columns during namespace cleanup. The new durable-stream legacy control therefore proves one source, not the full retained-source contract.

Please make retry fencing use the same complete retained-source inventory (or one shared inventory) and add at least the schedule-memo regression so a later column addition cannot silently diverge. Synthetic diff SHA-256: acb731bcea7c51fac9ed8249ad0d29d4a2e24954c292625a0d958a53f7cfebac; reviewed cleanup source SHA-256: 4063e36aa4f68d08633a8630f7244e627343eceb2322643d887379d59ca9ae6d.

This finding is scoped to #911s new retry behavior. It does not hold unrelated work, Rust, backend qualification, or publication.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Exact-head retained-source follow-up on 3bacb51: workflow instance metadata is another reproducible hole in the new retry fence.

I staged two pending deletions by pruning under a synthetic provider outage, then inserted a retained workflow_instances row whose memo held a structurally valid external-payload reference to one candidate, with no registry row. After provider recovery and retryPending(), both provider objects were gone and both intentions were cleared, while the retained instance memo still pointed at the now-missing URI. The isolated counterfactual passed 1 test / 7 assertions because it asserted that observed breakage.

Published Workflow 2.15.12 permits JSON-like nested memo values and copies start memo into workflow_instances.memo. The retry inventory scans run, typed-memo, history and schedule state but not WorkflowInstance::memo. The namespace-cleanup inventory likewise accepts instance IDs but has no workflow_instances source, so an instance-only retained/imported copy is neither fenced nor collected.

Please include workflow_instances memo (and any other genuinely reference-bearing instance metadata established by the shared inventory) in the same unified source list requested in the prior schedule finding, with a regression showing a pending retry cannot break a retained instance reference. Synthetic diff SHA-256: 42212148501007f4c61a108997ed8d913631e2ffcfb63088af67ed0557571b8d. Reviewed cleanup source SHA-256 remains 4063e36aa4f68d08633a8630f7244e627343eceb2322643d887379d59ca9ae6d.

This extends the existing #911 retained-source correction target. It is not an unrelated hold on Rust, backend qualification, RC work, or publication.

@rmcdaniel

Copy link
Copy Markdown
Member Author

One more exact source in the same retained-inventory correction is now reproduced: schedule-history payloads with no workflow run.

On 3bacb51 I staged two pending deletions, inserted a normal-shape ScheduleCreated history row with workflow_run_id null and a valid external reference under payload.action.input, then retried after provider recovery. Both objects were deleted and both intentions cleared while the schedule-history row remained with the broken reference. The isolated counterfactual passed 1 test / 7 assertions because it asserted that result; diff SHA-256 8ab67719c1457eaf0f04b95006cea1d7d754a14af80191719b211ceedbe8a9e2.

Published Workflow 2.15.12 records ScheduleCreated and ScheduleUpdated snapshots with spec/action before any run exists, so null-run schedule-history rows are normal, not only synthetic legacy data. retainedPayloadColumns currently limits schedule history to retained run IDs, excluding those rows, even though namespace cleanup already treats all schedule-history payloads in the namespace as reference-bearing.

Please cover null-run schedule-history payloads in the shared retained-source inventory and regression set. This is part of the existing #911 source-completeness finding, not a new unrelated gate.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security exact-head finding on 8640eefc59a3e2bc23016601c6befc0cfdf650fb: the retained-source inventory still omits both accepted service binding snapshots.

Workflow 2.15.12 treats these as live payload owners: WorkflowServiceOperation::handler_binding may carry payload_blob; ServiceCallScheduler::persistableOptions() freezes that binding into WorkflowServiceCallDispatch::dispatch_options; and DefaultServiceControlPlane later resolves a string payload_blob against the target namespace. Neither workflow_service_operations.handler_binding nor workflow_service_call_dispatches.dispatch_options is scanned by retainedReferences() / the namespace source inventory.

Two isolated synthetic controls on this exact head each established a resolvable stored reference before pruning, shared it with the closed run, then observed the retention pass report both provider objects deleted while the independent owner row and exact stored reference remained:

  • catalog operation binding: 1 test, 7 assertions, pass while asserting the retained broken reference;
  • frozen scheduled dispatch binding after the catalog default no longer owns it: 1 test, 7 assertions, pass while asserting the retained broken reference.

Both ran separately with --network none against the locked dependencies and durableworkflow/server:2.11.7; test-only diff SHA-256: 56b6b269797fb5cbdf42b7a2abbedbd552b5d54af7b5696280d7766fb02c153e.

Impact: pruning one run can delete provider bytes still required by a catalog default or an already accepted/scheduled attempt. The correction needs both the current catalog binding and the immutable dispatch snapshot in the retained-source inventory (with appropriate namespace ownership/join semantics). This is scoped to #911; no unrelated work or publication is held.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Implemented the remaining demonstrated retained-owner corrections at 494c0f6724fc6e207e110ece72ccb42e486de164 (source change 47ecf2880, merged current main cbf1937d). Collection and both retained fences now consume one source inventory, including instance memo/labels, null-run schedule history, service-operation bindings and target-owned frozen dispatch snapshots. Namespace deletion removes dispatches and validation tasks with their owners.

Ten added controls cover pre-prune and retry ownership, cross-namespace byte preservation, and actual owner cleanup. The complete eight-file integration run passes 197 tests / 2,685 assertions / zero skips, including all 52 controlled database interleavings. Tested source-file hashes match the pushed head. Normal published Core 2.15.12 is unchanged across all 2,259 files and 12 runtime origins before/after. The namespace query ceiling stays unchanged and passes.

The earlier namespace HTTP 207/evidence, seven schedule fields and custom typed-absence replay corrections were accepted by Security. Final-head database/restart/release-contract qualification and exact Security review remain in progress; ordinary CI is active. The PR remains draft, and no published-image acceptance is claimed.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security delta review complete for exact head 494c0f6, whose runtime correction is 47ecf28. The later merge from current main adds no PHP runtime delta to this correction.

The shared referenceSources inventory now drives candidate collection, namespace retained fencing, and post-commit retry fencing. I checked the previously demonstrated gaps specifically: workflow_instances memo/visibility labels; namespace-wide schedule history including null-run rows; service-operation handler bindings; and dispatch_options through the service-call target-owned namespace join. The candidate OR scopes and retained complement scopes match the rows NamespaceLifecycleCleanup removes, including service-call dispatches and update-validation tasks. The added pre-prune/retry controls also exercise preservation across namespaces followed by actual owner cleanup.

Local source checks: git diff --check passes; the four reviewed file SHA-256 values are aa18c08568822e7bba3983ba96844cf716d821b36390f1f7e8113df537d9fffa, 032418eac361c42446e5a4de6383c51551abf5b7fda3776fd96eb232574abd1e, 5b54e4d0f6778bf54b47319701ccb73847bd9ce5e0abd5ec7672e55f62ce38a7, and 334ff144d39f3a2e6645d7fbadfe43187bd4291e717170d75bd5702c3c99de71 respectively. GitHub reports zero open code-scanning alerts for this PR. I found no new security defect in this delta and no Security source hold remains. The PR is still draft and exact-head CI is still running; those remain ordinary owner/merge gates, not Security blockers.

@sonarqubecloud

sonarqubecloud Bot commented Nov 1, 2026

Copy link
Copy Markdown

@rmcdaniel rmcdaniel left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer review of exact 494c0f6724fc6e207e110ece72ccb42e486de164, incorporating current main cbf1937d. The post-commit deletion protocol preserves committed reference ownership, canonical object-lock/backup-hold order, per-intent failure isolation, saved-policy recovery and idempotent typed-absence acknowledgement. The single inventory and complementary namespace scopes include all demonstrated retained owners; primary pruned metadata is excluded. Security accepted this exact delta in comment6557180416.

Actual qualification on November 1 UTC:

Profile Retention cases Assertions Outcome
SQLite 3.40.1, matching-source integration subset 28 293 pass, no skips
MySQL 8.0.46 28 293 pass, no skips
MariaDB 10.11.19, native driver 28 293 pass, no skips
MariaDB 10.11.19, mysql driver 28 293 pass, no skips
PostgreSQL 17.11 28 293 pass, no skips

Eight full integration files pass197/2685 with all52 database races enabled and zero skips (11:08:07–11:09:45; every tested source hash matches the reviewed head). Four final-head external DB profiles completed11:10:26–11:17:34. All three full release-image contract files plus published-PHP selectors pass141/1629 (11:17:34–11:17:52). A real second process recovers both saved-policy intentions after namespace deletion (HTTP207, namespace/configuration absent, 2 pending/files ->0 pending/files;11:17:52–11:17:58). All2259 publishedCore2.15.12 files/12 runtime origins match before/after; style passes17 changedPHP files. The existing namespace query bound remains unchanged.

Exact-head Sonar at11:10:50: gateOK,0BUG,0VULNERABILITY,0hotspots to review;8CODE_SMELL findings remain open. Specific maintenance disposition: the three complexity findings cover the ownership/grouped-scan loops and namespace release coordination, retained together to keep the complete inventory and lock ordering inspectable; functional ownership/race and query-cap controls pass. The three generic RuntimeExceptions reject absent backup coordination, staging outside a transaction, and ambiguous intent identity before deletion; they are internal invariant failures, not successful cleanup. The five-return factory uses explicit disabled/local/filesystem/unsupported branches. The unused private namespace parameter has no behavioral effect. These are accepted maintenance debt for this P1 repair, not false positives or functional/security failures; no gate/check was disabled or waived.

Ordinary hosted PHP/performance checks are still active. This review supports ready/enqueue through the normal protected gates; it does not claim a merged change, fixed published image or installed acceptance.

@rmcdaniel
rmcdaniel marked this pull request as ready for review November 1, 2026 11:22
@rmcdaniel
rmcdaniel enabled auto-merge November 1, 2026 11:22
@rmcdaniel
rmcdaniel added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit fa3fc2f Nov 1, 2026
26 checks passed
@rmcdaniel
rmcdaniel deleted the fix/789-retention-rollback-20261101 branch November 1, 2026 12:00
rsguimaraes-pe pushed a commit to rsguimaraes-pe/server that referenced this pull request Nov 13, 2026
… the prune commits

Run retention deleted a pruned run's provider objects inside the prune's
transaction, so a later SQL or commit failure rolled back the registrations
but not the deleted bytes (Security, durable-workflow#325 comment 6555755439). Native now
follows published Server 2.11.8 (durable-workflow#911, durable-workflow#789): a prune records each object's
deletion in its transaction, under the object lock, with the namespace's
storage policy, and deletes the object only after the commit. A provider
failure leaves the deletion pending; the retention and cleanup passes retry
it first, and a new registration of the object fences it.

Schema version 8 adds the native-owned dw_external_payload_deletions on
SQLite, PostgreSQL, MySQL and MariaDB, with catalogs captured from MySQL
8.0.46, MariaDB 10.11.19 and PostgreSQL 17.11. The retention pass reports
external_payload_cleanup; the cleanup routes report retention_cleanup and the
pass answers 207 while a deletion is pending.

Namespace delete now settles the namespace's external payloads (from durable-workflow#908)
on the same model: both passes record deletions, the answer carries
external_payload_cleanup and is 207 while any is pending, and a cleanup pass
also retries deletions of deleted namespaces, which native has no scheduled
command to reach.

Regressions mirror Security's counterfactual: a disposable trigger aborting
the registration delete rolls back the prune or namespace delete with the
object, registration and state intact. Both fixtures pass unchanged against a
fresh published 2.11.8 harness; the namespace delete probe also retries the
SQLite reference's backend_lock_pressure refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rsguimaraes-pe pushed a commit to rsguimaraes-pe/server that referenced this pull request Nov 13, 2026
…bases

Server 2.11.8 (Core 2.15.12) was published today, and native refused every
2.11.8 database as unqualified. Core 2.15.12 adds no migration; 2.11.8's
only change is Server's runtime_external_payload_deletions table (durable-workflow#911,
durable-workflow#789). Two layouts record it, php-2.11.8 from php-2.11.3 and
php-2.11.8-from-2.9.7 from php-2.11.3-from-2.9.7. The deltas were captured
from real published-image databases on SQLite, MySQL 8.0, MariaDB 10.11 and
PostgreSQL 17 (fresh, and 2.9.7 migrated), and native's upgrade-inspect
then named every real 2.11.8 database by its layout.

Takeover carries each pending deletion over to native's
dw_external_payload_deletions with its id, object, saved policy, attempts,
failure and times, so native retries it as PHP does; it deletes no object,
then drops PHP's table. Inspection reports
external_payload_deletion_inspection, and takeover refuses before any
change while a pending deletion names a storage driver native does not
serve or a failure PHP does not record. MySQL and MariaDB run it as
resumable journal step 11. A php2118 CI cohort takes over a published
2.11.8 source end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant