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

@@ -5,6 +5,8 @@
//! widgets.set("k", #{ n: 1 });
//! let v = widgets.get("k"); // value or () if absent
//! if widgets.has("k") { ... }
//! widgets.set_if("k", (), #{ n: 0 }); // insert only if ABSENT -> bool
//! widgets.set_if("k", old, new); // swap only if current == old -> bool
//! widgets.delete("k"); // bool (was-present)
//! let page = widgets.list(); // returns #{ keys: [...], next_cursor: () }
//! ```
@@ -105,6 +107,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
register_get(engine);
register_set(engine);
register_set_if(engine);
register_has(engine);
register_delete(engine);
register_list(engine);
@@ -114,11 +117,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
register_group_get(engine);
register_group_set(engine);
register_group_set_if(engine);
register_group_has(engine);
register_group_delete(engine);
register_group_list(engine);
}
/// Map a Rhai `expected` argument to the CAS precondition: Rhai unit `()` means
/// "expected ABSENT" (insert-if-absent); any other value is the expected current
/// value.
fn expected_from_dynamic(expected: &Dynamic) -> Option<serde_json::Value> {
if expected.is_unit() {
None
} else {
Some(dynamic_to_json(expected))
}
}
fn register_get(engine: &mut RhaiEngine) {
engine.register_fn(
"get",
@@ -145,6 +160,27 @@ fn register_set(engine: &mut RhaiEngine) {
);
}
fn register_set_if(engine: &mut RhaiEngine) {
// `handle.set_if(key, expected, new)` — CAS. `expected = ()` means "only if
// absent". Returns `true` if the swap happened, `false` if the precondition
// failed.
engine.register_fn(
"set_if",
|handle: &mut KvHandle,
key: &str,
expected: Dynamic,
new: Dynamic|
-> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let exp = expected_from_dynamic(&expected);
let new = dynamic_to_json(&new);
block_on("kv", async move {
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
})
},
);
}
fn register_has(engine: &mut RhaiEngine) {
engine.register_fn(
"has",
@@ -243,6 +279,24 @@ fn register_group_set(engine: &mut RhaiEngine) {
);
}
fn register_group_set_if(engine: &mut RhaiEngine) {
engine.register_fn(
"set_if",
|handle: &mut GroupKvHandle,
key: &str,
expected: Dynamic,
new: Dynamic|
-> Result<bool, Box<EvalAltResult>> {
let h = handle.clone();
let exp = expected_from_dynamic(&expected);
let new = dynamic_to_json(&new);
block_on("kv", async move {
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
})
},
);
}
fn register_group_has(engine: &mut RhaiEngine) {
engine.register_fn(
"has",

View File

@@ -52,6 +52,27 @@ impl KvService for InMemoryKv {
Ok(())
}
async fn set_if(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
expected: Option<Value>,
new: Value,
) -> Result<bool, KvError> {
let mut data = self.data.lock().await;
let k = (cx.app_id, collection.to_string(), key.to_string());
let matches = match (&expected, data.get(&k)) {
(None, None) => true,
(Some(exp), Some(cur)) => exp == cur,
_ => false,
};
if matches {
data.insert(k, new);
}
Ok(matches)
}
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
Ok(self
.data
@@ -167,6 +188,27 @@ async fn kv_set_then_get_round_trip() {
assert_eq!(body, json!({ "n": 1 }));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_set_if_compare_and_swap() {
let engine = make_engine();
let app = AppId::new();
// `set_if(key, (), new)` inserts only when absent; `set_if(key, expected,
// new)` swaps only on match. Returns a bool each time.
let src = r#"
let c = kv::collection("counters");
let first = c.set_if("n", (), 1); // absent -> inserts, true
let dup = c.set_if("n", (), 2); // present -> false
let bad = c.set_if("n", 99, 3); // mismatch -> false
let good = c.set_if("n", 1, 3); // match -> swaps, true
#{ first: first, dup: dup, bad: bad, good: good, final: c.get("n") }
"#;
let body = run_script(engine, src, baseline_request(app)).await;
assert_eq!(
body,
json!({ "first": true, "dup": false, "bad": false, "good": true, "final": 3 })
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kv_get_missing_returns_unit() {
let engine = make_engine();