Skip to main content

icydb_core/db/session/
mutation_job.rs

1//! Module: session::mutation_job
2//! Responsibility: charged mutation-job start, state load, phase dispatch, and terminal acknowledgement.
3//! Does not own: SQL lowering, Forward/Verify execution, or authorization.
4//! Boundary: trusted session API -> excluded mutation progress record.
5
6use crate::{
7    db::{
8        DbSession, MutationJobError, MutationJobId, MutationJobState,
9        executor::budget::{ExecutionBudgetExceeded, HardExecutionContext},
10        integrity::with_mutation_progress_store,
11    },
12    traits::CanisterKind,
13};
14use icydb_diagnostic_code::{
15    DiagnosticExecutionBudgetResource, DiagnosticExecutionBudgetScope, DiagnosticExecutionLane,
16};
17
18#[cfg(feature = "sql")]
19use crate::db::{
20    MutationJobAdvanceReceipt, MutationJobAdvanceRequest, MutationJobPhase,
21    integrity::InsertMutationJobResult,
22    mutation_job::{CanonicalMutationIntent, MutationJobRecord},
23};
24
25#[cfg(feature = "sql")]
26const MUTATION_JOB_START_SHAPE: u64 = 0x6d75_7461_7465_0100;
27const MUTATION_JOB_LOAD_SHAPE: u64 = 0x6d75_7461_7465_0101;
28const MUTATION_JOB_ACKNOWLEDGE_SHAPE: u64 = 0x6d75_7461_7465_0102;
29#[cfg(feature = "sql")]
30const MUTATION_JOB_ADVANCE_SHAPE: u64 = 0x6d75_7461_7465_0103;
31
32impl<C: CanisterKind> DbSession<C> {
33    /// Start one durable trusted fixed SQL mutation job.
34    ///
35    /// SQL is parsed and admitted exactly once for a new identity. The
36    /// catalog-native intent and initial engine checkpoint are durably retained
37    /// before this method returns, and no target row is read or mutated.
38    /// Repeating the same canonical request returns the retained state without
39    /// replacing its operation timestamp or resetting progress.
40    #[cfg(feature = "sql")]
41    pub fn start_trusted_sql_mutation_job(
42        &self,
43        job_id: MutationJobId,
44        sql: &str,
45    ) -> Result<MutationJobState, MutationJobError> {
46        job_id.validate()?;
47        self.charge_mutation_job_operation(
48            DiagnosticExecutionLane::Mutation,
49            MUTATION_JOB_START_SHAPE,
50        )?;
51        let prepared = self.prepare_mutation_job_start(job_id, sql)?;
52        let submitted_intent = CanonicalMutationIntent::decode(&prepared.canonical_intent)?;
53        let submitted = MutationJobRecord::new(
54            job_id,
55            prepared.canonical_intent,
56            prepared.engine_continuation,
57        )?;
58        with_mutation_progress_store::<C, _>(|store| match store.insert_mutation(&submitted)? {
59            InsertMutationJobResult::Inserted => Ok(submitted.state().clone()),
60            InsertMutationJobResult::Occupied(retained) => {
61                resolve_occupied_mutation_job_start(&retained, &submitted_intent)
62            }
63        })
64    }
65
66    /// Load the bounded public state for one retained mutation job.
67    pub fn mutation_job_state(
68        &self,
69        job_id: MutationJobId,
70    ) -> Result<MutationJobState, MutationJobError> {
71        self.charge_mutation_job_operation(
72            DiagnosticExecutionLane::TrustedRead,
73            MUTATION_JOB_LOAD_SHAPE,
74        )?;
75        with_mutation_progress_store::<C, _>(|store| store.load_mutation(job_id))
76            .map(|record| record.state().clone())
77    }
78
79    /// Advance one durable mutation job through one bounded engine-owned step.
80    ///
81    /// The request carries only job identity, expected sequence, and a replay
82    /// key. SQL and continuation bytes remain private IcyDB custody.
83    #[cfg(feature = "sql")]
84    pub fn advance_trusted_mutation_job(
85        &self,
86        request: &MutationJobAdvanceRequest,
87    ) -> Result<MutationJobAdvanceReceipt, MutationJobError> {
88        let retained =
89            with_mutation_progress_store::<C, _>(|store| store.load_mutation(request.job_id))?;
90        if let Some(receipt) = retained.exact_replay(request)? {
91            return Ok(receipt.clone());
92        }
93        self.charge_mutation_job_operation(
94            DiagnosticExecutionLane::Mutation,
95            MUTATION_JOB_ADVANCE_SHAPE,
96        )?;
97        retained.ensure_can_advance(request)?;
98        match retained.state().phase {
99            MutationJobPhase::Forward => self.advance_mutation_job_forward(&retained, request),
100            MutationJobPhase::Verify => self.advance_mutation_job_verify(&retained, request),
101        }
102    }
103
104    /// Remove one terminal mutation job after its result has been consumed.
105    ///
106    /// Repeating acknowledgement after a lost response succeeds when the job
107    /// is already absent. Active jobs and stale terminal sequences fail closed.
108    pub fn acknowledge_mutation_job(
109        &self,
110        job_id: MutationJobId,
111        expected_terminal_sequence: u64,
112    ) -> Result<(), MutationJobError> {
113        self.charge_mutation_job_operation(
114            DiagnosticExecutionLane::Mutation,
115            MUTATION_JOB_ACKNOWLEDGE_SHAPE,
116        )?;
117        with_mutation_progress_store::<C, _>(|store| {
118            store.acknowledge_mutation(job_id, expected_terminal_sequence)
119        })
120    }
121
122    fn charge_mutation_job_operation(
123        &self,
124        lane: DiagnosticExecutionLane,
125        shape: u64,
126    ) -> Result<(), MutationJobError> {
127        self.db
128            .request_execution_scope()
129            .charge(
130                HardExecutionContext::new(DiagnosticExecutionBudgetScope::Execution, lane, shape),
131                DiagnosticExecutionBudgetResource::QueryExecutions,
132                1,
133            )
134            .map_err(mutation_job_execution_budget_error)
135    }
136}
137
138#[cfg(feature = "sql")]
139fn resolve_occupied_mutation_job_start(
140    retained: &MutationJobRecord,
141    submitted_intent: &CanonicalMutationIntent,
142) -> Result<MutationJobState, MutationJobError> {
143    let retained_intent = CanonicalMutationIntent::decode(retained.canonical_intent())?;
144    if retained_intent.same_start_request(submitted_intent) {
145        return Ok(retained.state().clone());
146    }
147    if !retained_intent.same_authority(submitted_intent) {
148        return Err(MutationJobError::AuthorityMismatch);
149    }
150    Err(MutationJobError::IdentityConflict)
151}
152
153const fn mutation_job_execution_budget_error(error: ExecutionBudgetExceeded) -> MutationJobError {
154    MutationJobError::ExecutionBudgetExceeded {
155        resource: error.resource().raw(),
156        limit: error.limit(),
157        observed: error.observed(),
158        scope: error.scope().raw(),
159        lane: error.lane().raw(),
160        normalized_shape_fingerprint_prefix: error.normalized_shape_fingerprint_prefix(),
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::{
168        db::{
169            MutationJobAdvanceRequest, MutationJobIdempotencyKey, MutationJobPhase,
170            MutationJobRestartReason, MutationJobStatus, RequestExecutionRoot, StoreRegistry,
171            mutation_job::{MutationJobRecord, MutationJobTransition},
172        },
173        traits::Path,
174    };
175    #[cfg(feature = "sql")]
176    use crate::{
177        db::{
178            data::{AcceptedFixedUpdatePatch, FieldSlot},
179            executor::budget::{HardExecutionBudget, HardExecutionFailureHeadroom},
180            query::plan::expr::{BinaryOp, Expr, FieldId},
181        },
182        types::Timestamp,
183        value::Value,
184    };
185
186    struct TestCanister;
187
188    impl Path for TestCanister {
189        const PATH: &'static str = "db::session::mutation_job::tests::Canister";
190    }
191
192    impl CanisterKind for TestCanister {
193        const COMMIT_MEMORY_ID: u8 = 244;
194        const COMMIT_STABLE_KEY: &'static str = "icydb.test.mutation-job.commit.v1";
195        const STARTUP_MEMORY_ID: u8 = 246;
196        const STARTUP_STABLE_KEY: &'static str = "icydb.test.mutation-job.startup.control.v1";
197        const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 245;
198        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "icydb.test.mutation-job.progress.v1";
199    }
200
201    thread_local! {
202        static STORE_REGISTRY: StoreRegistry = StoreRegistry::new();
203    }
204
205    fn job_id() -> MutationJobId {
206        MutationJobId::try_from_bytes([19; 32]).expect("nonzero mutation job id should admit")
207    }
208
209    fn session() -> DbSession<TestCanister> {
210        let root = RequestExecutionRoot::__new_runtime_root();
211        DbSession::new(&STORE_REGISTRY, &root)
212    }
213
214    #[cfg(feature = "sql")]
215    fn exhausted_session() -> DbSession<TestCanister> {
216        let budget =
217            HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
218        let root = RequestExecutionRoot::new_for_tests(budget);
219        DbSession::new(&STORE_REGISTRY, &root)
220    }
221
222    #[test]
223    fn session_load_and_terminal_acknowledgement_preserve_the_store_contract() {
224        let initial = MutationJobRecord::new(job_id(), vec![1, 2], vec![3])
225            .expect("bounded initial record should admit");
226        with_mutation_progress_store::<TestCanister, _>(|store| {
227            store.insert_mutation(&initial).map(|_| ())
228        })
229        .expect("initial record should insert");
230
231        let session = session();
232        assert_eq!(
233            session.mutation_job_state(job_id()),
234            Ok(initial.state().clone())
235        );
236        assert_eq!(
237            session.acknowledge_mutation_job(job_id(), 0),
238            Err(MutationJobError::Active),
239        );
240
241        let request = MutationJobAdvanceRequest::new(
242            job_id(),
243            0,
244            MutationJobIdempotencyKey::new("authority-drift")
245                .expect("bounded idempotency key should admit"),
246        );
247        let (terminal, _) = initial
248            .apply_transition(
249                &request,
250                MutationJobTransition::new(
251                    MutationJobStatus::RestartRequired(
252                        MutationJobRestartReason::AcceptedSchemaChanged,
253                    ),
254                    MutationJobPhase::Forward,
255                    Vec::new(),
256                    0,
257                    0,
258                    0,
259                ),
260            )
261            .expect("terminal restart receipt should admit");
262        with_mutation_progress_store::<TestCanister, _>(|store| store.replace_mutation(&terminal))
263            .expect("terminal record should replace active state");
264
265        assert_eq!(
266            session.acknowledge_mutation_job(job_id(), 0),
267            Err(MutationJobError::StaleSequence {
268                expected: 0,
269                actual: 1,
270            }),
271        );
272        assert_eq!(session.acknowledge_mutation_job(job_id(), 1), Ok(()));
273        assert_eq!(session.acknowledge_mutation_job(job_id(), 1), Ok(()));
274        assert_eq!(
275            session.mutation_job_state(job_id()),
276            Err(MutationJobError::NotFound),
277        );
278    }
279
280    #[cfg(feature = "sql")]
281    fn canonical_intent(
282        authority: u8,
283        scope_value: u64,
284        timestamp: i64,
285    ) -> CanonicalMutationIntent {
286        let scope = Expr::Binary {
287            op: BinaryOp::Eq,
288            left: Box::new(Expr::Field(FieldId::new("collection_id"))),
289            right: Box::new(Expr::Literal(Value::Nat64(scope_value))),
290        };
291        let patch = AcceptedFixedUpdatePatch::from_canonical_fields(vec![(
292            FieldSlot::from_validated_index(1),
293            vec![3, 4, 5],
294        )])
295        .expect("fixed patch should admit");
296        CanonicalMutationIntent::new(
297            [authority; 16],
298            [authority; 32],
299            "journaled".to_string(),
300            "schema::Token".to_string(),
301            7,
302            11,
303            1,
304            [authority; 16],
305            &scope,
306            &patch,
307            Timestamp::from_millis(timestamp),
308            17,
309        )
310        .expect("canonical intent should admit")
311    }
312
313    #[cfg(feature = "sql")]
314    #[test]
315    fn occupied_start_distinguishes_replay_authority_drift_and_identity_conflict() {
316        let retained_intent = canonical_intent(1, 7, 100);
317        let retained = MutationJobRecord::new(
318            job_id(),
319            retained_intent.encode().expect("intent should encode"),
320            vec![9],
321        )
322        .expect("record should admit");
323
324        assert_eq!(
325            resolve_occupied_mutation_job_start(&retained, &canonical_intent(1, 7, 200)),
326            Ok(retained.state().clone()),
327        );
328        assert_eq!(
329            resolve_occupied_mutation_job_start(&retained, &canonical_intent(2, 7, 200)),
330            Err(MutationJobError::AuthorityMismatch),
331        );
332        assert_eq!(
333            resolve_occupied_mutation_job_start(&retained, &canonical_intent(1, 8, 200)),
334            Err(MutationJobError::IdentityConflict),
335        );
336    }
337
338    #[cfg(feature = "sql")]
339    #[test]
340    fn aggregate_budget_exhaustion_does_not_advance_durable_state() {
341        let initial = MutationJobRecord::new(job_id(), vec![1, 2], vec![3])
342            .expect("bounded initial record should admit");
343        with_mutation_progress_store::<TestCanister, _>(|store| {
344            store.insert_mutation(&initial).map(|_| ())
345        })
346        .expect("initial record should insert");
347        let request = MutationJobAdvanceRequest::new(
348            job_id(),
349            0,
350            MutationJobIdempotencyKey::new("budget-exhausted")
351                .expect("bounded idempotency key should admit"),
352        );
353
354        assert!(matches!(
355            exhausted_session().advance_trusted_mutation_job(&request),
356            Err(MutationJobError::ExecutionBudgetExceeded {
357                limit: 0,
358                observed: 1,
359                ..
360            })
361        ));
362        assert_eq!(
363            session().mutation_job_state(job_id()),
364            Ok(initial.state().clone()),
365        );
366    }
367
368    #[cfg(feature = "sql")]
369    #[test]
370    fn exact_replay_precedes_advance_budget_accounting() {
371        let initial = MutationJobRecord::new(job_id(), vec![1, 2], vec![3])
372            .expect("bounded initial record should admit");
373        let request = MutationJobAdvanceRequest::new(
374            job_id(),
375            0,
376            MutationJobIdempotencyKey::new("lost-response")
377                .expect("bounded idempotency key should admit"),
378        );
379        let (advanced, receipt) = initial
380            .apply_transition(
381                &request,
382                MutationJobTransition::new(
383                    MutationJobStatus::Active,
384                    MutationJobPhase::Forward,
385                    vec![4],
386                    1,
387                    1,
388                    0,
389                ),
390            )
391            .expect("bounded successor should admit");
392        with_mutation_progress_store::<TestCanister, _>(|store| {
393            store.insert_mutation(&advanced).map(|_| ())
394        })
395        .expect("advanced record should insert");
396
397        assert_eq!(
398            exhausted_session().advance_trusted_mutation_job(&request),
399            Ok(receipt),
400        );
401        assert_eq!(
402            session().mutation_job_state(job_id()),
403            Ok(advanced.state().clone()),
404        );
405    }
406}