Skip to content

feat: atomic compare and swap via ifMatch, ifNoneMatch#772

Draft
pi0 wants to merge 5 commits into
mainfrom
feat/cas
Draft

feat: atomic compare and swap via ifMatch, ifNoneMatch#772
pi0 wants to merge 5 commits into
mainfrom
feat/cas

Conversation

@pi0
Copy link
Copy Markdown
Member

@pi0 pi0 commented May 4, 2026

This PR adds atomic compare-and-swap to the driver/storage API via HTTP-style ifMatch / ifNoneMatch preconditions on setItem and setItemRaw. Useful for atomic swaps or write-once object storage.

// Atomic create-only (write-once objects):
await storage.setItem(`/objects/${hash}`, blob, { ifNoneMatch: "*" });

// Optimistic swap (HEAD/refs):
const { etag } = await storage.getMeta("/refs/HEAD");
await storage.setItem("/refs/HEAD", newCommit, { ifMatch: etag });
// → throws CASMismatchError if HEAD changed since the read

API

  • setItem(k, v, { ifMatch?, ifNoneMatch? }) returns { etag } when CAS opts are passed; void otherwise.
  • getMeta(k) returns etag?: string on CAS-aware drivers.
  • CASMismatchError on precondition failure; CASUnsupportedError thrown upfront when CAS opts hit a driver lacking flags.cas (no silent precondition-skip).
  • Cross-bundle catches: prefer CASMismatchError.is(err) / err.code === "ERR_CAS_MISMATCH" over instanceof (driver code and bundled main each carry their own class definition by design — drivers transpile per-file, the rest of src is bundled).

Driver support

