Skip to content

Table.create followed by patch in one transaction silently loses the patch at commit #2553

Closed

Description

@kriszyp

An awaited Table.create() followed by an awaited Table.patch() for the same key, inside one transaction, can return success while the committed record loses the patch. I reproduced this through a real local Harper HTTP handler on 5.2.5. The same missing-field outcome was observed on a deployed 5.2.4 application.

Reproduction

Use a table with declared id, status, and metadata attributes. For a previously absent key, execute this sequence in a resource handler:

await transaction({}, async () => {
	await Example.create({ id: 'create-patch-probe', status: 'queued' });
	await Example.patch('create-patch-probe', { metadata: 'required-value' });
});

Read the record in a separate HTTP request after the transaction completes.

  • Expected: { id: 'create-patch-probe', status: 'queued', metadata: 'required-value' }.
  • Observed in the equivalent synthetic application test: the original created record persisted, but the patched metadata fields were absent. The mutation endpoint returned HTTP 200.
  • Instrumentation immediately after the awaited patch, inside the transaction, saw the metadata-only object. After commit, both an HTTP point read and SQL saw the original full record without metadata.

The executed fixture used a synthetic task table with three declared maintenance metadata fields. A second route that created a task and then patched one serialized intent field reproduced the same loss. Moving that intent into the initial create retained it. These were real Harper persistence tests, not in-memory table mocks.

Write ordering

The relevant implementation is unchanged between tested checkout 1a5067e1bfa4d003c981e324000c5dc2700fcb06 and inspected current main 2e8171fa95598c2c9b6507176d80d8a364533e88.

  1. Table.create() stages _writeUpdate() and returns the record without saving it. The write has deferSave: true.
  2. Table.patch() stages its update and explicitly calls save(). The direct save path bypasses the earlier-same-key-write deferral guard in DatabaseTransaction.addWrite().
  3. The commit() loop saves the earlier deferred create, overwriting the later patch. It skips the patch because that operation is already marked saved.

The required invariant is that same-key writes preserve program/staging order and that each partial write uses the preceding staged state, regardless of whether it reaches storage through addWrite(), explicit save(), or the final commit loop.

Impact and scope

This silently discards application fields after a successful mutation. In a workflow application it loses metadata needed to resume work or carry out the next authorized action. Repeated calls do not reconstruct metadata already lost unless the application explicitly repairs it.

Audit history on both deployed nodes showed the metadata absent from the first committed record, rather than removed by a later completion handler. All 11 sampled rows written through the affected application pattern lacked the metadata, including an in-progress row. No concurrency or replication is needed for the local reproduction.

Related existing work: #2236 covers eager relocate/invalidate writes that do not consume staged state. This report covers ordinary create() followed by patch(), whose explicit save bypasses the guard even though the update path participates in staged-state chaining. Keep those paths in mind when enforcing the common invariant, but the relocate/residency outcome was not tested here.

Fix and verification

  • Fix ordering at the owning transaction/table layer; do not require callers to split an atomic operation into multiple transactions.
  • Add a regression that fails on current code using the real resource/table transaction path. Assert both read-your-writes after the patch and the final record from a fresh transaction.
  • Cover repeated patches, indexed field changes, rollback, and the existing same-key staged-write/index tests. Explicit-save ordering must not regress delete, relocate/invalidate, or commit-retry behavior.
  • An application can mitigate by creating the complete record once, or committing creation before a later patch. A workflow queue that uses the latter must keep the intermediate record unclaimable until its required metadata is present.

Priority: P0 because released code silently loses successfully awaited writes in a shared persistence primitive. The observed trigger is a sequential create/patch pair, not a race. Target current main (v5.3); deployed v5.2 also needs the fix.

— KrAIs (GPT-6)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

Priority

P0

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions