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:
@@ -186,6 +186,59 @@ impl KvService for KvServiceImpl {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_if(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
expected: Option<serde_json::Value>,
|
||||
new: serde_json::Value,
|
||||
) -> Result<bool, KvError> {
|
||||
validate_collection(collection)?;
|
||||
let encoded_len = serde_json::to_vec(&new)
|
||||
.map(|v| v.len())
|
||||
.map_err(|e| KvError::Backend(format!("encode value: {e}")))?;
|
||||
if encoded_len > self.max_value_bytes {
|
||||
return Err(KvError::ValueTooLarge {
|
||||
limit: self.max_value_bytes,
|
||||
actual: encoded_len,
|
||||
});
|
||||
}
|
||||
self.check_write(cx).await?;
|
||||
// `insert` when the precondition was "absent", else `update`. The op is
|
||||
// only emitted on a successful swap.
|
||||
let op = if expected.is_some() {
|
||||
"update"
|
||||
} else {
|
||||
"insert"
|
||||
};
|
||||
let swapped = self
|
||||
.repo
|
||||
.set_if(cx.app_id, collection, key, expected, new.clone())
|
||||
.await?;
|
||||
if swapped {
|
||||
// Non-transactional with the write (same caveat as `set`).
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit(
|
||||
cx,
|
||||
ServiceEvent {
|
||||
source: "kv",
|
||||
op,
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(key.to_string()),
|
||||
payload: Some(new),
|
||||
old_payload: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, source = "kv", op, event_emit_failure = true, "event emit failed");
|
||||
}
|
||||
}
|
||||
Ok(swapped)
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
@@ -283,6 +336,28 @@ mod tests {
|
||||
.insert((app_id, collection.to_string(), key.to_string()), value))
|
||||
}
|
||||
|
||||
async fn set_if(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
expected: Option<serde_json::Value>,
|
||||
new: serde_json::Value,
|
||||
) -> Result<bool, KvRepoError> {
|
||||
// Holding the map lock makes the compare-and-set atomic in-memory.
|
||||
let mut data = self.data.lock().await;
|
||||
let k = (app_id, collection.to_string(), key.to_string());
|
||||
let matches = match (&expected, data.get(&k)) {
|
||||
(None, None) => true, // insert-if-absent
|
||||
(Some(exp), Some(cur)) => exp == cur, // swap-if-equal
|
||||
_ => false,
|
||||
};
|
||||
if matches {
|
||||
data.insert(k, new);
|
||||
}
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
@@ -464,6 +539,63 @@ mod tests {
|
||||
assert!(matches!(err, KvError::InvalidCollection));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_if_is_compare_and_swap() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
// Insert-if-absent: expected () swaps only when the key is missing.
|
||||
assert!(
|
||||
kv.set_if(&cx, "cas", "k", None, serde_json::json!(1))
|
||||
.await
|
||||
.unwrap(),
|
||||
"insert-if-absent must succeed on a missing key"
|
||||
);
|
||||
assert!(
|
||||
!kv.set_if(&cx, "cas", "k", None, serde_json::json!(2))
|
||||
.await
|
||||
.unwrap(),
|
||||
"insert-if-absent must fail once the key exists"
|
||||
);
|
||||
assert_eq!(
|
||||
kv.get(&cx, "cas", "k").await.unwrap(),
|
||||
Some(serde_json::json!(1))
|
||||
);
|
||||
|
||||
// Swap-if-equal: wrong expected → no-op; correct expected → swaps.
|
||||
assert!(
|
||||
!kv.set_if(
|
||||
&cx,
|
||||
"cas",
|
||||
"k",
|
||||
Some(serde_json::json!(99)),
|
||||
serde_json::json!(3)
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
"a mismatched expected must not swap"
|
||||
);
|
||||
assert_eq!(
|
||||
kv.get(&cx, "cas", "k").await.unwrap(),
|
||||
Some(serde_json::json!(1))
|
||||
);
|
||||
assert!(
|
||||
kv.set_if(
|
||||
&cx,
|
||||
"cas",
|
||||
"k",
|
||||
Some(serde_json::json!(1)),
|
||||
serde_json::json!(3)
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
"a matching expected must swap"
|
||||
);
|
||||
assert_eq!(
|
||||
kv.get(&cx, "cas", "k").await.unwrap(),
|
||||
Some(serde_json::json!(3))
|
||||
);
|
||||
}
|
||||
|
||||
/// Load-bearing: a script with `cx.app_id = A` must NOT see
|
||||
/// entries inserted under `cx.app_id = B`. This is the cross-app
|
||||
/// isolation boundary; getting this wrong is a security
|
||||
|
||||
Reference in New Issue
Block a user