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, ProgressJobInventory,
9        integrity::with_mutation_progress_store,
10        resource::budget::{ExecutionBudgetExceeded, HardExecutionContext},
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,
21    executor::mutation::job::{
22        advance_mutation_job, validate_current_initial_mutation_job_continuation,
23    },
24    integrity::InsertMutationJobResult,
25    mutation_job::{CanonicalMutationIntent, MutationJobRecord},
26    resource::budget::with_mutation_execution_budget,
27};
28
29#[cfg(feature = "sql")]
30const MUTATION_JOB_START_SHAPE: u64 = 0x6d75_7461_7465_0100;
31const MUTATION_JOB_LOAD_SHAPE: u64 = 0x6d75_7461_7465_0101;
32const MUTATION_JOB_ACKNOWLEDGE_SHAPE: u64 = 0x6d75_7461_7465_0102;
33#[cfg(feature = "sql")]
34const MUTATION_JOB_ADVANCE_SHAPE: u64 = 0x6d75_7461_7465_0103;
35#[cfg(feature = "sql")]
36const MUTATION_JOB_CANCEL_UNADVANCED_SHAPE: u64 = 0x6d75_7461_7465_0104;
37const PROGRESS_JOB_INVENTORY_SHAPE: u64 = 0x6d75_7461_7465_0105;
38
39impl<C: CanisterKind> DbSession<C> {
40    /// Start one durable trusted fixed SQL mutation job.
41    ///
42    /// SQL is parsed and admitted exactly once for a new identity. The
43    /// catalog-native intent and initial engine checkpoint are durably retained
44    /// before this method returns, and no target row is read or mutated.
45    /// Repeating the same canonical request returns the retained state without
46    /// replacing its statement timestamp or resetting progress.
47    #[cfg(feature = "sql")]
48    pub fn start_trusted_sql_mutation_job(
49        &self,
50        job_id: MutationJobId,
51        sql: &str,
52    ) -> Result<MutationJobState, MutationJobError> {
53        job_id.validate()?;
54        self.admit_mutation_job_operation(
55            DiagnosticExecutionLane::Mutation,
56            MUTATION_JOB_START_SHAPE,
57        )?;
58        let prepared = self.prepare_mutation_job_start(job_id, sql)?;
59        let submitted_intent = CanonicalMutationIntent::decode(&prepared.canonical_intent)?;
60        let submitted = MutationJobRecord::new(
61            job_id,
62            prepared.canonical_intent,
63            prepared.engine_continuation,
64        )?;
65        with_mutation_progress_store::<C, _>(|store| match store.insert_mutation(&submitted)? {
66            InsertMutationJobResult::Inserted => Ok(submitted.state().clone()),
67            InsertMutationJobResult::Occupied(retained) => {
68                resolve_occupied_mutation_job_start(&retained, &submitted_intent)
69            }
70        })
71    }
72
73    /// Load the bounded public state for one retained mutation job.
74    pub fn mutation_job_state(
75        &self,
76        job_id: MutationJobId,
77    ) -> Result<MutationJobState, MutationJobError> {
78        self.admit_mutation_job_operation(
79            DiagnosticExecutionLane::TrustedRead,
80            MUTATION_JOB_LOAD_SHAPE,
81        )?;
82        with_mutation_progress_store::<C, _>(|store| store.load_mutation(job_id))
83            .map(|record| record.state().clone())
84    }
85
86    /// Advance one durable mutation job through one bounded engine-owned step.
87    ///
88    /// The request carries only job identity, expected sequence, and a replay
89    /// key. SQL and continuation bytes remain private IcyDB custody.
90    /// Catalog admission or inspection failure returns
91    /// [`MutationJobError::TargetQueryFailed`] without changing retained progress;
92    /// retry the same request after the obstruction is resolved. Successfully
93    /// inspected missing or changed schema authority requires a terminal restart.
94    /// Execution-budget policy exhaustion retains its terminal restart reason.
95    #[cfg(feature = "sql")]
96    pub fn advance_trusted_mutation_job(
97        &self,
98        request: &MutationJobAdvanceRequest,
99    ) -> Result<MutationJobAdvanceReceipt, MutationJobError> {
100        let retained =
101            with_mutation_progress_store::<C, _>(|store| store.load_mutation(request.job_id))?;
102        if let Some(receipt) = retained.exact_replay(request)? {
103            return Ok(receipt.clone());
104        }
105        self.admit_mutation_job_operation(
106            DiagnosticExecutionLane::Mutation,
107            MUTATION_JOB_ADVANCE_SHAPE,
108        )?;
109        retained.ensure_can_advance(request)?;
110        let context = HardExecutionContext::new(
111            DiagnosticExecutionBudgetScope::Execution,
112            DiagnosticExecutionLane::Mutation,
113            MUTATION_JOB_ADVANCE_SHAPE,
114        );
115        with_mutation_execution_budget(
116            context,
117            || {
118                advance_mutation_job(&self.db, &retained, request, |entity_source| {
119                    self.find_accepted_schema_catalog_context_for_entity_source_key(entity_source)
120                })
121            },
122            mutation_job_execution_internal_error,
123        )
124    }
125
126    /// Remove one terminal mutation job after its result has been consumed.
127    ///
128    /// Repeating acknowledgement after a lost response succeeds when the job
129    /// is already absent. Active jobs and stale terminal sequences fail closed.
130    /// Recovery admission must succeed before retained progress can be removed.
131    pub fn acknowledge_mutation_job(
132        &self,
133        job_id: MutationJobId,
134        expected_terminal_sequence: u64,
135    ) -> Result<(), MutationJobError> {
136        self.admit_mutation_job_operation(
137            DiagnosticExecutionLane::Mutation,
138            MUTATION_JOB_ACKNOWLEDGE_SHAPE,
139        )?;
140        with_mutation_progress_store::<C, _>(|store| {
141            store.acknowledge_mutation(job_id, expected_terminal_sequence)
142        })
143    }
144
145    /// Idempotently remove one exact initial mutation-job record.
146    ///
147    /// Cancellation is available only before any page or receipt exists. A
148    /// logical restart must use a fresh [`MutationJobId`]; absent-record
149    /// success never makes an old identity reusable.
150    /// Recovery admission must succeed before retained progress can be removed.
151    #[cfg(feature = "sql")]
152    pub fn cancel_unadvanced_mutation_job(
153        &self,
154        job_id: MutationJobId,
155        expected_sequence: u64,
156    ) -> Result<(), MutationJobError> {
157        job_id.validate()?;
158        self.admit_mutation_job_operation(
159            DiagnosticExecutionLane::Mutation,
160            MUTATION_JOB_CANCEL_UNADVANCED_SHAPE,
161        )?;
162        with_mutation_progress_store::<C, _>(|store| {
163            store.cancel_unadvanced_mutation(
164                job_id,
165                expected_sequence,
166                validate_current_initial_mutation_job_continuation,
167            )
168        })
169    }
170
171    /// Return one complete fail-closed inventory of shared retained progress.
172    ///
173    /// Callers remain responsible for authorization. The result exposes only
174    /// family, job identity, bounded lifecycle, sequence, and capacity facts.
175    pub fn progress_job_inventory(&self) -> Result<ProgressJobInventory, MutationJobError> {
176        self.admit_mutation_job_operation(
177            DiagnosticExecutionLane::TrustedRead,
178            PROGRESS_JOB_INVENTORY_SHAPE,
179        )?;
180        with_mutation_progress_store::<C, _>(|store| store.inventory())
181    }
182
183    fn admit_mutation_job_operation(
184        &self,
185        lane: DiagnosticExecutionLane,
186        shape: u64,
187    ) -> Result<(), MutationJobError> {
188        self.db
189            .request_execution_scope()
190            .charge(
191                HardExecutionContext::new(DiagnosticExecutionBudgetScope::Execution, lane, shape),
192                DiagnosticExecutionBudgetResource::QueryExecutions,
193                1,
194            )
195            .map_err(mutation_job_execution_budget_error)?;
196        // Every progress writer shares control admission. Observation and exact
197        // replay remain state-only; this check never drives pending recovery.
198        if lane == DiagnosticExecutionLane::Mutation {
199            self.db
200                .ensure_recovered_control_state()
201                .map_err(|_| MutationJobError::TargetQueryFailed)?;
202        }
203        Ok(())
204    }
205}
206
207#[cfg(feature = "sql")]
208fn resolve_occupied_mutation_job_start(
209    retained: &MutationJobRecord,
210    submitted_intent: &CanonicalMutationIntent,
211) -> Result<MutationJobState, MutationJobError> {
212    let retained_intent = CanonicalMutationIntent::decode(retained.canonical_intent())?;
213    if retained_intent.same_start_request(submitted_intent) {
214        return Ok(retained.state().clone());
215    }
216    if !retained_intent.same_authority(submitted_intent) {
217        return Err(MutationJobError::AuthorityMismatch);
218    }
219    Err(MutationJobError::IdentityConflict)
220}
221
222const fn mutation_job_execution_budget_error(error: ExecutionBudgetExceeded) -> MutationJobError {
223    MutationJobError::ExecutionBudgetExceeded {
224        resource: error.resource().raw(),
225        limit: error.limit(),
226        observed: error.observed(),
227        scope: error.scope().raw(),
228        lane: error.lane().raw(),
229        normalized_shape_fingerprint_prefix: error.normalized_shape_fingerprint_prefix(),
230    }
231}
232
233#[cfg(feature = "sql")]
234fn mutation_job_execution_internal_error(_error: crate::error::InternalError) -> MutationJobError {
235    MutationJobError::Internal
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::{
242        db::{
243            MutationJobAdvanceRequest, MutationJobIdempotencyKey, MutationJobPhase,
244            MutationJobRestartReason, MutationJobStatus, RequestExecutionRoot, StoreRegistry,
245            mutation_job::{MutationJobRecord, MutationJobTransition},
246        },
247        traits::Path,
248    };
249    #[cfg(feature = "sql")]
250    use crate::{
251        db::{
252            data::{AcceptedFixedUpdatePatch, FieldSlot},
253            query::plan::expr::{BinaryOp, Expr, FieldId},
254            resource::budget::{HardExecutionBudget, HardExecutionFailureHeadroom},
255        },
256        types::Timestamp,
257        value::Value,
258    };
259
260    struct TestCanister;
261
262    impl Path for TestCanister {
263        const PATH: &'static str = "db::session::mutation_job::tests::Canister";
264    }
265
266    impl CanisterKind for TestCanister {
267        fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
268            Ok(244)
269        }
270        const COMMIT_STABLE_KEY: &'static str = "icydb.test.mutation_job.commit.v1";
271        fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
272            Ok(246)
273        }
274        const STARTUP_STABLE_KEY: &'static str = "icydb.test.mutation_job.startup.control.v1";
275        fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
276            Ok(245)
277        }
278        const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "icydb.test.mutation_job.progress.v1";
279    }
280
281    thread_local! {
282        static STORE_REGISTRY: StoreRegistry = StoreRegistry::new();
283    }
284
285    fn job_id() -> MutationJobId {
286        MutationJobId::try_from_bytes([19; 32]).expect("nonzero mutation job id should admit")
287    }
288
289    fn session() -> DbSession<TestCanister> {
290        let root = RequestExecutionRoot::__new_runtime_root();
291        let session = DbSession::new(&STORE_REGISTRY, &root);
292        assert!(session.db.drive_startup_recovery_page().unwrap());
293        session
294    }
295
296    #[cfg(feature = "sql")]
297    fn exhausted_session() -> DbSession<TestCanister> {
298        let budget =
299            HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
300        let root = RequestExecutionRoot::new_for_tests(budget);
301        DbSession::new(&STORE_REGISTRY, &root)
302    }
303
304    #[test]
305    fn session_load_and_terminal_acknowledgement_preserve_the_store_contract() {
306        let initial = MutationJobRecord::new(job_id(), vec![1, 2], vec![3])
307            .expect("bounded initial record should admit");
308        with_mutation_progress_store::<TestCanister, _>(|store| {
309            store.insert_mutation(&initial).map(|_| ())
310        })
311        .expect("initial record should insert");
312
313        let session = session();
314        assert_eq!(
315            session.mutation_job_state(job_id()),
316            Ok(initial.state().clone())
317        );
318        assert_eq!(
319            session.acknowledge_mutation_job(job_id(), 0),
320            Err(MutationJobError::Active),
321        );
322
323        let request = MutationJobAdvanceRequest::new(
324            job_id(),
325            0,
326            MutationJobIdempotencyKey::new("authority-drift")
327                .expect("bounded idempotency key should admit"),
328        );
329        let (terminal, _) = initial
330            .apply_transition(
331                &request,
332                MutationJobTransition::new(
333                    MutationJobStatus::RestartRequired(
334                        MutationJobRestartReason::ManagedTimestampRegression,
335                    ),
336                    MutationJobPhase::Forward,
337                    Vec::new(),
338                    0,
339                    0,
340                    0,
341                ),
342            )
343            .expect("terminal restart receipt should admit");
344        with_mutation_progress_store::<TestCanister, _>(|store| store.replace_mutation(&terminal))
345            .expect("terminal record should replace active state");
346
347        assert_eq!(
348            session.acknowledge_mutation_job(job_id(), 0),
349            Err(MutationJobError::StaleSequence {
350                expected: 0,
351                actual: 1,
352            }),
353        );
354        assert_eq!(session.acknowledge_mutation_job(job_id(), 1), Ok(()));
355        assert_eq!(session.acknowledge_mutation_job(job_id(), 1), Ok(()));
356        assert_eq!(
357            session.mutation_job_state(job_id()),
358            Err(MutationJobError::NotFound),
359        );
360    }
361
362    #[cfg(feature = "sql")]
363    fn canonical_intent(
364        authority: u8,
365        scope_value: u64,
366        timestamp: i64,
367    ) -> CanonicalMutationIntent {
368        let scope = Expr::Binary {
369            op: BinaryOp::Eq,
370            left: Box::new(Expr::Field(FieldId::new("collection_id"))),
371            right: Box::new(Expr::Literal(Value::Nat64(scope_value))),
372        };
373        let patch = AcceptedFixedUpdatePatch::from_canonical_fields(vec![(
374            FieldSlot::from_validated_index(1),
375            vec![3, 4, 5],
376        )])
377        .expect("fixed patch should admit");
378        CanonicalMutationIntent::new(
379            [authority; 16],
380            [authority; 32],
381            "journaled".to_string(),
382            "schema::Token".to_string(),
383            7,
384            11,
385            1,
386            [authority; 16],
387            &scope,
388            &patch,
389            Timestamp::from_millis(timestamp),
390            17,
391        )
392        .expect("canonical intent should admit")
393    }
394
395    #[cfg(feature = "sql")]
396    #[test]
397    fn occupied_start_distinguishes_replay_authority_drift_and_identity_conflict() {
398        let retained_intent = canonical_intent(1, 7, 100);
399        let retained = MutationJobRecord::new(
400            job_id(),
401            retained_intent.encode().expect("intent should encode"),
402            vec![9],
403        )
404        .expect("record should admit");
405
406        assert_eq!(
407            resolve_occupied_mutation_job_start(&retained, &canonical_intent(1, 7, 200)),
408            Ok(retained.state().clone()),
409        );
410        assert_eq!(
411            resolve_occupied_mutation_job_start(&retained, &canonical_intent(2, 7, 200)),
412            Err(MutationJobError::AuthorityMismatch),
413        );
414        assert_eq!(
415            resolve_occupied_mutation_job_start(&retained, &canonical_intent(1, 8, 200)),
416            Err(MutationJobError::IdentityConflict),
417        );
418    }
419
420    #[cfg(feature = "sql")]
421    #[test]
422    fn aggregate_budget_exhaustion_does_not_advance_durable_state() {
423        let initial = MutationJobRecord::new(job_id(), vec![1, 2], vec![3])
424            .expect("bounded initial record should admit");
425        with_mutation_progress_store::<TestCanister, _>(|store| {
426            store.insert_mutation(&initial).map(|_| ())
427        })
428        .expect("initial record should insert");
429        let request = MutationJobAdvanceRequest::new(
430            job_id(),
431            0,
432            MutationJobIdempotencyKey::new("budget-exhausted")
433                .expect("bounded idempotency key should admit"),
434        );
435
436        assert!(matches!(
437            exhausted_session().advance_trusted_mutation_job(&request),
438            Err(MutationJobError::ExecutionBudgetExceeded {
439                limit: 0,
440                observed: 1,
441                ..
442            })
443        ));
444        assert_eq!(
445            session().mutation_job_state(job_id()),
446            Ok(initial.state().clone()),
447        );
448    }
449
450    #[cfg(feature = "sql")]
451    #[test]
452    fn exact_replay_precedes_advance_budget_accounting() {
453        let initial = MutationJobRecord::new(job_id(), vec![1, 2], vec![3])
454            .expect("bounded initial record should admit");
455        let request = MutationJobAdvanceRequest::new(
456            job_id(),
457            0,
458            MutationJobIdempotencyKey::new("lost-response")
459                .expect("bounded idempotency key should admit"),
460        );
461        let (advanced, receipt) = initial
462            .apply_transition(
463                &request,
464                MutationJobTransition::new(
465                    MutationJobStatus::Active,
466                    MutationJobPhase::Forward,
467                    vec![4],
468                    1,
469                    1,
470                    0,
471                ),
472            )
473            .expect("bounded successor should admit");
474        with_mutation_progress_store::<TestCanister, _>(|store| {
475            store.insert_mutation(&advanced).map(|_| ())
476        })
477        .expect("advanced record should insert");
478
479        assert_eq!(
480            exhausted_session().advance_trusted_mutation_job(&request),
481            Ok(receipt),
482        );
483        assert_eq!(
484            session().mutation_job_state(job_id()),
485            Ok(advanced.state().clone()),
486        );
487    }
488}