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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user