Driver Status Mechanism
memory per-key counter etag
lru-cache per-key counter etag (cleaned via dispose on eviction)
fs ifNoneMatch:"*" cross-process atomic via temp-file + link(); ifMatch single-process via per-path mutex (etag = mtime-size-ino). Cross-host needs an external lock.
redis / upstash SET ... NX for ifNoneMatch:"*"; WATCH/MULTI/EXEC for general case; content-hashed etag (SHA-1). No Lua, so works on ioredis-mock.
deno-kv / deno-kv-node native kv.atomic().check({ key, versionstamp }).set(...).commit(); versionstamp is the etag. Fast path for ifNoneMatch:"*" (single atomic check(versionstamp: null)); read-then-atomic for the rest.
mongodb insertOne for ifNoneMatch:"*" (E11000 → mismatch); updateOne with { key, _etag: ifMatch } filter for ifMatch:<etag>; SHA-1 content etag stored in _etag field; lazy unique index on key.
s3 native If-Match / If-None-Match headers; 412/409 → mismatch; etag returned unquoted. Backend support varies (R2 ✓, AWS post-2024 ✓, MinIO version-dependent). aws4fetch signs precondition headers correctly.
cloudflare-r2-binding native onlyIf: { etagMatches } / { etagDoesNotMatch } with "*" wildcard string. put() returning null → mismatch. All four CAS modes work in miniflare.
netlify-blobs native onlyIfNew: true for ifNoneMatch:"*", onlyIfMatch: <etag> for ifMatch:<etag>; ifMatch:"*" and ifNoneMatch:<etag> emulated via getMetadata + atomic etag-pinned set. (Mock BlobsServer doesn't echo etag on HEAD — 2 assertions skipped via casNoMetaEtag flag.)
vercel-blob native ifMatch on put(); ifMatch:"*" and ifNoneMatch:<etag> emulated via head() pre-check + native ifMatch write for atomicity. BlobPreconditionFailedErrorCASMismatchError.
db0 per-key in-process lock + SELECT etagcheckCASINSERT/UPDATE. New etag column added via ALTER TABLE ADD COLUMN (idempotent). Soft-breaking: existing rows have NULL etag until rewritten. SHA-1 content etag. Tested on sqlite / libsql / pglite.
planetscale mirrors db0: per-key lock + SELECT etagcheckCASINSERT/UPDATE. ALTER TABLE ADD COLUMN etag VARCHAR(64) fired idempotently (try/catch — Vitess has no IF NOT EXISTS for ADD COLUMN). On managed Planetscale, run the migration via the schema-deploy workflow.
http client forwards If-Match / If-None-Match headers, parses ETag from PUT/HEAD responses, maps 412 → CASMismatchError. Server (src/server.ts) parses headers, surfaces CASMismatchError → 412 and CASUnsupportedError → 501, sets ETag response header. ⚠️ no version negotiation: a new client sending preconditions to an old server will silently overwrite.
overlay delegates to top layer; flags.cas derives dynamically from layers[0].flags.cas so CASUnsupportedError is surfaced upfront when the writable layer lacks CAS.
azure-cosmos items.create() for ifNoneMatch:"*" (409 → mismatch); item.replace(body, { accessCondition: { type: "IfMatch", condition: <etag> } }) for ifMatch:<etag> (412 → mismatch); read-then-pinned-replace for the other shapes. Etag = the server-managed _etag field.
azure-storage-blob native conditions: { ifMatch, ifNoneMatch } on blockBlobClient.upload(); 412/409 → mismatch. Etag wire-quotes are stripped on the way out, re-added on the way in (matches s3).
azure-storage-table createEntity() for ifNoneMatch:"*"; updateEntity(entity, "Replace", { etag }) for ifMatch:<etag> and ifMatch:"*" (SDK accepts "*" as wildcard). ifNoneMatch:<etag> emulated via getEntity + conditional update.
azure-app-configuration addConfigurationSetting for ifNoneMatch:"*" (409 → mismatch); setConfigurationSetting({ ..., etag }, { onlyIfUnchanged: true }) for ifMatch:<etag> (412 → mismatch). Other shapes via getConfigurationSetting + conditional set with current etag.
indexedb ⚠️ needs raw-IDB transactions (idb-keyval doesn't expose them)
github currently read-only
azure-key-vault, uploadthing append/version-only by design
cloudflare-kv-binding / -kv-http KV is eventually consistent + has no CAS primitive — will throw CASUnsupportedError. Use D1 / Durable Objects / R2 for refs.
vercel-runtime-cache opaque cache, no metadata API
null n/a

For drivers without CAS support, callers can fall back to a sidecar lock built on ifNoneMatch:"*" + TTL.

Test plan

  • CAS test block in test/drivers/utils.ts gated by supportsCAS; covers ifNoneMatch:"*", ifMatch:<etag>, ifMatch:"*", etag-after-write
  • Negative path: drivers without flags.cas throw CASUnsupportedError
  • End-to-end CAS through http transport (server.ts ↔ http.ts, including the deno-kv test which proxies via http)
  • Full driver suite passes (530 tests, +30 CAS-specific)
  • pnpm build clean

Drivers in the ✅ tier with describe.skip test files (azure-* and planetscale) are wired and build-verified; supportsCAS: true is set so the CAS suite engages whenever those tests are un-skipped against real backends.

Draft for now — opening for feedback on the API shape, etag-scheme choices per driver, and flags.cas contract before merge.

Conditional `setItem` / `setItemRaw` writes:

  storage.setItem(key, value, { ifNoneMatch: "*" }) // create-only
  storage.setItem(key, value, { ifMatch: meta.etag }) // optimistic swap

Drivers expose `etag` via `getMeta` and throw `CASMismatchError` on
precondition failure. Drivers without `flags.cas` throw
`CASUnsupportedError` upfront so silent precondition-skip is impossible.

Implemented in: memory, lru-cache, fs (atomic create via temp+link;
single-process etag mutex), redis (SET NX + WATCH/MULTI/EXEC, content
SHA-1 etag).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a242427a-541e-4770-afdf-259f68bb1839

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cas

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Adds ifMatch/ifNoneMatch CAS to deno-kv (+node), mongodb, s3,
cloudflare-r2-binding, netlify-blobs, vercel-blob, db0, http (+server),
and overlay. Native primitives where available; emulated with
checkCAS + in-process lock where not.

- deno-kv: atomic check/set with versionstamp as etag
- mongodb: insertOne/updateOne with _etag (SHA-1) field + unique index
- s3: If-Match/If-None-Match headers, 412/409 -> mismatch
- cloudflare-r2-binding: onlyIf etagMatches/etagDoesNotMatch
- netlify-blobs: onlyIfNew/onlyIfMatch (native + emulated for *)
- vercel-blob: native ifMatch + head-then-write for other shapes
- db0: SELECT-then-write under in-process lock; new etag column
- http: forward If-Match/If-None-Match; server maps 412/501
- overlay: delegate to top layer; flags.cas derives from layers[0]
@pi0 pi0 changed the title feat: ifMatch/ifNoneMatch (CAS) support feat: atomic compare and swap support via ifMatch, ifNoneMatch May 5, 2026
@pi0 pi0 changed the title feat: atomic compare and swap support via ifMatch, ifNoneMatch feat: atomic compare and swap via ifMatch, ifNoneMatch May 5, 2026
pi0 added 3 commits May 5, 2026 09:33
- azure-cosmos: items.create + replace with IfMatch accessCondition
- azure-storage-blob: native conditions.ifMatch / ifNoneMatch
- azure-storage-table: createEntity + updateEntity with etag
- azure-app-configuration: addConfigurationSetting + setConfigurationSetting onlyIfUnchanged
- planetscale: SELECT-then-write under per-key lock; etag column

Test files for these drivers are describe.skip; verified compile + build only.
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