feat(kv): set_if compare-and-swap for KV (per-app + shared + SDK) (Track A M5)

No atomic CAS existed, so concurrent writers to the same KV key raced. Add
set_if(key, expected, new): writes only when the current value equals expected,
or (expected absent) only when the key is missing — the primitive for lock-free
counters / optimistic concurrency.

- KvService + GroupKvService traits gain set_if with a defaulted "unsupported"
  impl (Noop + other impls untouched); the real services override it.
- kv_repo/group_kv_repo: atomic set_if — a single UPDATE ... WHERE value =
  $expected (JSONB semantic equality) or INSERT ... ON CONFLICT DO NOTHING.
  Atomic without a transaction; returns whether the write happened.
- Services enforce the same value-size cap + (group) writes-authed + row/byte
  quotas as set; the event fires only on a successful swap.
- Executor Rhai SDK: kv::collection(c).set_if(key, expected, new) and the
  shared_collection variant; Rhai () for expected means "only if absent".

Pinned by kv_service/group_kv_service set_if unit tests (CAS + fail-closed) +
sdk_kv::kv_set_if_compare_and_swap (through a real script). No migration.
docs/sdk-shape.md documents the primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 15:19:03 +02:00
parent 636106e9ea
commit 0f05c270d1
9 changed files with 510 additions and 0 deletions

View File

@@ -36,6 +36,25 @@ pub trait KvService: Send + Sync {
value: serde_json::Value,
) -> Result<(), KvError>;
/// Compare-and-swap. Atomically writes `new` only if the current value
/// equals `expected` — or, when `expected` is `None`, only if the key is
/// ABSENT (insert-if-absent). Returns `true` if the swap happened, `false`
/// if the precondition failed (value differed / key already present).
/// Default: unsupported, so only storage-backed impls opt in.
async fn set_if(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
expected: Option<serde_json::Value>,
new: serde_json::Value,
) -> Result<bool, KvError> {
let _ = (cx, collection, key, expected, new);
Err(KvError::Backend(
"set_if is not supported by this kv implementation".into(),
))
}
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError>;
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError>;