Skip to main content

rust_store_core/command/
claim.rs

1//! claim 命令规划:并发领取原语(outbox / 任务分派的底层原语)。
2//!
3//! 语义:在 filter 命中的行中按 sort 取前 limit 行,**原子**写入领取标记(update),
4//! 并返回被领取的完整文档。原子性由 SQL 侧行锁(`FOR UPDATE SKIP LOCKED`)或
5//! 单写者(SQLite)在方言翻译层保证(见 `dialect::write::claim`);core 只产出
6//! 命令序列,不做 IO。
7//!
8//! 计划形状(Host 契约):
9//!
10//! ```json
11//! { "mode": "atomic_commands", "schema": "Outbox",
12//!   "commands": [
13//!     { "kind": "claim", "source": "...", "database": null, "schema": null,
14//!       "collection": "outbox", "filter": {...}, "sort": [["_id", 1]],
15//!       "limit": 10, "update": {...}, "nowStamp": 0 },
16//!     { "kind": "find", "source": "...", "database": null, "schema": null,
17//!       "collection": "outbox", "filter": { "_id": { "$in": "___claimed_ids___" } },
18//!       "projection": null, "placeholder": "___claimed_ids___" }
19//!   ] }
20//! ```
21//!
22//! `atomic_commands`:全部 commands 必须在**同一连接同一事务**内顺序执行;前一条
23//! `claim` 的结果集 `_id` 数组填充后一条的 [`CLAIMED_IDS`] 占位符(整值替换,Host
24//! 契约同 `{{phase1.ids}}`)。ids 为空(0 行命中)时 Host 跳过后续命令、直接返回
25//! 空集。mode 为新增值,不与既有 `two_phase` 语义重叠。
26//!
27//! MongoDB 无 claim 方言路径:Host 的 Mongo 执行器对 `kind: "claim"` 显式拒绝
28//! (宿主侧抛 PushdownUnsupportedError),core 不产出该路径。
29
30use serde_json::{json, Value};
31
32use crate::command::cmd::{cmd_claim, cmd_find};
33use crate::command::mutate::{build_raw_update, build_set_data, has_raw_operators};
34use crate::command::write_links::attach_write_links;
35use crate::command::{
36    ensure_context, forbid_t2q, CLAIMED_IDS, ERR_NO_BATCH_WRITE, MODE_ATOMIC_COMMANDS,
37};
38use crate::permission::{can_write_schema, Context};
39use crate::rbac::{ensure_write, merge_row_condition, WriteAction};
40use crate::schema::{Registry, Schema};
41use crate::types::{validate_condition, validate_condition_shape};
42
43/// claim 计划:领取 filter 命中的前 limit 行并写入领取标记。
44///
45/// `now` 由 Host 注入(core 无时钟与随机源);`sort` 缺省 `[["_id", 1]]`(FIFO);
46/// `limit <= 0` 显式拒绝(禁静默取 1——领取语义下静默改值会掩盖调用方错误)。
47#[allow(clippy::too_many_arguments)]
48pub fn plan_claim(
49    schema_name: &str,
50    registry: &Registry,
51    ctx: Option<&Context>,
52    filter: &Value,
53    update: &Value,
54    sort: Option<&Value>,
55    limit: i64,
56    now: i64,
57) -> Result<Value, String> {
58    ensure_context(registry, ctx)?;
59    // 并发原语属 DB 独有能力:text2query 档收缩(同 triggers / $pipeline 直通先例)
60    forbid_t2q(registry, "claim")?;
61    let schema = registry.get(schema_name)?;
62    validate_condition(filter)?;
63    validate_condition_shape(schema, filter, registry.profile())?;
64    if limit <= 0 {
65        return Err(format!(
66            "claim 的 limit 必须 > 0,实际 {limit}(禁静默取 1)"
67        ));
68    }
69    if update.as_object().map(|o| o.is_empty()).unwrap_or(true) {
70        return Err("claim 的 update 必须为非空对象(领取必须写入标记)".to_string());
71    }
72    // 拒写清单 + 静态写权限(updateMany 同款:不走 creator 探针)
73    if let Some(c) = ctx {
74        let deny_write =
75            crate::permission::has_any_role(c, &registry.role_rules().deny_write_roles);
76        if deny_write || !can_write_schema(registry.role_rules(), schema, ctx) {
77            return Err(ERR_NO_BATCH_WRITE.to_string());
78        }
79    }
80    // RBAC 表级写判定(deny-wins)+ 行级条件合并(与 updateMany 语义一致:
81    // ownerOnly / condition 并入 filter,匹配不到即 0 行)
82    ensure_write(registry, schema, ctx, WriteAction::Update)?;
83    let eff_condition = merge_row_condition(registry, schema, ctx, "update", Some(filter.clone()))
84        .unwrap_or_else(|| json!({}));
85
86    let update_doc = if has_raw_operators(update) {
87        build_raw_update(registry, schema, ctx, update, now)
88    } else {
89        let mut set_data = build_set_data(registry, schema, ctx, update);
90        if schema.timestamps {
91            set_data.insert("updatedAt".to_string(), json!(now));
92        }
93        json!({ "$set": Value::Object(set_data) })
94    };
95    ensure_fields_declared(schema, &update_doc)?;
96
97    let sort_v = sort.cloned().unwrap_or_else(|| json!([["_id", 1]]));
98    let claim_cmd = cmd_claim(schema, &eff_condition, &sort_v, limit, &update_doc, now);
99    let mut find_cmd = cmd_find(schema, &json!({ "_id": { "$in": CLAIMED_IDS } }), None);
100    if let Some(o) = find_cmd.as_object_mut() {
101        o.insert("placeholder".to_string(), json!(CLAIMED_IDS));
102    }
103    let plan = json!({
104        "mode": MODE_ATOMIC_COMMANDS,
105        "schema": schema_name,
106        "commands": [claim_cmd, find_cmd],
107    });
108    attach_write_links(plan, registry, &[schema_name], registry.write_link_policy())
109}
110
111/// update_doc 中的目标字段必须为 schema 已声明字段(豁免 `_id` 与 timestamps 自动列)。
112///
113/// claim 是宿主编程原语:未知列名属编程错误,规划期显式拒绝(不静默透传、
114/// 不依赖驱动层报错——D2 绝不静默)。
115pub(super) fn ensure_fields_declared(schema: &Schema, update_doc: &Value) -> Result<(), String> {
116    let exempt =
117        |f: &str| f == "_id" || (schema.timestamps && (f == "createdAt" || f == "updatedAt"));
118    let Some(ops) = update_doc.as_object() else {
119        return Ok(());
120    };
121    for (op, body) in ops {
122        // 仅校验已知写操作符的字段体;其余(非法操作符 / 混合形态)由方言翻译层显式报错
123        if !matches!(op.as_str(), "$set" | "$inc" | "$unset") {
124            continue;
125        }
126        let Some(fields) = body.as_object() else {
127            continue;
128        };
129        for k in fields.keys() {
130            if !exempt(k) && !schema.fields.contains_key(k) {
131                return Err(format!(
132                    "并发原语(claim/compareAndSet)的 update 字段 \"{k}\" 未在 schema 声明(D2:规划期拒绝,不落库报错)"
133                ));
134            }
135        }
136    }
137    Ok(())
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::command::plan_compare_and_set;
144    use crate::schema::Registry;
145    use serde_json::json;
146
147    fn registry() -> Registry {
148        let mut r = Registry::new();
149        r.register(&json!({
150            "name": "Outbox", "collection": "outbox", "timestamps": false,
151            "fields": {
152                "status": { "type": "string" },
153                "lockedBy": { "type": "string" },
154                "lockedAt": { "type": "int" }
155            },
156            "relations": {}
157        }))
158        .expect("schema 注册失败");
159        r
160    }
161
162    // ── 计划形状(atomic_commands 契约) ──
163
164    #[test]
165    fn plan_claim_shape() {
166        let r = registry();
167        let plan = plan_claim(
168            "Outbox",
169            &r,
170            None,
171            &json!({ "status": "pending" }),
172            &json!({ "lockedBy": "w1", "lockedAt": 1 }),
173            None,
174            10,
175            123,
176        )
177        .expect("plan");
178        assert_eq!(plan["mode"], json!(MODE_ATOMIC_COMMANDS));
179        assert_eq!(plan["schema"], json!("Outbox"));
180        let cmds = plan["commands"].as_array().unwrap();
181        assert_eq!(cmds.len(), 2);
182        // 命令一:claim(定位四元组 + filter + 缺省 sort + limit + update + nowStamp)
183        assert_eq!(cmds[0]["kind"], json!("claim"));
184        assert_eq!(cmds[0]["collection"], json!("outbox"));
185        assert_eq!(cmds[0]["sort"], json!([["_id", 1]]), "缺省排序 FIFO");
186        assert_eq!(cmds[0]["limit"], json!(10));
187        assert_eq!(cmds[0]["nowStamp"], json!(123));
188        assert!(cmds[0]["source"].is_string());
189        // 命令二:find 取回被领行(占位符整值替换,契约同 {{phase1.ids}})
190        assert_eq!(cmds[1]["kind"], json!("find"));
191        assert_eq!(
192            cmds[1]["filter"]["_id"]["$in"],
193            json!(CLAIMED_IDS),
194            "占位符逐字 ___claimed_ids___"
195        );
196        assert_eq!(cmds[1]["placeholder"], json!(CLAIMED_IDS));
197    }
198
199    #[test]
200    fn plan_cas_shape() {
201        let r = registry();
202        let plan = plan_compare_and_set(
203            "Outbox",
204            &r,
205            None,
206            &json!({ "_id": "l1", "lockedBy": "w1" }),
207            &json!({ "lockedAt": 2 }),
208            456,
209        )
210        .expect("plan");
211        assert_eq!(plan["mode"], json!(MODE_ATOMIC_COMMANDS));
212        let cmds = plan["commands"].as_array().unwrap();
213        assert_eq!(cmds.len(), 1, "CAS 单命令也走 atomic_commands");
214        assert_eq!(cmds[0]["kind"], json!("compareAndSet"));
215        assert_eq!(cmds[0]["nowStamp"], json!(456));
216    }
217
218    // ── 错误面(步骤 5:文案前缀断言) ──
219
220    #[test]
221    fn plan_claim_unknown_schema_errs() {
222        let r = registry();
223        let err = plan_claim(
224            "NoSuch",
225            &r,
226            None,
227            &json!({}),
228            &json!({ "lockedBy": "w1" }),
229            None,
230            1,
231            0,
232        )
233        .unwrap_err();
234        assert!(!err.is_empty());
235    }
236
237    #[test]
238    fn plan_claim_nonpositive_limit_errs() {
239        let r = registry();
240        for limit in [0i64, -3] {
241            let err = plan_claim(
242                "Outbox",
243                &r,
244                None,
245                &json!({}),
246                &json!({ "lockedBy": "w1" }),
247                None,
248                limit,
249                0,
250            )
251            .unwrap_err();
252            assert!(err.contains("limit 必须 > 0"), "{err}");
253        }
254    }
255
256    #[test]
257    fn plan_claim_empty_update_errs() {
258        let r = registry();
259        let err = plan_claim("Outbox", &r, None, &json!({}), &json!({}), None, 1, 0).unwrap_err();
260        assert!(err.contains("非空对象"), "{err}");
261    }
262
263    #[test]
264    fn plan_claim_undeclared_update_field_errs() {
265        let r = registry();
266        let err = plan_claim(
267            "Outbox",
268            &r,
269            None,
270            &json!({}),
271            &json!({ "noSuchField": 1 }),
272            None,
273            1,
274            0,
275        )
276        .unwrap_err();
277        assert!(err.contains("未在 schema 声明"), "{err}");
278    }
279
280    #[test]
281    fn plan_cas_undeclared_update_field_errs() {
282        let r = registry();
283        let err = plan_compare_and_set(
284            "Outbox",
285            &r,
286            None,
287            &json!({}),
288            &json!({ "noSuchField": 1 }),
289            0,
290        )
291        .unwrap_err();
292        assert!(err.contains("未在 schema 声明"), "{err}");
293    }
294}