1use 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 #[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 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 #[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 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 #[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 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 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}