1use 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 integrity::InsertMutationJobResult,
21 mutation_job::{CanonicalMutationIntent, MutationJobRecord},
22};
23
24#[cfg(feature = "sql")]
25const MUTATION_JOB_START_SHAPE: u64 = 0x6d75_7461_7465_0100;
26const MUTATION_JOB_LOAD_SHAPE: u64 = 0x6d75_7461_7465_0101;
27const MUTATION_JOB_ACKNOWLEDGE_SHAPE: u64 = 0x6d75_7461_7465_0102;
28
29impl<C: CanisterKind> DbSession<C> {
30 #[cfg(feature = "sql")]
38 pub fn start_trusted_sql_mutation_job(
39 &self,
40 job_id: MutationJobId,
41 sql: &str,
42 ) -> Result<MutationJobState, MutationJobError> {
43 job_id.validate()?;
44 self.charge_mutation_job_operation(
45 DiagnosticExecutionLane::Mutation,
46 MUTATION_JOB_START_SHAPE,
47 )?;
48 let prepared = self.prepare_mutation_job_start(job_id, sql)?;
49 let submitted_intent = CanonicalMutationIntent::decode(&prepared.canonical_intent)?;
50 let submitted = MutationJobRecord::new(
51 job_id,
52 prepared.canonical_intent,
53 prepared.engine_continuation,
54 )?;
55 with_mutation_progress_store::<C, _>(|store| match store.insert_mutation(&submitted)? {
56 InsertMutationJobResult::Inserted => Ok(submitted.state().clone()),
57 InsertMutationJobResult::Occupied(retained) => {
58 resolve_occupied_mutation_job_start(&retained, &submitted_intent)
59 }
60 })
61 }
62
63 pub fn mutation_job_state(
65 &self,
66 job_id: MutationJobId,
67 ) -> Result<MutationJobState, MutationJobError> {
68 self.charge_mutation_job_operation(
69 DiagnosticExecutionLane::TrustedRead,
70 MUTATION_JOB_LOAD_SHAPE,
71 )?;
72 with_mutation_progress_store::<C, _>(|store| store.load_mutation(job_id))
73 .map(|record| record.state().clone())
74 }
75
76 pub fn acknowledge_mutation_job(
81 &self,
82 job_id: MutationJobId,
83 expected_terminal_sequence: u64,
84 ) -> Result<(), MutationJobError> {
85 self.charge_mutation_job_operation(
86 DiagnosticExecutionLane::Mutation,
87 MUTATION_JOB_ACKNOWLEDGE_SHAPE,
88 )?;
89 with_mutation_progress_store::<C, _>(|store| {
90 store.acknowledge_mutation(job_id, expected_terminal_sequence)
91 })
92 }
93
94 fn charge_mutation_job_operation(
95 &self,
96 lane: DiagnosticExecutionLane,
97 shape: u64,
98 ) -> Result<(), MutationJobError> {
99 self.db
100 .request_execution_scope()
101 .charge(
102 HardExecutionContext::new(DiagnosticExecutionBudgetScope::Execution, lane, shape),
103 DiagnosticExecutionBudgetResource::QueryExecutions,
104 1,
105 )
106 .map_err(mutation_job_execution_budget_error)
107 }
108}
109
110#[cfg(feature = "sql")]
111fn resolve_occupied_mutation_job_start(
112 retained: &MutationJobRecord,
113 submitted_intent: &CanonicalMutationIntent,
114) -> Result<MutationJobState, MutationJobError> {
115 let retained_intent = CanonicalMutationIntent::decode(retained.canonical_intent())?;
116 if retained_intent.same_start_request(submitted_intent) {
117 return Ok(retained.state().clone());
118 }
119 if !retained_intent.same_authority(submitted_intent) {
120 return Err(MutationJobError::AuthorityMismatch);
121 }
122 Err(MutationJobError::IdentityConflict)
123}
124
125const fn mutation_job_execution_budget_error(error: ExecutionBudgetExceeded) -> MutationJobError {
126 MutationJobError::ExecutionBudgetExceeded {
127 resource: error.resource().raw(),
128 limit: error.limit(),
129 observed: error.observed(),
130 scope: error.scope().raw(),
131 lane: error.lane().raw(),
132 normalized_shape_fingerprint_prefix: error.normalized_shape_fingerprint_prefix(),
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::{
140 db::{
141 MutationJobAdvanceRequest, MutationJobIdempotencyKey, MutationJobPhase,
142 MutationJobRestartReason, MutationJobStatus, RequestExecutionRoot, StoreRegistry,
143 mutation_job::{MutationJobRecord, MutationJobTransition},
144 },
145 traits::Path,
146 };
147 #[cfg(feature = "sql")]
148 use crate::{
149 db::{
150 data::{AcceptedFixedUpdatePatch, FieldSlot},
151 query::plan::expr::{BinaryOp, Expr, FieldId},
152 },
153 types::Timestamp,
154 value::Value,
155 };
156
157 struct TestCanister;
158
159 impl Path for TestCanister {
160 const PATH: &'static str = "db::session::mutation_job::tests::Canister";
161 }
162
163 impl CanisterKind for TestCanister {
164 const COMMIT_MEMORY_ID: u8 = 244;
165 const COMMIT_STABLE_KEY: &'static str = "icydb.test.mutation-job.commit.v1";
166 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 245;
167 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str = "icydb.test.mutation-job.progress.v1";
168 }
169
170 thread_local! {
171 static STORE_REGISTRY: StoreRegistry = StoreRegistry::new();
172 }
173
174 fn job_id() -> MutationJobId {
175 MutationJobId::try_from_bytes([19; 32]).expect("nonzero mutation job id should admit")
176 }
177
178 fn session() -> DbSession<TestCanister> {
179 let root = RequestExecutionRoot::__new_runtime_root();
180 DbSession::new(&STORE_REGISTRY, &root)
181 }
182
183 #[test]
184 fn session_load_and_terminal_acknowledgement_preserve_the_store_contract() {
185 let initial = MutationJobRecord::new(job_id(), vec![1, 2], Vec::new())
186 .expect("bounded initial record should admit");
187 with_mutation_progress_store::<TestCanister, _>(|store| {
188 store.insert_mutation(&initial).map(|_| ())
189 })
190 .expect("initial record should insert");
191
192 let session = session();
193 assert_eq!(
194 session.mutation_job_state(job_id()),
195 Ok(initial.state().clone())
196 );
197 assert_eq!(
198 session.acknowledge_mutation_job(job_id(), 0),
199 Err(MutationJobError::Active),
200 );
201
202 let request = MutationJobAdvanceRequest::new(
203 job_id(),
204 0,
205 MutationJobIdempotencyKey::new("authority-drift")
206 .expect("bounded idempotency key should admit"),
207 );
208 let (terminal, _) = initial
209 .apply_transition(
210 &request,
211 MutationJobTransition::new(
212 MutationJobStatus::RestartRequired(
213 MutationJobRestartReason::AcceptedSchemaChanged,
214 ),
215 MutationJobPhase::Forward,
216 Vec::new(),
217 0,
218 0,
219 0,
220 ),
221 )
222 .expect("terminal restart receipt should admit");
223 with_mutation_progress_store::<TestCanister, _>(|store| store.replace_mutation(&terminal))
224 .expect("terminal record should replace active state");
225
226 assert_eq!(
227 session.acknowledge_mutation_job(job_id(), 0),
228 Err(MutationJobError::StaleSequence {
229 expected: 0,
230 actual: 1,
231 }),
232 );
233 assert_eq!(session.acknowledge_mutation_job(job_id(), 1), Ok(()));
234 assert_eq!(session.acknowledge_mutation_job(job_id(), 1), Ok(()));
235 assert_eq!(
236 session.mutation_job_state(job_id()),
237 Err(MutationJobError::NotFound),
238 );
239 }
240
241 #[cfg(feature = "sql")]
242 fn canonical_intent(
243 authority: u8,
244 scope_value: u64,
245 timestamp: i64,
246 ) -> CanonicalMutationIntent {
247 let scope = Expr::Binary {
248 op: BinaryOp::Eq,
249 left: Box::new(Expr::Field(FieldId::new("collection_id"))),
250 right: Box::new(Expr::Literal(Value::Nat64(scope_value))),
251 };
252 let patch = AcceptedFixedUpdatePatch::from_canonical_fields(vec![(
253 FieldSlot::from_validated_index(1),
254 vec![3, 4, 5],
255 )])
256 .expect("fixed patch should admit");
257 CanonicalMutationIntent::new(
258 [authority; 16],
259 [authority; 32],
260 "journaled".to_string(),
261 "schema::Token".to_string(),
262 7,
263 11,
264 1,
265 [authority; 16],
266 &scope,
267 &patch,
268 Timestamp::from_millis(timestamp),
269 17,
270 )
271 .expect("canonical intent should admit")
272 }
273
274 #[cfg(feature = "sql")]
275 #[test]
276 fn occupied_start_distinguishes_replay_authority_drift_and_identity_conflict() {
277 let retained_intent = canonical_intent(1, 7, 100);
278 let retained = MutationJobRecord::new(
279 job_id(),
280 retained_intent.encode().expect("intent should encode"),
281 vec![9],
282 )
283 .expect("record should admit");
284
285 assert_eq!(
286 resolve_occupied_mutation_job_start(&retained, &canonical_intent(1, 7, 200)),
287 Ok(retained.state().clone()),
288 );
289 assert_eq!(
290 resolve_occupied_mutation_job_start(&retained, &canonical_intent(2, 7, 200)),
291 Err(MutationJobError::AuthorityMismatch),
292 );
293 assert_eq!(
294 resolve_occupied_mutation_job_start(&retained, &canonical_intent(1, 8, 200)),
295 Err(MutationJobError::IdentityConflict),
296 );
297 }
298}