Skip to content

Upsert: replace optimistic retry with a single row-lock linearization #164

Description

@pathscale

Context

From the 2026-07-27 concurrency-fix series (patch 0003). Generated upsert had a check-then-act race: pk_map.get(&pk) then update()/insert(), so a concurrent delete between check and update made upsert return NotFound (observed ~5% of full-suite runs via tests/worktable/vacuum.rs::vacuum_parallel_with_upserts).

Current state (correct, but not the ideal design)

The fix in gen_table_upsert_fn (codegen/src/generators/{persist,in_memory}/table/impls.rs) is an optimistic retry loop:

  • update() hits NotFound → retry as insert.
  • insert() hits WorkTableError::PrimaryAlreadyExists (typed variant added for this) → retry as update.
  • Secondary unique-index conflicts propagate (retrying can never succeed).

Convergence argument: a retry is only taken when a concurrent delete/insert flipped the key's existence between the check and the operation, so the loop cannot spin without the key changing state each iteration. Measured 0/100 full-suite failures. But it has no linearization point, and a review flagged theoretical starvation under adversarial same-key churn.

The better design

Now that lock acquisition is atomic (LockMap::get_or_insert_with, patch 0001), the lock map can hand out a row lock for a pk whether or not the row exists. The clean upsert:

  1. Acquire the full row lock for pk once.
  2. Check pk_map under that serialization.
  3. Perform insert-or-update while holding it.

Blocker: update() and insert() internally acquire the same row lock, so a lock-holding upsert deadlocks against its own callees. The prerequisite is factoring non-locking inner variants (e.g. update_locked/insert_locked) that the public methods and upsert both wrap. delete() also takes the row lock, which is exactly why the held-lock upsert excludes the racing delete.

Verification hints

vacuum_parallel_with_upserts (3000 upserts racing a delete task) is the stress test; it must stay 0/N. Also re-check PrimaryAlreadyExists remains reachable from plain insert() (tests match on it).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions