1use super::AcceptedSchemaCatalogContext;
8use crate::{
9 db::{
10 DbSession, DynamicMutation, DynamicMutationResult, DynamicStructuralPatch,
11 DynamicTypedBindingError, DynamicTypedEntityBinding, DynamicTypedFieldBindingRequest,
12 DynamicTypedFieldType, DynamicTypedMutation, DynamicTypedStructuralPatch, DynamicWriteCell,
13 commit::{CommitRowOp, database_incarnation_id},
14 data::{
15 AcceptedMutationIntentPatch, AcceptedPreKeyInsert, DecodedDataStoreKey, FieldSlot,
16 RawRow, StructuralRowContract, StructuralSlotReader,
17 canonical_row_from_raw_row_with_accepted_decode_contract,
18 resolve_existing_replace_structural_patch_with_accepted_contract,
19 resolve_insert_structural_patch_with_accepted_contract,
20 resolve_update_structural_patch_with_accepted_contract,
21 },
22 executor::{
23 AcceptedMutationConstraintScheduler,
24 commit_structural_row_ops_with_mutation_progress_for_path,
25 commit_structural_row_ops_with_window_for_path, mutation_key_exists_error,
26 },
27 integrity::MutationProgressRecordOp,
28 schema::{
29 AcceptedFieldKind, AcceptedIdentityAllocation, AcceptedRowLayoutRuntimeContract,
30 FieldId, FieldInsertGeneration, IdentityStatementCursor, lower_field_type,
31 output_value_from_runtime,
32 },
33 write_context::{AcceptedWriteContext, MutationMode},
34 },
35 error::{InternalError, MutationDiagnosticContext},
36 metrics::sink::{MetricsEvent, SaveMutationKind, record},
37 traits::CanisterKind,
38 types::{CurrentTimestamp, Timestamp},
39 value::{InputValue, Value},
40};
41use icydb_schema::{EntitySourceKey, FieldSourceKey, FieldType, TypeSourceKey};
42
43#[derive(Clone, Debug, Eq, PartialEq)]
44struct AcceptedIdentityInsertField {
45 field_id: FieldId,
46 field_slot: usize,
47 accepted_kind: AcceptedFieldKind,
48}
49
50struct AcceptedStructuralMutationCommitOptions {
51 mutation_progress: Option<MutationProgressRecordOp>,
52 capture_output_values: bool,
53}
54
55impl AcceptedStructuralMutationCommitOptions {
56 const fn standard() -> Self {
57 Self {
58 mutation_progress: None,
59 capture_output_values: true,
60 }
61 }
62
63 const fn with_mutation_progress(mutation_progress: MutationProgressRecordOp) -> Self {
64 Self {
65 mutation_progress: Some(mutation_progress),
66 capture_output_values: false,
67 }
68 }
69}
70
71pub(in crate::db::session) enum AcceptedStructuralMutationTarget {
74 ResolveFromAfterImage,
75 Expected(Box<DecodedDataStoreKey>),
76 ExpectedLoaded(AcceptedLoadedStructuralRow),
77}
78
79pub(in crate::db::session) struct AcceptedLoadedStructuralRow {
82 key: Box<DecodedDataStoreKey>,
83 row: RawRow,
84}
85
86impl AcceptedLoadedStructuralRow {
87 pub(in crate::db::session) fn from_validated_parts(
88 key: DecodedDataStoreKey,
89 row: RawRow,
90 ) -> Self {
91 Self {
92 key: Box::new(key),
93 row,
94 }
95 }
96
97 fn into_parts(self) -> (DecodedDataStoreKey, RawRow) {
98 (*self.key, self.row)
99 }
100}
101
102impl AcceptedStructuralMutationTarget {
103 pub(in crate::db::session) fn expected(key: DecodedDataStoreKey) -> Self {
104 Self::Expected(Box::new(key))
105 }
106
107 pub(in crate::db::session) const fn expected_loaded(row: AcceptedLoadedStructuralRow) -> Self {
110 Self::ExpectedLoaded(row)
111 }
112}
113
114pub(in crate::db::session) enum AcceptedStructuralMutation {
117 Save {
118 mode: MutationMode,
119 target: AcceptedStructuralMutationTarget,
120 patch: AcceptedMutationIntentPatch,
121 },
122 Delete {
123 key: Box<DecodedDataStoreKey>,
124 },
125}
126
127impl AcceptedStructuralMutation {
128 pub(in crate::db::session) const fn save(
129 mode: MutationMode,
130 target: AcceptedStructuralMutationTarget,
131 patch: AcceptedMutationIntentPatch,
132 ) -> Self {
133 Self::Save {
134 mode,
135 target,
136 patch,
137 }
138 }
139
140 pub(in crate::db::session) fn delete(key: DecodedDataStoreKey) -> Self {
141 Self::Delete { key: Box::new(key) }
142 }
143}
144
145const MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS: usize = 4_096;
146const MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES: usize = 16 * 1024 * 1024;
147const MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES: usize = 1024 * 1024;
148
149fn add_structural_mutation_staged_bytes(
150 total: &mut usize,
151 lengths: impl IntoIterator<Item = usize>,
152) -> Result<(), InternalError> {
153 for length in lengths {
154 *total = total.checked_add(length).ok_or_else(|| {
155 InternalError::mutation_batch_staged_bytes_exceeded(
156 None,
157 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
158 )
159 })?;
160 if *total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
161 return Err(InternalError::mutation_batch_staged_bytes_exceeded(
162 Some(*total),
163 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
164 ));
165 }
166 }
167 Ok(())
168}
169
170fn validate_structural_mutation_result_bytes(encoded_bytes: usize) -> Result<(), InternalError> {
171 if encoded_bytes > MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES {
172 return Err(InternalError::mutation_batch_result_bytes_exceeded(
173 encoded_bytes,
174 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES,
175 ));
176 }
177 Ok(())
178}
179
180pub(in crate::db::session) struct AcceptedStructuralMutationRow {
182 values: Vec<Value>,
183 logical_changed: bool,
184}
185
186impl AcceptedStructuralMutationRow {
187 #[cfg(any(feature = "sql", test))]
188 pub(in crate::db::session) fn into_values(self) -> Vec<Value> {
189 self.values
190 }
191
192 pub(in crate::db::session) const fn logical_changed(&self) -> bool {
193 self.logical_changed
194 }
195}
196
197const fn dynamic_mutation_mode(request: &DynamicMutation) -> Option<MutationMode> {
198 match request {
199 DynamicMutation::Insert { .. } => Some(MutationMode::Insert),
200 DynamicMutation::Update { .. } => Some(MutationMode::Update),
201 DynamicMutation::Replace { .. } => Some(MutationMode::Replace),
202 DynamicMutation::Delete { .. } => None,
203 }
204}
205
206const fn dynamic_typed_mutation_mode(request: &DynamicTypedMutation) -> MutationMode {
207 match request {
208 DynamicTypedMutation::Insert { .. } => MutationMode::Insert,
209 DynamicTypedMutation::Update { .. } => MutationMode::Update,
210 DynamicTypedMutation::Replace { .. } => MutationMode::Replace,
211 }
212}
213
214const fn diagnostic_mutation_operation(
215 mode: MutationMode,
216) -> icydb_diagnostic_code::DiagnosticMutationOperation {
217 match mode {
218 MutationMode::Insert => icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
219 MutationMode::Replace => icydb_diagnostic_code::DiagnosticMutationOperation::Replace,
220 MutationMode::Update => icydb_diagnostic_code::DiagnosticMutationOperation::Update,
221 }
222}
223
224const fn mutation_diagnostic_context(
225 entity_tag: crate::types::EntityTag,
226 mode: MutationMode,
227 batch_position: u32,
228) -> MutationDiagnosticContext {
229 MutationDiagnosticContext::new(
230 entity_tag.value(),
231 diagnostic_mutation_operation(mode),
232 batch_position,
233 )
234}
235
236const fn dynamic_write_context(operation_timestamp: Timestamp) -> AcceptedWriteContext {
237 AcceptedWriteContext::new(operation_timestamp)
238}
239
240fn insert_key_exists_after_generation(identity_generated: bool) -> InternalError {
241 if identity_generated {
242 InternalError::identity_state_corruption()
243 } else {
244 mutation_key_exists_error()
245 }
246}
247
248fn dynamic_key(
249 entity_tag: crate::types::EntityTag,
250 key: &InputValue,
251) -> Result<DecodedDataStoreKey, InternalError> {
252 let value = key
253 .clone()
254 .try_into_runtime_non_enum()
255 .ok_or_else(InternalError::executor_unsupported)?;
256 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
257}
258
259fn lower_dynamic_patch(
260 entity_path: &str,
261 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
262 patch: &DynamicStructuralPatch,
263 mode: MutationMode,
264 mutation_context: MutationDiagnosticContext,
265) -> Result<AcceptedMutationIntentPatch, InternalError> {
266 let mut lowered = AcceptedMutationIntentPatch::new();
267 for (field_name, cell) in patch.fields() {
268 let slot = descriptor
269 .field_slot_index_by_name(field_name)
270 .ok_or_else(|| {
271 InternalError::mutation_structural_field_unknown(entity_path, field_name)
272 })?;
273 let field = descriptor
274 .field_for_slot_index(slot)
275 .ok_or_else(InternalError::executor_invariant)?;
276 if !matches!(cell, DynamicWriteCell::Omitted)
277 && (field.write_policy().insert_generation().is_some()
278 || field.write_policy().write_management().is_some())
279 {
280 return Err(InternalError::mutation_database_owned_field_explicit(
281 mutation_context,
282 field.field_id().get(),
283 ));
284 }
285 let slot = FieldSlot::from_validated_index(slot);
286 lowered = match cell {
287 DynamicWriteCell::Omitted => lowered,
288 DynamicWriteCell::Default => match mode {
289 MutationMode::Insert | MutationMode::Replace => {
290 lowered.set_explicit_insert_default(slot)
291 }
292 MutationMode::Update => lowered.set_explicit_update_default(slot),
293 },
294 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::Null),
295 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value.clone()),
296 };
297 }
298 Ok(lowered)
299}
300
301fn lower_dynamic_mutation_intent(
302 entity_tag: crate::types::EntityTag,
303 entity_path: &str,
304 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
305 request: &DynamicMutation,
306 batch_position: u32,
307) -> Result<(AcceptedStructuralMutation, Option<SaveMutationKind>), InternalError> {
308 match request {
309 DynamicMutation::Insert { patch, .. } => {
310 let mode = MutationMode::Insert;
311 Ok((
312 AcceptedStructuralMutation::save(
313 mode,
314 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
315 lower_dynamic_patch(
316 entity_path,
317 descriptor,
318 patch,
319 mode,
320 mutation_diagnostic_context(entity_tag, mode, batch_position),
321 )?,
322 ),
323 Some(SaveMutationKind::Insert),
324 ))
325 }
326 DynamicMutation::Update { key, patch, .. }
327 | DynamicMutation::Replace { key, patch, .. } => {
328 let mode =
329 dynamic_mutation_mode(request).ok_or_else(InternalError::executor_invariant)?;
330 let kind = match mode {
331 MutationMode::Insert => SaveMutationKind::Insert,
332 MutationMode::Replace => SaveMutationKind::Replace,
333 MutationMode::Update => SaveMutationKind::Update,
334 };
335 Ok((
336 AcceptedStructuralMutation::save(
337 mode,
338 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
339 lower_dynamic_patch(
340 entity_path,
341 descriptor,
342 patch,
343 mode,
344 mutation_diagnostic_context(entity_tag, mode, batch_position),
345 )?,
346 ),
347 Some(kind),
348 ))
349 }
350 DynamicMutation::Delete { key, .. } => Ok((
351 AcceptedStructuralMutation::delete(dynamic_key(entity_tag, key)?),
352 None,
353 )),
354 }
355}
356
357fn lower_typed_patch(
358 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
359 patch: &DynamicTypedStructuralPatch,
360 mode: MutationMode,
361 mutation_context: MutationDiagnosticContext,
362) -> Result<AcceptedMutationIntentPatch, InternalError> {
363 let mut lowered = AcceptedMutationIntentPatch::new();
364 for (field_id, slot, cell) in patch.fields() {
365 let slot_index = usize::from(*slot);
366 let field = descriptor
367 .field_for_slot_index(slot_index)
368 .ok_or_else(InternalError::store_invariant)?;
369 if field.field_id().get() != *field_id {
370 return Err(InternalError::store_invariant());
371 }
372 if !matches!(cell, DynamicWriteCell::Omitted)
373 && (field.write_policy().insert_generation().is_some()
374 || field.write_policy().write_management().is_some())
375 {
376 return Err(InternalError::mutation_database_owned_field_explicit(
377 mutation_context,
378 field.field_id().get(),
379 ));
380 }
381 let slot = FieldSlot::from_validated_index(slot_index);
382 lowered = match cell {
383 DynamicWriteCell::Omitted => lowered,
384 DynamicWriteCell::Default => match mode {
385 MutationMode::Insert | MutationMode::Replace => {
386 lowered.set_explicit_insert_default(slot)
387 }
388 MutationMode::Update => lowered.set_explicit_update_default(slot),
389 },
390 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::Null),
391 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value.clone()),
392 };
393 }
394 Ok(lowered)
395}
396
397fn preserve_dynamic_replacement_identity(
398 key: &DecodedDataStoreKey,
399 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
400 mut patch: AcceptedMutationIntentPatch,
401) -> Result<AcceptedMutationIntentPatch, InternalError> {
402 let primary_key_slots = descriptor.primary_key_slot_indices();
403 let runtime_key = key.primary_key_runtime_value();
404 let components = match runtime_key {
405 Value::List(values) if primary_key_slots.len() > 1 => values,
406 value if primary_key_slots.len() == 1 => vec![value],
407 _ => return Err(InternalError::executor_invariant()),
408 };
409 if components.len() != primary_key_slots.len() {
410 return Err(InternalError::executor_invariant());
411 }
412
413 for (slot, value) in primary_key_slots.iter().copied().zip(components) {
414 let _ = descriptor
415 .field_for_slot_index(slot)
416 .ok_or_else(InternalError::executor_invariant)?;
417 let has_explicit_intent = patch
418 .entries()
419 .iter()
420 .any(|entry| entry.slot().index() == slot);
421 if has_explicit_intent {
422 continue;
423 }
424 let value = InputValue::try_from_runtime_non_enum(&value)
425 .ok_or_else(InternalError::executor_invariant)?;
426 patch =
427 patch.set_preserved_replacement_identity(FieldSlot::from_validated_index(slot), value);
428 }
429
430 Ok(patch)
431}
432
433fn accepted_identity_insert_field(
437 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
438) -> Result<Option<AcceptedIdentityInsertField>, InternalError> {
439 let mut identity = None;
440 for field in descriptor.fields() {
441 if field.write_policy().insert_generation() != Some(FieldInsertGeneration::Identity) {
442 continue;
443 }
444 let field_slot = usize::from(field.slot().get());
445 if identity
446 .replace(AcceptedIdentityInsertField {
447 field_id: field.field_id(),
448 field_slot,
449 accepted_kind: field.kind().clone(),
450 })
451 .is_some()
452 || descriptor.primary_key_slot_indices() != [field_slot]
453 {
454 return Err(InternalError::identity_corruption());
455 }
456 }
457 Ok(identity)
458}
459
460fn checked_pre_key_candidate_count(count: usize) -> Result<u32, InternalError> {
461 u32::try_from(count).map_err(|_| InternalError::identity_candidate_count_exhausted())
462}
463
464fn validate_identity_materialization(
465 entity_tag: crate::types::EntityTag,
466 identity_field: &AcceptedIdentityInsertField,
467 candidate: &AcceptedPreKeyInsert,
468 allocation: &AcceptedIdentityAllocation,
469 data_key: &DecodedDataStoreKey,
470 reader: &StructuralSlotReader<'_>,
471) -> Result<(), InternalError> {
472 let owner = allocation.owner();
473 let slot_value = reader.required_cached_value(identity_field.field_slot)?;
474 if candidate.entity_tag() != entity_tag
475 || candidate.input_ordinal() != allocation.input_ordinal()
476 || owner.entity_tag() != entity_tag
477 || owner.field_id() != identity_field.field_id
478 || allocation.field_slot() != identity_field.field_slot
479 || slot_value != allocation.value()
480 || data_key.primary_key_runtime_value() != *allocation.value()
481 {
482 return Err(InternalError::identity_corruption());
483 }
484 Ok(())
485}
486
487fn data_key_from_row(
488 entity_tag: crate::types::EntityTag,
489 contract: &StructuralRowContract,
490 row: &RawRow,
491) -> Result<DecodedDataStoreKey, InternalError> {
492 let reader =
493 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
494 let values = contract
495 .primary_key_slot_indices()
496 .iter()
497 .map(|slot| reader.required_cached_value(*slot).cloned())
498 .collect::<Result<Vec<_>, _>>()?;
499 let value = match values.as_slice() {
500 [value] => value.clone(),
501 _ => Value::List(values),
502 };
503 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
504}
505
506#[cfg(feature = "sql")]
507pub(in crate::db::session) fn structural_data_key_from_runtime_values(
508 entity_tag: crate::types::EntityTag,
509 values: Vec<Value>,
510) -> Result<DecodedDataStoreKey, InternalError> {
511 let value = match values.as_slice() {
512 [value] => value.clone(),
513 _ => Value::List(values),
514 };
515 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
516}
517
518fn validated_existing_row(
519 store: crate::db::registry::StoreHandle,
520 data_key: &DecodedDataStoreKey,
521 contract: &StructuralRowContract,
522) -> Result<Option<RawRow>, InternalError> {
523 let raw_key = data_key.to_raw()?;
524 let row = store.with_data(|data| data.get(&raw_key));
525 if let Some(row) = row.as_ref() {
526 let reader =
527 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
528 reader.validate_primary_key(data_key)?;
529 }
530 Ok(row)
531}
532
533fn prepare_dynamic_mutation_result(
534 catalog: &AcceptedSchemaCatalogContext,
535 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
536 rows: Vec<AcceptedStructuralMutationRow>,
537 enforce_mixed_batch_result_bound: bool,
538) -> Result<DynamicMutationResult, InternalError> {
539 let affected_rows = rows.iter().try_fold(0_u32, |total, row| {
540 total
541 .checked_add(u32::from(row.logical_changed()))
542 .ok_or_else(InternalError::executor_invariant)
543 })?;
544 let columns = descriptor
545 .fields()
546 .iter()
547 .map(|field| field.name().to_string())
548 .collect();
549 let rows = rows
550 .into_iter()
551 .map(|row| {
552 row.values
553 .iter()
554 .map(|value| {
555 output_value_from_runtime(catalog.enum_catalog(), value)
556 .map_err(|_| InternalError::store_invariant())
557 })
558 .collect::<Result<Vec<_>, _>>()
559 })
560 .collect::<Result<Vec<_>, _>>()?;
561 let result = DynamicMutationResult {
562 entity: catalog.snapshot().entity_name().to_string(),
563 columns,
564 rows,
565 affected_rows,
566 };
567 if enforce_mixed_batch_result_bound {
568 let encoded =
569 candid::encode_one(&result).map_err(|_| InternalError::executor_invariant())?;
570 validate_structural_mutation_result_bytes(encoded.len())?;
571 }
572 Ok(result)
573}
574
575fn dynamic_typed_field_type(
576 field_type: DynamicTypedFieldType,
577) -> Result<FieldType, DynamicTypedBindingError> {
578 match field_type {
579 DynamicTypedFieldType::Scalar(scalar) => Ok(FieldType::Scalar(scalar)),
580 DynamicTypedFieldType::List(item) => {
581 Ok(FieldType::List(Box::new(dynamic_typed_field_type(*item)?)))
582 }
583 DynamicTypedFieldType::Named(source_key) => TypeSourceKey::try_new(source_key)
584 .map(FieldType::Named)
585 .map_err(|_| DynamicTypedBindingError::FieldUnavailable),
586 }
587}
588
589fn typed_adapter_field_kind_matches(
590 accepted: &AcceptedFieldKind,
591 expected: &AcceptedFieldKind,
592) -> bool {
593 if accepted == expected {
594 return true;
595 }
596 match (accepted, expected) {
597 (AcceptedFieldKind::Relation { key_kind, .. }, expected) => {
598 typed_adapter_field_kind_matches(key_kind, expected)
599 }
600 (AcceptedFieldKind::List(accepted), AcceptedFieldKind::List(expected)) => {
601 typed_adapter_field_kind_matches(accepted, expected)
602 }
603 _ => false,
604 }
605}
606
607impl<C: CanisterKind> DbSession<C> {
608 pub fn issue_typed_entity_binding(
610 &self,
611 entity_source_key: &str,
612 field_requests: &[DynamicTypedFieldBindingRequest],
613 ) -> Result<DynamicTypedEntityBinding, DynamicTypedBindingError> {
614 let entity_source = EntitySourceKey::try_new(entity_source_key)
615 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?;
616 let field_requests = field_requests
617 .iter()
618 .map(|request| {
619 Ok((
620 FieldSourceKey::try_new(request.source_key.clone())
621 .map_err(|_| DynamicTypedBindingError::FieldUnavailable)?,
622 dynamic_typed_field_type(request.field_type.clone())?,
623 request.nullable,
624 ))
625 })
626 .collect::<Result<Vec<_>, DynamicTypedBindingError>>()?;
627 let catalog = self
628 .find_accepted_schema_catalog_context_for_entity_source_key(entity_source.as_str())?
629 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
630 let identity = catalog.identity();
631 if identity.entity_path() != entity_source.as_str() {
632 return Err(InternalError::store_invariant().into());
633 }
634 let store = self.db.recovered_store(identity.store_path())?;
635 let bundle = store
636 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
637 .ok_or_else(InternalError::store_invariant)?;
638 let entity_tag = identity.entity_tag();
639 if bundle.source_bindings().entity(&entity_source) != Some(entity_tag)
640 || bundle.revision() != catalog.revision()
641 {
642 return Err(InternalError::store_invariant().into());
643 }
644 let snapshot = bundle
645 .entity_snapshots()
646 .get(&entity_tag)
647 .ok_or_else(InternalError::store_invariant)?;
648 let row_contract = catalog.inspection_plan().row_contract();
649 let mut fields = Vec::with_capacity(field_requests.len());
650 for (source, field_type, nullable) in &field_requests {
651 let field_id = bundle
652 .source_bindings()
653 .field(entity_tag, source)
654 .ok_or(DynamicTypedBindingError::FieldUnavailable)?;
655 let field = snapshot
656 .fields()
657 .iter()
658 .find(|field| field.id() == field_id)
659 .ok_or_else(InternalError::store_invariant)?;
660 let runtime_field =
661 row_contract.required_accepted_field_contract(usize::from(field.slot().get()))?;
662 if runtime_field.field_id() != field_id {
663 return Err(InternalError::store_invariant().into());
664 }
665 let expected_kind = lower_field_type(field_type, bundle.source_bindings())
666 .map_err(|_| DynamicTypedBindingError::IncompatibleField)?;
667 if field.nullable() != *nullable
668 || !typed_adapter_field_kind_matches(field.kind(), &expected_kind)
669 {
670 return Err(DynamicTypedBindingError::IncompatibleField);
671 }
672 fields.push((
673 source.as_str().to_string(),
674 field_id.get(),
675 field.slot().get(),
676 field.name().to_string(),
677 ));
678 }
679 let adapter_names = bundle.typed_adapter_names()?;
680
681 DynamicTypedEntityBinding::new(
682 database_incarnation_id()?.to_bytes(),
683 entity_source.as_str().to_string(),
684 snapshot.entity_name().to_string(),
685 entity_tag.value(),
686 catalog.revision().get(),
687 catalog.fingerprint(),
688 row_contract.current_layout_version().get(),
689 fields,
690 adapter_names.named_types,
691 adapter_names.enum_variants,
692 adapter_names.composite_fields,
693 )
694 .map_err(Into::into)
695 }
696
697 pub(in crate::db::session) fn current_typed_entity_binding_catalog(
698 &self,
699 binding: &DynamicTypedEntityBinding,
700 ) -> Result<Option<AcceptedSchemaCatalogContext>, InternalError> {
701 if database_incarnation_id()?.to_bytes() != binding.database_incarnation {
702 return Ok(None);
703 }
704 let Some(catalog) = self.find_accepted_schema_catalog_context_for_entity_source_key(
705 binding.entity_source.as_str(),
706 )?
707 else {
708 return Ok(None);
709 };
710 let row_contract = catalog.inspection_plan().row_contract();
711 let identity = catalog.identity();
712 if identity.entity_path() != binding.entity_source.as_str()
713 || identity.entity_tag().value() != binding.entity_tag
714 || catalog.revision().get() != binding.accepted_revision
715 || catalog.fingerprint() != binding.accepted_fingerprint
716 || row_contract.current_layout_version().get() != binding.entity_generation
717 {
718 return Ok(None);
719 }
720 let entity_source = EntitySourceKey::try_new(binding.entity_source.clone())
721 .map_err(|_| InternalError::store_invariant())?;
722 let store = self.db.recovered_store(identity.store_path())?;
723 let bundle = store
724 .with_schema(crate::db::schema::SchemaStore::current_accepted_schema_bundle)?
725 .ok_or_else(InternalError::store_invariant)?;
726 if bundle.revision() != catalog.revision()
727 || bundle.source_bindings().entity(&entity_source) != Some(identity.entity_tag())
728 {
729 return Ok(None);
730 }
731 let snapshot = bundle
732 .entity_snapshots()
733 .get(&identity.entity_tag())
734 .ok_or_else(InternalError::store_invariant)?;
735 for (source_key, expected_field_id, expected_slot) in binding.field_identity_bindings() {
736 let source = FieldSourceKey::try_new(source_key)
737 .map_err(|_| InternalError::store_invariant())?;
738 let Some(field_id) = bundle
739 .source_bindings()
740 .field(identity.entity_tag(), &source)
741 else {
742 return Ok(None);
743 };
744 let Some(field) = snapshot
745 .fields()
746 .iter()
747 .find(|field| field.id() == field_id)
748 else {
749 return Err(InternalError::store_invariant());
750 };
751 if field_id.get() != expected_field_id || field.slot().get() != expected_slot {
752 return Ok(None);
753 }
754 }
755 Ok(Some(catalog))
756 }
757
758 pub fn typed_entity_binding_is_current(
760 &self,
761 binding: &DynamicTypedEntityBinding,
762 ) -> Result<bool, InternalError> {
763 self.current_typed_entity_binding_catalog(binding)
764 .map(|catalog| catalog.is_some())
765 }
766
767 #[cfg(feature = "sql")]
770 pub(in crate::db::session) fn execute_accepted_structural_delete_batch(
771 &self,
772 catalog: &AcceptedSchemaCatalogContext,
773 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
774 keys: Vec<DecodedDataStoreKey>,
775 precommit_validation: impl FnOnce(&[Vec<Value>]) -> Result<(), InternalError>,
776 ) -> Result<Vec<Vec<Value>>, InternalError> {
777 let mutations = keys
778 .into_iter()
779 .map(AcceptedStructuralMutation::delete)
780 .collect();
781 self.execute_accepted_structural_mutation_batch_inner(
782 catalog,
783 descriptor,
784 mutations,
785 Timestamp::now(),
786 AcceptedStructuralMutationCommitOptions::standard(),
787 |rows| {
788 let rows = rows
789 .into_iter()
790 .map(AcceptedStructuralMutationRow::into_values)
791 .collect::<Vec<_>>();
792 precommit_validation(rows.as_slice())?;
793 Ok(rows)
794 },
795 )
796 }
797
798 pub(in crate::db::session) fn execute_accepted_structural_save_batch<T>(
806 &self,
807 catalog: &AcceptedSchemaCatalogContext,
808 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
809 mutations: Vec<AcceptedStructuralMutation>,
810 operation_timestamp: Timestamp,
811 precommit_preparation: impl FnOnce(
812 Vec<AcceptedStructuralMutationRow>,
813 ) -> Result<T, InternalError>,
814 ) -> Result<T, InternalError> {
815 self.execute_accepted_structural_mutation_batch_inner(
816 catalog,
817 descriptor,
818 mutations,
819 operation_timestamp,
820 AcceptedStructuralMutationCommitOptions::standard(),
821 precommit_preparation,
822 )
823 }
824
825 #[cfg(any(feature = "sql", test))]
827 pub(in crate::db::session) fn execute_accepted_structural_update_with_mutation_progress(
828 &self,
829 catalog: &AcceptedSchemaCatalogContext,
830 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
831 mutations: Vec<AcceptedStructuralMutation>,
832 operation_timestamp: Timestamp,
833 mutation_progress: MutationProgressRecordOp,
834 ) -> Result<usize, InternalError> {
835 self.execute_accepted_structural_mutation_batch_inner(
836 catalog,
837 descriptor,
838 mutations,
839 operation_timestamp,
840 AcceptedStructuralMutationCommitOptions::with_mutation_progress(mutation_progress),
841 |rows| Ok(rows.len()),
842 )
843 }
844
845 #[expect(
846 clippy::too_many_lines,
847 reason = "one phased owner keeps accepted authority, mutation context, precommit preparation, output capture, and commit staging inseparable"
848 )]
849 fn execute_accepted_structural_mutation_batch_inner<T>(
850 &self,
851 catalog: &AcceptedSchemaCatalogContext,
852 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
853 mutations: Vec<AcceptedStructuralMutation>,
854 operation_timestamp: Timestamp,
855 options: AcceptedStructuralMutationCommitOptions,
856 precommit_preparation: impl FnOnce(
857 Vec<AcceptedStructuralMutationRow>,
858 ) -> Result<T, InternalError>,
859 ) -> Result<T, InternalError> {
860 let identity = catalog.identity();
861 let AcceptedStructuralMutationCommitOptions {
862 mutation_progress,
863 capture_output_values,
864 } = options;
865 let entity_path = identity.entity_path();
866 let store_path = identity.store_path();
867 let row_decode_contract =
868 descriptor.row_decode_contract(catalog.value_catalog_handle().clone());
869 let row_contract = StructuralRowContract::from_accepted_decode_contract(
870 entity_path,
871 row_decode_contract.clone(),
872 );
873 let store = self.db.recovered_store(store_path)?;
874 let write_context = dynamic_write_context(operation_timestamp);
875 let identity_field = accepted_identity_insert_field(descriptor)?;
876 let identity_incarnation = identity_field
877 .as_ref()
878 .map(|_| database_incarnation_id())
879 .transpose()?;
880 let mutation_count = mutations.len();
881 if mutation_count > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
882 return Err(InternalError::mutation_batch_too_many_items(
883 mutation_count,
884 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
885 ));
886 }
887 let identity_candidate_count = mutations
888 .iter()
889 .filter(|mutation| {
890 matches!(
891 mutation,
892 AcceptedStructuralMutation::Save {
893 mode: MutationMode::Insert,
894 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
895 ..
896 }
897 )
898 })
899 .count();
900 let _ = checked_pre_key_candidate_count(identity_candidate_count)?;
901 let mut identity_cursor: Option<IdentityStatementCursor> = None;
902 let mut identity_insert_ordinal = 0_u32;
903 let mut scheduler = AcceptedMutationConstraintScheduler::new(
904 entity_path,
905 identity.entity_tag(),
906 row_decode_contract.clone(),
907 catalog.fingerprint(),
908 catalog.fingerprint_method_version(),
909 catalog.accepted_row_constraints(),
910 mutation_count,
911 );
912 let mut output = Vec::with_capacity(mutation_count);
913 let mut staged_bytes = 0_usize;
914
915 for (input_index, mutation) in mutations.into_iter().enumerate() {
916 let batch_input_ordinal = u32::try_from(input_index).map_err(|_| {
917 InternalError::mutation_batch_too_many_items(
918 mutation_count,
919 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
920 )
921 })?;
922 let AcceptedStructuralMutation::Save {
923 mode,
924 target,
925 patch: authored_patch,
926 } = mutation
927 else {
928 let AcceptedStructuralMutation::Delete { key } = mutation else {
929 return Err(InternalError::executor_invariant());
930 };
931 let before = validated_existing_row(store, &key, &row_contract)?
932 .ok_or_else(|| InternalError::store_not_found(&key))?;
933 let raw_key = key.to_raw()?;
934 let canonical_before = canonical_row_from_raw_row_with_accepted_decode_contract(
935 entity_path,
936 row_decode_contract.clone(),
937 &before,
938 )?;
939 add_structural_mutation_staged_bytes(
940 &mut staged_bytes,
941 [
942 raw_key.as_bytes().len(),
943 canonical_before.as_raw_row().as_bytes().len(),
944 ],
945 )?;
946 scheduler.schedule_delete(
947 CommitRowOp::new(
948 entity_path,
949 raw_key,
950 Some(canonical_before.as_raw_row().as_bytes().to_vec()),
951 None,
952 catalog.fingerprint(),
953 ),
954 batch_input_ordinal,
955 )?;
956 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
957 canonical_before.as_raw_row(),
958 &row_contract,
959 )?;
960 let values = if capture_output_values {
961 let mut values = Vec::with_capacity(descriptor.fields().len());
962 for field in descriptor.fields() {
963 values.push(
964 reader
965 .required_cached_value(usize::from(field.slot().get()))?
966 .clone(),
967 );
968 }
969 values
970 } else {
971 Vec::new()
972 };
973 output.push(AcceptedStructuralMutationRow {
974 values,
975 logical_changed: true,
976 });
977 continue;
978 };
979 let mutation_context =
980 mutation_diagnostic_context(identity.entity_tag(), mode, batch_input_ordinal);
981 let (expected_key, preloaded_before, pre_key_insert, mut keyed_patch) = match target {
982 AcceptedStructuralMutationTarget::ResolveFromAfterImage => {
983 let candidate_ordinal =
984 if identity_field.is_some() && matches!(mode, MutationMode::Insert) {
985 identity_insert_ordinal
986 } else {
987 batch_input_ordinal
988 };
989 (
990 None,
991 None,
992 Some(AcceptedPreKeyInsert::new(
993 identity.entity_tag(),
994 authored_patch,
995 candidate_ordinal,
996 )),
997 None,
998 )
999 }
1000 AcceptedStructuralMutationTarget::Expected(key) => {
1001 (Some(*key), None, None, Some(authored_patch))
1002 }
1003 AcceptedStructuralMutationTarget::ExpectedLoaded(loaded) => {
1004 let (key, row) = loaded.into_parts();
1005 (Some(key), Some(row), None, Some(authored_patch))
1006 }
1007 };
1008 if matches!(mode, MutationMode::Replace)
1009 && let Some(key) = expected_key.as_ref()
1010 {
1011 let patch = keyed_patch
1012 .take()
1013 .ok_or_else(InternalError::executor_invariant)?;
1014 keyed_patch = Some(preserve_dynamic_replacement_identity(
1015 key, descriptor, patch,
1016 )?);
1017 }
1018 let patch = pre_key_insert
1019 .as_ref()
1020 .map(AcceptedPreKeyInsert::fields)
1021 .or(keyed_patch.as_ref())
1022 .ok_or_else(InternalError::executor_invariant)?;
1023 let before = match (expected_key.as_ref(), preloaded_before) {
1024 (Some(_), Some(row)) => Some(row),
1025 (Some(key), None) => validated_existing_row(store, key, &row_contract)?,
1026 (None, None) => None,
1027 (None, Some(_)) => return Err(InternalError::executor_invariant()),
1028 };
1029 match mode {
1030 MutationMode::Insert if before.is_some() => {
1031 return Err(mutation_key_exists_error());
1032 }
1033 MutationMode::Update if before.is_none() => {
1034 let key = expected_key
1035 .as_ref()
1036 .ok_or_else(InternalError::executor_invariant)?;
1037 return Err(InternalError::store_not_found(key));
1038 }
1039 MutationMode::Insert | MutationMode::Replace | MutationMode::Update => {}
1040 }
1041
1042 let identity_allocation = if let Some(identity_field) = identity_field.as_ref()
1043 && matches!(mode, MutationMode::Insert)
1044 && before.is_none()
1045 {
1046 let candidate = pre_key_insert.as_ref().ok_or_else(|| {
1047 InternalError::mutation_database_owned_field_explicit(
1048 mutation_context,
1049 identity_field.field_id.get(),
1050 )
1051 })?;
1052 if identity_cursor.is_none() {
1053 let incarnation = identity_incarnation
1054 .ok_or_else(InternalError::identity_state_corruption)?;
1055 identity_cursor = Some(store.with_schema(|schema_store| {
1056 schema_store.identity_statement_cursor(
1057 incarnation,
1058 identity.entity_tag(),
1059 identity_field.field_id,
1060 &identity_field.accepted_kind,
1061 )
1062 })?);
1063 }
1064 let allocation = identity_cursor
1065 .as_mut()
1066 .ok_or_else(InternalError::identity_state_corruption)?
1067 .allocate(identity_field.field_slot, candidate.input_ordinal())?;
1068 identity_insert_ordinal = identity_insert_ordinal
1069 .checked_add(1)
1070 .ok_or_else(InternalError::identity_candidate_count_exhausted)?;
1071 Some(allocation)
1072 } else if let Some(identity_field) = identity_field.as_ref()
1073 && matches!(mode, MutationMode::Replace)
1074 && before.is_none()
1075 {
1076 return Err(InternalError::mutation_database_owned_field_explicit(
1077 mutation_context,
1078 identity_field.field_id.get(),
1079 ));
1080 } else {
1081 None
1082 };
1083
1084 let resolved = match (mode, before.as_ref()) {
1085 (MutationMode::Insert | MutationMode::Replace, None) => {
1086 resolve_insert_structural_patch_with_accepted_contract(
1087 entity_path,
1088 row_decode_contract.clone(),
1089 catalog.fingerprint(),
1090 catalog.accepted_row_constraints(),
1091 patch,
1092 write_context,
1093 mutation_context,
1094 identity_allocation.as_ref(),
1095 )?
1096 }
1097 (MutationMode::Update, Some(before)) => {
1098 resolve_update_structural_patch_with_accepted_contract(
1099 entity_path,
1100 row_decode_contract.clone(),
1101 catalog.fingerprint(),
1102 catalog.accepted_row_constraints(),
1103 before,
1104 patch,
1105 write_context,
1106 mutation_context,
1107 )?
1108 }
1109 (MutationMode::Replace, Some(before)) => {
1110 resolve_existing_replace_structural_patch_with_accepted_contract(
1111 entity_path,
1112 row_decode_contract.clone(),
1113 catalog.fingerprint(),
1114 catalog.accepted_row_constraints(),
1115 before,
1116 patch,
1117 write_context,
1118 mutation_context,
1119 )?
1120 }
1121 (MutationMode::Insert, Some(_)) | (MutationMode::Update, None) => {
1122 return Err(InternalError::executor_invariant());
1123 }
1124 };
1125 let (after, provenance) = resolved.into_parts();
1126 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1127 after.as_raw_row(),
1128 &row_contract,
1129 )?;
1130 let data_key = match expected_key {
1131 Some(key) => {
1132 reader.validate_primary_key(&key)?;
1133 key
1134 }
1135 None => {
1136 data_key_from_row(identity.entity_tag(), &row_contract, after.as_raw_row())?
1137 }
1138 };
1139 if let Some(allocation) = identity_allocation.as_ref() {
1140 validate_identity_materialization(
1141 identity.entity_tag(),
1142 identity_field
1143 .as_ref()
1144 .ok_or_else(InternalError::identity_corruption)?,
1145 pre_key_insert
1146 .as_ref()
1147 .ok_or_else(InternalError::identity_corruption)?,
1148 allocation,
1149 &data_key,
1150 &reader,
1151 )?;
1152 }
1153 if matches!(mode, MutationMode::Insert)
1154 && validated_existing_row(store, &data_key, &row_contract)?.is_some()
1155 {
1156 return Err(insert_key_exists_after_generation(
1157 identity_allocation.is_some(),
1158 ));
1159 }
1160 let raw_key = data_key.to_raw()?;
1161 let canonical_before = before
1162 .as_ref()
1163 .map(|before| {
1164 canonical_row_from_raw_row_with_accepted_decode_contract(
1165 entity_path,
1166 row_decode_contract.clone(),
1167 before,
1168 )
1169 })
1170 .transpose()?;
1171 let logical_changed = canonical_before.as_ref().is_none_or(|before| {
1172 before.as_raw_row().as_bytes() != after.as_raw_row().as_bytes()
1173 });
1174 let physical_changed = before
1175 .as_ref()
1176 .is_none_or(|before| before.as_bytes() != after.as_raw_row().as_bytes());
1177 add_structural_mutation_staged_bytes(
1178 &mut staged_bytes,
1179 [
1180 raw_key.as_bytes().len(),
1181 canonical_before
1182 .as_ref()
1183 .map_or(0, |before| before.as_raw_row().as_bytes().len()),
1184 after.as_raw_row().as_bytes().len(),
1185 ],
1186 )?;
1187 let row_op = physical_changed.then(|| {
1188 CommitRowOp::new(
1189 entity_path,
1190 raw_key.clone(),
1191 canonical_before
1192 .as_ref()
1193 .map(|before| before.as_raw_row().as_bytes().to_vec()),
1194 Some(after.as_raw_row().as_bytes().to_vec()),
1195 catalog.fingerprint(),
1196 )
1197 });
1198 scheduler.schedule_save_after_image(
1199 mode,
1200 &data_key,
1201 after.as_raw_row(),
1202 provenance.as_slice(),
1203 row_op,
1204 batch_input_ordinal,
1205 )?;
1206 let values = if capture_output_values {
1207 let mut values = Vec::with_capacity(descriptor.fields().len());
1208 for field in descriptor.fields() {
1209 values.push(
1210 reader
1211 .required_cached_value(usize::from(field.slot().get()))?
1212 .clone(),
1213 );
1214 }
1215 values
1216 } else {
1217 Vec::new()
1218 };
1219 output.push(AcceptedStructuralMutationRow {
1220 values,
1221 logical_changed,
1222 });
1223 }
1224
1225 let batch = scheduler.finish();
1226 let prepared = precommit_preparation(output)?;
1227 let identity_ranges = identity_cursor
1228 .map(IdentityStatementCursor::into_range_advance)
1229 .transpose()?
1230 .into_iter()
1231 .flatten()
1232 .collect::<Vec<_>>();
1233 if batch.is_empty() && !identity_ranges.is_empty() {
1234 return Err(InternalError::identity_corruption());
1235 }
1236 if batch.is_empty() {
1237 if mutation_progress.is_some() {
1238 return Err(InternalError::executor_invariant());
1239 }
1240 } else if let Some(mutation_progress) = mutation_progress {
1241 commit_structural_row_ops_with_mutation_progress_for_path(
1242 &self.db,
1243 entity_path,
1244 batch,
1245 identity_ranges,
1246 mutation_progress,
1247 "accepted_structural_batch_apply",
1248 )?;
1249 } else {
1250 commit_structural_row_ops_with_window_for_path(
1251 &self.db,
1252 entity_path,
1253 batch,
1254 identity_ranges,
1255 "accepted_structural_batch_apply",
1256 )?;
1257 }
1258 Ok(prepared)
1259 }
1260
1261 fn execute_one_accepted_save_mutation(
1262 &self,
1263 catalog: &AcceptedSchemaCatalogContext,
1264 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1265 mode: MutationMode,
1266 target: AcceptedStructuralMutationTarget,
1267 patch: AcceptedMutationIntentPatch,
1268 ) -> Result<DynamicMutationResult, InternalError> {
1269 let identity = catalog.identity();
1270 let entity_path = identity.entity_path();
1271 let result = self.execute_accepted_structural_save_batch(
1272 catalog,
1273 descriptor,
1274 vec![AcceptedStructuralMutation::save(mode, target, patch)],
1275 Timestamp::now(),
1276 |rows| prepare_dynamic_mutation_result(catalog, descriptor, rows, false),
1277 )?;
1278 record(MetricsEvent::SaveMutation {
1279 entity_path: entity_path.into(),
1280 kind: match mode {
1281 MutationMode::Insert => SaveMutationKind::Insert,
1282 MutationMode::Replace => SaveMutationKind::Replace,
1283 MutationMode::Update => SaveMutationKind::Update,
1284 },
1285 rows_touched: u64::from(result.affected_rows),
1286 });
1287 Ok(result)
1288 }
1289
1290 pub fn execute_trusted_dynamic_mutation(
1297 &self,
1298 request: &DynamicMutation,
1299 ) -> Result<DynamicMutationResult, InternalError> {
1300 self.execute_trusted_dynamic_mutation_batch_with_result_policy(vec![request.clone()], false)
1301 }
1302
1303 pub fn execute_trusted_dynamic_mutation_batch(
1309 &self,
1310 requests: Vec<DynamicMutation>,
1311 ) -> Result<DynamicMutationResult, InternalError> {
1312 self.execute_trusted_dynamic_mutation_batch_with_result_policy(requests, true)
1313 }
1314
1315 fn execute_trusted_dynamic_mutation_batch_with_result_policy(
1316 &self,
1317 requests: Vec<DynamicMutation>,
1318 enforce_mixed_batch_result_bound: bool,
1319 ) -> Result<DynamicMutationResult, InternalError> {
1320 if requests.is_empty() {
1321 return Err(InternalError::mutation_batch_empty());
1322 }
1323 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1324 return Err(InternalError::mutation_batch_too_many_items(
1325 requests.len(),
1326 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1327 ));
1328 }
1329 let first = requests
1330 .first()
1331 .ok_or_else(InternalError::mutation_batch_empty)?;
1332 if first.entity().is_empty() {
1333 return Err(InternalError::executor_unsupported());
1334 }
1335 let catalog = self.accepted_schema_catalog_context_for_entity_name(Some(first.entity()))?;
1336 let accepted_identity = catalog.identity();
1337 let descriptor =
1338 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1339 let mut mutations = Vec::with_capacity(requests.len());
1340 let mut save_kinds = Vec::with_capacity(requests.len());
1341
1342 for (batch_position, request) in requests.iter().enumerate() {
1343 let batch_position = u32::try_from(batch_position).map_err(|_| {
1344 InternalError::mutation_batch_too_many_items(
1345 requests.len(),
1346 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1347 )
1348 })?;
1349 if request.entity().is_empty() {
1350 return Err(InternalError::executor_unsupported());
1351 }
1352 let item_catalog =
1353 self.accepted_schema_catalog_context_for_entity_name(Some(request.entity()))?;
1354 if item_catalog.identity() != accepted_identity {
1355 return Err(InternalError::mutation_batch_entity_mismatch(
1356 batch_position,
1357 accepted_identity.entity_tag().value(),
1358 item_catalog.identity().entity_tag().value(),
1359 ));
1360 }
1361 let (mutation, save_kind) = lower_dynamic_mutation_intent(
1362 accepted_identity.entity_tag(),
1363 accepted_identity.entity_path(),
1364 &descriptor,
1365 request,
1366 batch_position,
1367 )?;
1368 mutations.push(mutation);
1369 save_kinds.push(save_kind);
1370 }
1371
1372 let entity_path = accepted_identity.entity_path_handle();
1373 let (result, metrics) = self.execute_accepted_structural_mutation_batch_inner(
1374 &catalog,
1375 &descriptor,
1376 mutations,
1377 Timestamp::now(),
1378 AcceptedStructuralMutationCommitOptions::standard(),
1379 |rows| {
1380 if rows.len() != save_kinds.len() {
1381 return Err(InternalError::executor_invariant());
1382 }
1383 let metrics = rows
1384 .iter()
1385 .zip(save_kinds)
1386 .filter_map(|(row, kind)| kind.map(|kind| (kind, row.logical_changed())))
1387 .collect::<Vec<_>>();
1388 let result = prepare_dynamic_mutation_result(
1389 &catalog,
1390 &descriptor,
1391 rows,
1392 enforce_mixed_batch_result_bound,
1393 )?;
1394 Ok((result, metrics))
1395 },
1396 )?;
1397 for (kind, logical_changed) in metrics {
1398 record(MetricsEvent::SaveMutation {
1399 entity_path: entity_path.clone(),
1400 kind,
1401 rows_touched: u64::from(logical_changed),
1402 });
1403 }
1404 Ok(result)
1405 }
1406
1407 #[doc(hidden)]
1410 pub fn execute_trusted_typed_mutation(
1411 &self,
1412 binding: &DynamicTypedEntityBinding,
1413 request: &DynamicTypedMutation,
1414 ) -> Result<Option<DynamicMutationResult>, InternalError> {
1415 let Some(catalog) = self.current_typed_entity_binding_catalog(binding)? else {
1416 return Ok(None);
1417 };
1418 let identity = catalog.identity();
1419 let descriptor =
1420 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1421 let mode = dynamic_typed_mutation_mode(request);
1422 let (target, patch) = match request {
1423 DynamicTypedMutation::Insert { patch } => (
1424 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1425 patch,
1426 ),
1427 DynamicTypedMutation::Update { key, patch }
1428 | DynamicTypedMutation::Replace { key, patch } => (
1429 AcceptedStructuralMutationTarget::expected(dynamic_key(
1430 identity.entity_tag(),
1431 key,
1432 )?),
1433 patch,
1434 ),
1435 };
1436 if !patch.is_bound_to(binding) {
1437 return Ok(None);
1438 }
1439 let patch = lower_typed_patch(
1440 &descriptor,
1441 patch,
1442 mode,
1443 mutation_diagnostic_context(identity.entity_tag(), mode, 0),
1444 )?;
1445 self.execute_one_accepted_save_mutation(&catalog, &descriptor, mode, target, patch)
1446 .map(Some)
1447 }
1448
1449 pub fn execute_trusted_dynamic_insert_batch(
1455 &self,
1456 entity: &str,
1457 patches: Vec<DynamicStructuralPatch>,
1458 ) -> Result<DynamicMutationResult, InternalError> {
1459 let mutations = patches
1460 .into_iter()
1461 .map(|patch| DynamicMutation::Insert {
1462 entity: entity.to_string(),
1463 patch,
1464 })
1465 .collect();
1466 self.execute_trusted_dynamic_mutation_batch_with_result_policy(mutations, false)
1467 }
1468}
1469
1470#[cfg(test)]
1471mod typed_adapter_tests {
1472 use super::{
1473 AcceptedFieldKind, DbSession, DynamicTypedBindingError, DynamicTypedFieldBindingRequest,
1474 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell, dynamic_typed_field_type,
1475 typed_adapter_field_kind_matches,
1476 };
1477 use crate::{
1478 db::{
1479 data::DataStore,
1480 index::IndexStore,
1481 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
1482 schema::{
1483 AcceptedSchemaRevision, FieldId, FieldStorageDecode, LeafCodec,
1484 PersistedFieldSnapshot, PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot,
1485 SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
1486 accepted_schema_candidate_with_field_bindings_for_tests,
1487 },
1488 },
1489 traits::{CanisterKind, Path},
1490 types::EntityTag,
1491 value::InputValue,
1492 };
1493 use icydb_schema::{EntitySourceKey, FieldSourceKey, ScalarType};
1494 use std::{cell::RefCell, collections::BTreeMap};
1495
1496 const STORE_PATH: &str = "session::write::typed_adapter_tests::Store";
1497 const ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::Entity";
1498 const OTHER_ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity";
1499 const ID_SOURCE: &str = "session::write::typed_adapter_tests::Entity::id";
1500 const VALUE_SOURCE: &str = "session::write::typed_adapter_tests::Entity::value";
1501 const REPLACEMENT_SOURCE: &str =
1502 "session::write::typed_adapter_tests::Entity::replacement_value";
1503 const OTHER_ID_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity::id";
1504
1505 struct TestCanister;
1506
1507 impl Path for TestCanister {
1508 const PATH: &'static str = "session::write::typed_adapter_tests::Canister";
1509 }
1510
1511 impl CanisterKind for TestCanister {
1512 const COMMIT_MEMORY_ID: u8 = 41;
1513 const COMMIT_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.commit.v1";
1514 const STARTUP_MEMORY_ID: u8 = 49;
1515 const STARTUP_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.startup.control.v1";
1516 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 42;
1517 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
1518 "icydb.typed_adapter_tests.integrity.progress.v1";
1519 }
1520
1521 thread_local! {
1522 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
1523 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
1524 static SCHEMA_STORE: RefCell<SchemaStore> =
1525 const { RefCell::new(SchemaStore::init_heap()) };
1526 static STORE_REGISTRY: StoreRegistry = {
1527 let mut registry = StoreRegistry::new();
1528 registry.register_store(
1529 STORE_PATH,
1530 &DATA_STORE,
1531 &INDEX_STORE,
1532 &SCHEMA_STORE,
1533 StoreAllocationIdentities::absent(),
1534 StoreRuntimeStorageCapabilities::heap(),
1535 ).expect("typed adapter test store should register");
1536 registry
1537 };
1538 }
1539
1540 fn nat64_field(id: u32, name: &str, slot: u16) -> PersistedFieldSnapshot {
1541 PersistedFieldSnapshot::new_initial(
1542 FieldId::new(id),
1543 name.to_string(),
1544 SchemaFieldSlot::new(slot),
1545 AcceptedFieldKind::Nat64,
1546 Vec::new(),
1547 false,
1548 SchemaInsertDefault::None,
1549 FieldStorageDecode::ByKind,
1550 LeafCodec::Scalar(ScalarCodec::Nat64),
1551 )
1552 }
1553
1554 fn snapshot(
1555 entity_source: &str,
1556 entity_name: &str,
1557 fields: Vec<PersistedFieldSnapshot>,
1558 ) -> PersistedSchemaSnapshot {
1559 let layout = SchemaRowLayout::initial(
1560 fields
1561 .iter()
1562 .map(|field| (field.id(), field.slot()))
1563 .collect(),
1564 );
1565 PersistedSchemaSnapshot::new(
1566 SchemaVersion::initial(),
1567 entity_source.to_string(),
1568 entity_name.to_string(),
1569 FieldId::new(1),
1570 layout,
1571 fields,
1572 )
1573 }
1574
1575 fn field_source(source: &str) -> FieldSourceKey {
1576 FieldSourceKey::try_new(source).expect("typed field source should admit")
1577 }
1578
1579 fn entity_source(source: &str) -> EntitySourceKey {
1580 EntitySourceKey::try_new(source).expect("typed entity source should admit")
1581 }
1582
1583 fn publish(
1584 session: &DbSession<TestCanister>,
1585 expected: AcceptedSchemaRevision,
1586 revision: AcceptedSchemaRevision,
1587 snapshots: BTreeMap<EntityTag, PersistedSchemaSnapshot>,
1588 fields: BTreeMap<(EntityTag, FieldSourceKey), FieldId>,
1589 ) {
1590 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
1591 STORE_PATH, revision, snapshots, fields,
1592 );
1593 let store = session
1594 .db
1595 .store_handle(STORE_PATH)
1596 .expect("typed adapter test store should resolve");
1597 crate::db::commit::publish_accepted_schema_candidate(
1598 STORE_PATH, store, expected, &candidate,
1599 )
1600 .expect("typed binding candidate should publish");
1601 }
1602
1603 fn request(source: &str) -> DynamicTypedFieldBindingRequest {
1604 DynamicTypedFieldBindingRequest::new(
1605 source.to_string(),
1606 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
1607 false,
1608 )
1609 }
1610
1611 fn assert_query_diagnostic(
1612 error: crate::db::QueryError,
1613 code: icydb_diagnostic_code::DiagnosticCode,
1614 origin: icydb_diagnostic_code::ErrorOrigin,
1615 detail: icydb_diagnostic_code::DiagnosticDetail,
1616 ) {
1617 let diagnostic = error.diagnostic();
1618 assert_eq!(diagnostic.code(), code);
1619 assert_eq!(diagnostic.origin(), origin);
1620 assert_eq!(diagnostic.detail(), Some(&detail));
1621 }
1622
1623 #[test]
1624 fn typed_adapter_kind_matching_is_exact_but_accepts_relation_key_wrappers() {
1625 let relation = AcceptedFieldKind::Relation {
1626 target_path: "test::Target".to_string(),
1627 target_entity_name: "Target".to_string(),
1628 target_entity_tag: EntityTag::new(7),
1629 target_store_path: "test::Store".to_string(),
1630 key_kind: Box::new(AcceptedFieldKind::Nat64),
1631 };
1632
1633 assert!(typed_adapter_field_kind_matches(
1634 &relation,
1635 &AcceptedFieldKind::Nat64,
1636 ));
1637 assert!(typed_adapter_field_kind_matches(
1638 &AcceptedFieldKind::List(Box::new(relation)),
1639 &AcceptedFieldKind::List(Box::new(AcceptedFieldKind::Nat64)),
1640 ));
1641 assert!(!typed_adapter_field_kind_matches(
1642 &AcceptedFieldKind::Nat64,
1643 &AcceptedFieldKind::Nat32,
1644 ));
1645 }
1646
1647 #[test]
1648 fn typed_adapter_field_contract_rejects_invalid_named_source_identity() {
1649 assert!(matches!(
1650 dynamic_typed_field_type(DynamicTypedFieldType::Named(String::new())),
1651 Err(DynamicTypedBindingError::FieldUnavailable),
1652 ));
1653 assert!(matches!(
1654 dynamic_typed_field_type(DynamicTypedFieldType::Scalar(ScalarType::Nat16)),
1655 Ok(icydb_schema::FieldType::Scalar(ScalarType::Nat16)),
1656 ));
1657 }
1658
1659 #[expect(clippy::too_many_lines)]
1662 #[test]
1663 fn typed_binding_uses_accepted_ids_and_slots_across_renames_and_name_reuse() {
1664 let entity_tag = EntityTag::new(91);
1665 let other_entity_tag = EntityTag::new(92);
1666 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
1667 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
1668 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
1669
1670 let session = DbSession::<TestCanister>::new(
1671 &STORE_REGISTRY,
1672 &crate::db::RequestExecutionRoot::__new_runtime_root(),
1673 );
1674 session
1675 .db
1676 .drive_startup_recovery_page()
1677 .expect("typed adapter test database should initialize");
1678 publish(
1679 &session,
1680 AcceptedSchemaRevision::NONE,
1681 AcceptedSchemaRevision::INITIAL,
1682 BTreeMap::from([(
1683 entity_tag,
1684 snapshot(
1685 ENTITY_SOURCE,
1686 "Entity",
1687 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1688 ),
1689 )]),
1690 BTreeMap::from([
1691 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1692 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1693 ]),
1694 );
1695
1696 let initial_catalog = session
1697 .find_accepted_schema_catalog_context_for_entity_source_key(ENTITY_SOURCE)
1698 .expect("initial source catalog lookup should inspect")
1699 .expect("initial source catalog should exist");
1700 assert_eq!(initial_catalog.identity().entity_tag(), entity_tag);
1701 let initial = session
1702 .issue_typed_entity_binding(
1703 entity_source(ENTITY_SOURCE).as_str(),
1704 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1705 )
1706 .expect("initial typed binding should issue");
1707 assert_eq!(initial.field_slot(ID_SOURCE), Some(0));
1708 assert_eq!(initial.field_slot(VALUE_SOURCE), Some(1));
1709 assert_eq!(initial.output_field_slot("value"), Some(1));
1710 let initial_patch = initial
1711 .bind_write_fields(vec![(
1712 VALUE_SOURCE.to_string(),
1713 DynamicWriteCell::Value(InputValue::Nat64(7)),
1714 )])
1715 .expect("source-bound patch should lower");
1716 assert_eq!(
1717 initial_patch.fields(),
1718 &[(2, 1, DynamicWriteCell::Value(InputValue::Nat64(7)))]
1719 );
1720
1721 publish(
1722 &session,
1723 AcceptedSchemaRevision::INITIAL,
1724 AcceptedSchemaRevision::new(2),
1725 BTreeMap::from([
1726 (
1727 entity_tag,
1728 snapshot(
1729 ENTITY_SOURCE,
1730 "RenamedEntity",
1731 vec![
1732 nat64_field(1, "id", 0),
1733 nat64_field(2, "renamed_value", 1),
1734 nat64_field(3, "value", 2),
1735 ],
1736 ),
1737 ),
1738 (
1739 other_entity_tag,
1740 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1741 ),
1742 ]),
1743 BTreeMap::from([
1744 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1745 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
1746 (
1747 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1748 FieldId::new(3),
1749 ),
1750 (
1751 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1752 FieldId::new(1),
1753 ),
1754 ]),
1755 );
1756
1757 let stale_authority = session
1758 .ensure_accepted_schema_authority_is_current_for_store_path(
1759 STORE_PATH,
1760 initial_catalog.value_catalog_handle().authority(),
1761 )
1762 .expect_err("the initial accepted authority must be stale after revision two");
1763 assert_eq!(
1764 stale_authority.diagnostic_facts(),
1765 vec![
1766 (
1767 icydb_diagnostic_code::DiagnosticFactTag::ExpectedRevision,
1768 AcceptedSchemaRevision::INITIAL.get(),
1769 ),
1770 (
1771 icydb_diagnostic_code::DiagnosticFactTag::CurrentRevision,
1772 AcceptedSchemaRevision::new(2).get(),
1773 ),
1774 ],
1775 );
1776
1777 assert!(
1778 !session
1779 .typed_entity_binding_is_current(&initial)
1780 .expect("renamed binding currentness should inspect")
1781 );
1782 let renamed = session
1783 .issue_typed_entity_binding(ENTITY_SOURCE, &[request(ID_SOURCE), request(VALUE_SOURCE)])
1784 .expect("renamed source-bound adapter should rebind");
1785 assert_eq!(renamed.entity(), "RenamedEntity");
1786 assert_eq!(renamed.field_slot(VALUE_SOURCE), Some(1));
1787 assert_eq!(renamed.output_field_slot("renamed_value"), Some(1));
1788 assert_eq!(renamed.output_field_slot("value"), None);
1789
1790 publish(
1791 &session,
1792 AcceptedSchemaRevision::new(2),
1793 AcceptedSchemaRevision::new(3),
1794 BTreeMap::from([
1795 (
1796 entity_tag,
1797 snapshot(
1798 ENTITY_SOURCE,
1799 "RenamedEntity",
1800 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
1801 ),
1802 ),
1803 (
1804 other_entity_tag,
1805 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
1806 ),
1807 ]),
1808 BTreeMap::from([
1809 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
1810 (
1811 (entity_tag, field_source(REPLACEMENT_SOURCE)),
1812 FieldId::new(2),
1813 ),
1814 (
1815 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
1816 FieldId::new(1),
1817 ),
1818 ]),
1819 );
1820
1821 assert!(matches!(
1822 session.issue_typed_entity_binding(
1823 ENTITY_SOURCE,
1824 &[request(ID_SOURCE), request(VALUE_SOURCE)],
1825 ),
1826 Err(DynamicTypedBindingError::FieldUnavailable),
1827 ));
1828 assert!(
1829 !session
1830 .typed_entity_binding_is_current(&renamed)
1831 .expect("removed source binding should become stale")
1832 );
1833
1834 let replacement = session
1835 .issue_typed_entity_binding(
1836 ENTITY_SOURCE,
1837 &[request(ID_SOURCE), request(REPLACEMENT_SOURCE)],
1838 )
1839 .expect("explicit replacement source should bind");
1840 assert!(
1841 session
1842 .execute_trusted_typed_mutation(
1843 &replacement,
1844 &DynamicTypedMutation::Insert {
1845 patch: initial_patch
1846 },
1847 )
1848 .expect("cross-binding patch should fail closed")
1849 .is_none()
1850 );
1851 let patch = replacement
1852 .bind_write_fields(vec![
1853 (
1854 ID_SOURCE.to_string(),
1855 DynamicWriteCell::Value(InputValue::Nat64(1)),
1856 ),
1857 (
1858 REPLACEMENT_SOURCE.to_string(),
1859 DynamicWriteCell::Value(InputValue::Nat64(9)),
1860 ),
1861 ])
1862 .expect("replacement source write should bind by accepted IDs and slots");
1863 let result = session
1864 .execute_trusted_typed_mutation(&replacement, &DynamicTypedMutation::Insert { patch })
1865 .expect("typed insert should use the accepted mutation pipeline")
1866 .expect("replacement binding should remain current");
1867 assert_eq!(result.entity, "RenamedEntity");
1868 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
1869 assert_eq!(
1870 result.rows,
1871 vec![vec![
1872 crate::value::OutputValue::Nat64(1),
1873 crate::value::OutputValue::Nat64(9)
1874 ]]
1875 );
1876 assert_eq!(result.affected_rows, 1);
1877
1878 let second_patch = replacement
1879 .bind_write_fields(vec![
1880 (
1881 ID_SOURCE.to_string(),
1882 DynamicWriteCell::Value(InputValue::Nat64(2)),
1883 ),
1884 (
1885 REPLACEMENT_SOURCE.to_string(),
1886 DynamicWriteCell::Value(InputValue::Nat64(10)),
1887 ),
1888 ])
1889 .expect("second source-bound patch should lower");
1890 session
1891 .execute_trusted_typed_mutation(
1892 &replacement,
1893 &DynamicTypedMutation::Insert {
1894 patch: second_patch,
1895 },
1896 )
1897 .expect("second typed insert should use the accepted mutation pipeline")
1898 .expect("replacement binding should remain current");
1899
1900 {
1901 let query = crate::db::DynamicQuery::new("RenamedEntity")
1902 .select(["id", "value"])
1903 .order_by(crate::db::asc("id"))
1904 .limit(1);
1905 let result = session
1906 .execute_trusted_live_page(&query, None)
1907 .expect("SQL-free dynamic execution should use accepted authority");
1908 assert_eq!(result.entity, "RenamedEntity");
1909 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
1910 assert_eq!(
1911 result.rows,
1912 vec![vec![
1913 crate::value::OutputValue::Nat64(1),
1914 crate::value::OutputValue::Nat64(9)
1915 ]]
1916 );
1917 assert_eq!(result.row_count, 1);
1918 assert_query_diagnostic(
1919 session
1920 .execute_trusted_live_page(&query.cursor("00"), None)
1921 .expect_err("scalar execution must reject grouped cursor state"),
1922 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1923 icydb_diagnostic_code::ErrorOrigin::Query,
1924 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1925 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1926 },
1927 );
1928 assert_query_diagnostic(
1929 session
1930 .execute_public_dynamic_grouped_query(
1931 &crate::db::DynamicQuery::new("RenamedEntity").grouped_limits(1, 1024),
1932 )
1933 .expect_err("grouped execution must reject scalar query state"),
1934 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
1935 icydb_diagnostic_code::ErrorOrigin::Query,
1936 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
1937 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
1938 },
1939 );
1940
1941 let grouped_query = crate::db::DynamicQuery::new("RenamedEntity")
1942 .filter(crate::db::FieldRef::new("id").eq(1_u64))
1943 .group_by("value")
1944 .aggregate(crate::db::count())
1945 .grouped_limits(1, 16 * 1024)
1946 .limit(1);
1947 let grouped = session
1948 .execute_public_dynamic_grouped_query(&grouped_query)
1949 .expect("SQL-free grouped execution should use accepted authority");
1950 let typed_grouped = session
1951 .execute_public_dynamic_grouped_query_for_typed_binding(
1952 &replacement,
1953 &grouped_query,
1954 )
1955 .expect("typed grouped execution should inspect accepted authority")
1956 .expect("replacement binding should remain current");
1957 assert_eq!(typed_grouped, grouped);
1958 assert!(
1959 session
1960 .execute_public_dynamic_grouped_query_for_typed_binding(
1961 &renamed,
1962 &grouped_query,
1963 )
1964 .expect("stale grouped binding should inspect accepted authority")
1965 .is_none(),
1966 "stale typed grouped bindings must fail closed before execution"
1967 );
1968 assert_eq!(grouped.entity, "RenamedEntity");
1969 assert_eq!(grouped.row_count, 1);
1970 assert_eq!(grouped.rows.len(), 1);
1971 assert_eq!(
1972 grouped.rows[0].group_key(),
1973 &[crate::value::OutputValue::Nat64(9)]
1974 );
1975 assert_eq!(
1976 grouped.rows[0].aggregate_values(),
1977 &[crate::value::OutputValue::Nat64(1)]
1978 );
1979 assert_eq!(grouped.next_cursor, None);
1980
1981 let grouped_state_error = session
1982 .execute_trusted_dynamic_grouped_query(&grouped_query.clone().grouped_limits(1, 1))
1983 .expect_err("grouped retained state must respect its explicit byte ceiling");
1984 assert!(matches!(
1985 grouped_state_error.diagnostic().detail(),
1986 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
1987 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
1988 })
1989 ));
1990 assert_eq!(
1991 grouped_state_error.diagnostic_facts()[0],
1992 (
1993 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
1994 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctStateBytes.raw(),
1995 ),
1996 );
1997
1998 assert_query_diagnostic(
1999 session
2000 .execute_public_dynamic_grouped_query(&grouped_query.clone().select(["value"]))
2001 .expect_err("grouped output must reject scalar selection"),
2002 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
2003 icydb_diagnostic_code::ErrorOrigin::Query,
2004 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2005 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
2006 },
2007 );
2008 assert_query_diagnostic(
2009 session
2010 .execute_public_dynamic_grouped_query(
2011 &crate::db::DynamicQuery::new("RenamedEntity")
2012 .group_by("value")
2013 .aggregate(crate::db::count()),
2014 )
2015 .expect_err("public grouped execution must require explicit limits"),
2016 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2017 icydb_diagnostic_code::ErrorOrigin::Query,
2018 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2019 reason:
2020 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
2021 },
2022 );
2023 assert_query_diagnostic(
2024 session
2025 .execute_trusted_dynamic_grouped_query(
2026 &crate::db::DynamicQuery::new("RenamedEntity")
2027 .group_by("value")
2028 .aggregate(crate::db::count())
2029 .grouped_limits(0, 1024),
2030 )
2031 .expect_err("trusted grouped execution must reject zero limits"),
2032 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2033 icydb_diagnostic_code::ErrorOrigin::Query,
2034 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2035 reason:
2036 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
2037 },
2038 );
2039 assert_query_diagnostic(
2040 session
2041 .execute_public_dynamic_grouped_query(&grouped_query.grouped_limits(101, 1024))
2042 .expect_err("public grouped execution must enforce its group budget"),
2043 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2044 icydb_diagnostic_code::ErrorOrigin::Query,
2045 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2046 reason:
2047 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryExceedsBudget,
2048 },
2049 );
2050
2051 let paged_query = crate::db::DynamicQuery::new("RenamedEntity")
2052 .group_by("value")
2053 .aggregate(crate::db::count())
2054 .grouped_limits(2, 16 * 1024)
2055 .limit(1);
2056 assert_query_diagnostic(
2057 session
2058 .execute_public_dynamic_grouped_query(&paged_query)
2059 .expect_err("public grouped execution must reject an unbounded full scan"),
2060 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
2061 icydb_diagnostic_code::ErrorOrigin::Query,
2062 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
2063 reason:
2064 icydb_diagnostic_code::QueryReadAdmissionCode::UnboundedFullScanRejected,
2065 },
2066 );
2067 let first_page = session
2068 .execute_trusted_dynamic_grouped_query(&paged_query)
2069 .expect("SQL-free grouped first page should execute");
2070 assert_eq!(first_page.row_count, 1);
2071 assert_eq!(
2072 first_page.rows[0].group_key(),
2073 &[crate::value::OutputValue::Nat64(9)]
2074 );
2075 let cursor = first_page
2076 .next_cursor
2077 .expect("first grouped page should return a continuation cursor");
2078 assert_query_diagnostic(
2079 session
2080 .execute_trusted_dynamic_grouped_query(
2081 &paged_query.clone().cursor(format!("{cursor}0")),
2082 )
2083 .expect_err("tampered grouped cursor must fail closed"),
2084 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2085 icydb_diagnostic_code::ErrorOrigin::Cursor,
2086 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2087 kind: icydb_diagnostic_code::QueryErrorKind::InvalidContinuationCursor,
2088 },
2089 );
2090 let second_page = session
2091 .execute_trusted_dynamic_grouped_query(&paged_query.cursor(cursor))
2092 .expect("SQL-free grouped continuation should execute");
2093 assert_eq!(second_page.row_count, 1);
2094 assert_eq!(
2095 second_page.rows[0].group_key(),
2096 &[crate::value::OutputValue::Nat64(10)]
2097 );
2098 assert_eq!(second_page.next_cursor, None);
2099 }
2100 }
2101}
2102
2103#[cfg(test)]
2104mod mixed_relation_batch_tests {
2105 use super::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell};
2106 use crate::{
2107 db::{
2108 DynamicQuery, asc,
2109 data::DataStore,
2110 desc,
2111 index::IndexStore,
2112 query::expr::FilterExpr,
2113 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
2114 schema::{
2115 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
2116 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
2117 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
2118 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
2119 SchemaFieldSlot, SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore,
2120 SchemaVersion, accepted_schema_candidate_with_field_bindings_for_tests,
2121 },
2122 },
2123 error::ErrorClass,
2124 traits::{CanisterKind, Path},
2125 types::EntityTag,
2126 value::{InputValue, OutputValue},
2127 };
2128 use icydb_schema::FieldSourceKey;
2129 use std::{cell::RefCell, collections::BTreeMap};
2130
2131 const STORE_PATH: &str = "session::write::mixed_relation_batch_tests::Store";
2132 const ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node";
2133 const ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::id";
2134 const PARENT_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::parent_id";
2135 const CODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::code";
2136 const ENTITY_NAME: &str = "MixedRelationNode";
2137 const ENTITY_TAG: EntityTag = EntityTag::new(94);
2138 const OTHER_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other";
2139 const OTHER_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::id";
2140 const OTHER_VALUE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::value";
2141 const OTHER_ENTITY_NAME: &str = "MixedRelationOther";
2142 const OTHER_ENTITY_TAG: EntityTag = EntityTag::new(95);
2143
2144 struct TestCanister;
2145
2146 impl Path for TestCanister {
2147 const PATH: &'static str = "session::write::mixed_relation_batch_tests::Canister";
2148 }
2149
2150 impl CanisterKind for TestCanister {
2151 const COMMIT_MEMORY_ID: u8 = 47;
2152 const COMMIT_STABLE_KEY: &'static str = "icydb.mixed_relation_batch_tests.commit.v1";
2153 const STARTUP_MEMORY_ID: u8 = 50;
2154 const STARTUP_STABLE_KEY: &'static str =
2155 "icydb.mixed_relation_batch_tests.startup.control.v1";
2156 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 48;
2157 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2158 "icydb.mixed_relation_batch_tests.integrity.progress.v1";
2159 }
2160
2161 thread_local! {
2162 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2163 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2164 static SCHEMA_STORE: RefCell<SchemaStore> =
2165 const { RefCell::new(SchemaStore::init_heap()) };
2166 static STORE_REGISTRY: StoreRegistry = {
2167 let mut registry = StoreRegistry::new();
2168 registry.register_store(
2169 STORE_PATH,
2170 &DATA_STORE,
2171 &INDEX_STORE,
2172 &SCHEMA_STORE,
2173 StoreAllocationIdentities::absent(),
2174 StoreRuntimeStorageCapabilities::heap(),
2175 ).expect("mixed relation test store should register");
2176 registry
2177 };
2178 }
2179
2180 fn source_key(source: &str) -> FieldSourceKey {
2181 FieldSourceKey::try_new(source).expect("mixed relation field source should admit")
2182 }
2183
2184 fn relation_snapshot() -> PersistedSchemaSnapshot {
2185 let fields = vec![
2186 PersistedFieldSnapshot::new_initial(
2187 FieldId::new(1),
2188 "id".to_string(),
2189 SchemaFieldSlot::new(0),
2190 AcceptedFieldKind::Nat64,
2191 Vec::new(),
2192 false,
2193 SchemaInsertDefault::None,
2194 FieldStorageDecode::ByKind,
2195 LeafCodec::Scalar(ScalarCodec::Nat64),
2196 ),
2197 PersistedFieldSnapshot::new_initial(
2198 FieldId::new(2),
2199 "parent_id".to_string(),
2200 SchemaFieldSlot::new(1),
2201 AcceptedFieldKind::Nat64,
2202 Vec::new(),
2203 true,
2204 SchemaInsertDefault::None,
2205 FieldStorageDecode::ByKind,
2206 LeafCodec::Scalar(ScalarCodec::Nat64),
2207 ),
2208 PersistedFieldSnapshot::new_initial(
2209 FieldId::new(3),
2210 "code".to_string(),
2211 SchemaFieldSlot::new(2),
2212 AcceptedFieldKind::Nat64,
2213 Vec::new(),
2214 false,
2215 SchemaInsertDefault::None,
2216 FieldStorageDecode::ByKind,
2217 LeafCodec::Scalar(ScalarCodec::Nat64),
2218 ),
2219 ];
2220 let relation = PersistedRelationEdgeSnapshot::new(
2221 RelationId::new(1).expect("mixed relation identity should be non-zero"),
2222 "parent".to_string(),
2223 ENTITY_SOURCE.to_string(),
2224 vec![FieldId::new(2)],
2225 );
2226 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
2227 SchemaVersion::initial(),
2228 ENTITY_SOURCE.to_string(),
2229 ENTITY_NAME.to_string(),
2230 FieldId::new(1),
2231 SchemaRowLayout::initial(
2232 fields
2233 .iter()
2234 .map(|field| (field.id(), field.slot()))
2235 .collect(),
2236 ),
2237 fields,
2238 vec![PersistedIndexSnapshot::new(
2239 SchemaIndexId::new(1).expect("mixed unique index identity should be non-zero"),
2240 1,
2241 "by_code".to_string(),
2242 STORE_PATH.to_string(),
2243 true,
2244 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
2245 FieldId::new(3),
2246 SchemaFieldSlot::new(2),
2247 vec!["code".to_string()],
2248 AcceptedFieldKind::Nat64,
2249 false,
2250 )]),
2251 None,
2252 )],
2253 )
2254 .with_relations(vec![relation]);
2255 let constraints = AcceptedConstraintCatalog::initial(
2256 snapshot.fields(),
2257 snapshot.indexes(),
2258 snapshot.relations(),
2259 )
2260 .expect("mixed relation constraints should close");
2261 snapshot.with_constraint_catalog(constraints)
2262 }
2263
2264 fn other_snapshot() -> PersistedSchemaSnapshot {
2265 let fields = vec![
2266 PersistedFieldSnapshot::new_initial(
2267 FieldId::new(1),
2268 "id".to_string(),
2269 SchemaFieldSlot::new(0),
2270 AcceptedFieldKind::Nat64,
2271 Vec::new(),
2272 false,
2273 SchemaInsertDefault::None,
2274 FieldStorageDecode::ByKind,
2275 LeafCodec::Scalar(ScalarCodec::Nat64),
2276 ),
2277 PersistedFieldSnapshot::new_initial(
2278 FieldId::new(2),
2279 "value".to_string(),
2280 SchemaFieldSlot::new(1),
2281 AcceptedFieldKind::Nat64,
2282 Vec::new(),
2283 false,
2284 SchemaInsertDefault::None,
2285 FieldStorageDecode::ByKind,
2286 LeafCodec::Scalar(ScalarCodec::Nat64),
2287 ),
2288 ];
2289 PersistedSchemaSnapshot::new(
2290 SchemaVersion::initial(),
2291 OTHER_ENTITY_SOURCE.to_string(),
2292 OTHER_ENTITY_NAME.to_string(),
2293 FieldId::new(1),
2294 SchemaRowLayout::initial(
2295 fields
2296 .iter()
2297 .map(|field| (field.id(), field.slot()))
2298 .collect(),
2299 ),
2300 fields,
2301 )
2302 }
2303
2304 fn initialize() -> DbSession<TestCanister> {
2305 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2306 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2307 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2308 let session = DbSession::<TestCanister>::new(
2309 &STORE_REGISTRY,
2310 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2311 );
2312 session
2313 .db
2314 .drive_startup_recovery_page()
2315 .expect("mixed relation database should initialize");
2316 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2317 STORE_PATH,
2318 AcceptedSchemaRevision::INITIAL,
2319 BTreeMap::from([
2320 (ENTITY_TAG, relation_snapshot()),
2321 (OTHER_ENTITY_TAG, other_snapshot()),
2322 ]),
2323 BTreeMap::from([
2324 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
2325 ((ENTITY_TAG, source_key(PARENT_SOURCE)), FieldId::new(2)),
2326 ((ENTITY_TAG, source_key(CODE_SOURCE)), FieldId::new(3)),
2327 (
2328 (OTHER_ENTITY_TAG, source_key(OTHER_ID_SOURCE)),
2329 FieldId::new(1),
2330 ),
2331 (
2332 (OTHER_ENTITY_TAG, source_key(OTHER_VALUE_SOURCE)),
2333 FieldId::new(2),
2334 ),
2335 ]),
2336 );
2337 let store = session
2338 .db
2339 .store_handle(STORE_PATH)
2340 .expect("mixed relation store should resolve");
2341 crate::db::commit::publish_accepted_schema_candidate(
2342 STORE_PATH,
2343 store,
2344 AcceptedSchemaRevision::NONE,
2345 &candidate,
2346 )
2347 .expect("mixed relation candidate should publish");
2348 session
2349 }
2350
2351 fn patch(id: Option<u64>, parent: Option<u64>, code: Option<u64>) -> DynamicStructuralPatch {
2352 let mut fields = Vec::new();
2353 if let Some(id) = id {
2354 fields.push((
2355 "id".to_string(),
2356 DynamicWriteCell::Value(InputValue::Nat64(id)),
2357 ));
2358 }
2359 fields.push((
2360 "parent_id".to_string(),
2361 parent.map_or(DynamicWriteCell::Null, |parent| {
2362 DynamicWriteCell::Value(InputValue::Nat64(parent))
2363 }),
2364 ));
2365 if let Some(code) = code {
2366 fields.push((
2367 "code".to_string(),
2368 DynamicWriteCell::Value(InputValue::Nat64(code)),
2369 ));
2370 }
2371 DynamicStructuralPatch::new(fields)
2372 }
2373
2374 fn insert(id: u64, parent: Option<u64>) -> DynamicMutation {
2375 insert_with_code(id, parent, id)
2376 }
2377
2378 fn insert_with_code(id: u64, parent: Option<u64>, code: u64) -> DynamicMutation {
2379 DynamicMutation::Insert {
2380 entity: ENTITY_NAME.to_string(),
2381 patch: patch(Some(id), parent, Some(code)),
2382 }
2383 }
2384
2385 fn update_parent(id: u64, parent: Option<u64>) -> DynamicMutation {
2386 DynamicMutation::Update {
2387 entity: ENTITY_NAME.to_string(),
2388 key: InputValue::Nat64(id),
2389 patch: patch(None, parent, None),
2390 }
2391 }
2392
2393 fn update_code(id: u64, code: u64) -> DynamicMutation {
2394 DynamicMutation::Update {
2395 entity: ENTITY_NAME.to_string(),
2396 key: InputValue::Nat64(id),
2397 patch: DynamicStructuralPatch::new(vec![(
2398 "code".to_string(),
2399 DynamicWriteCell::Value(InputValue::Nat64(code)),
2400 )]),
2401 }
2402 }
2403
2404 fn delete(id: u64) -> DynamicMutation {
2405 DynamicMutation::Delete {
2406 entity: ENTITY_NAME.to_string(),
2407 key: InputValue::Nat64(id),
2408 }
2409 }
2410
2411 fn expected_row(id: u64, parent: Option<u64>) -> Vec<OutputValue> {
2412 expected_row_with_code(id, parent, id)
2413 }
2414
2415 fn expected_row_with_code(id: u64, parent: Option<u64>, code: u64) -> Vec<OutputValue> {
2416 vec![
2417 OutputValue::Nat64(id),
2418 parent.map_or(OutputValue::Null, OutputValue::Nat64),
2419 OutputValue::Nat64(code),
2420 ]
2421 }
2422
2423 fn other_patch(id: Option<u64>, value: u64) -> DynamicStructuralPatch {
2424 let mut fields = Vec::new();
2425 if let Some(id) = id {
2426 fields.push((
2427 "id".to_string(),
2428 DynamicWriteCell::Value(InputValue::Nat64(id)),
2429 ));
2430 }
2431 fields.push((
2432 "value".to_string(),
2433 DynamicWriteCell::Value(InputValue::Nat64(value)),
2434 ));
2435 DynamicStructuralPatch::new(fields)
2436 }
2437
2438 fn assert_relation_violation(error: &crate::error::InternalError) {
2439 assert!(error.diagnostic_facts().contains(&(
2440 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
2441 icydb_diagnostic_code::DiagnosticConstraintKind::Relation.raw(),
2442 )));
2443 }
2444
2445 #[test]
2446 fn live_pages_resume_mixed_projection_from_authenticated_hidden_order_values() {
2447 let session = initialize();
2448 session
2449 .execute_trusted_dynamic_mutation_batch(vec![
2450 insert_with_code(1, None, 10),
2451 insert_with_code(2, Some(1), 20),
2452 insert_with_code(3, None, 30),
2453 ])
2454 .expect("live-page rows should insert");
2455 let query = DynamicQuery::new(ENTITY_NAME)
2456 .select(["id"])
2457 .order_by(desc("code"));
2458
2459 let first = session
2460 .execute_public_live_page(&query, None)
2461 .expect("initial live page should execute");
2462 assert_eq!(
2463 first.rows,
2464 vec![vec![OutputValue::Nat64(3)], vec![OutputValue::Nat64(2)]]
2465 );
2466 let cursor = first
2467 .continuation
2468 .as_deref()
2469 .expect("unreturned matching row should produce continuation");
2470 let second = session
2471 .execute_public_live_page(&query, Some(cursor))
2472 .expect("authenticated live continuation should resume");
2473 assert_eq!(second.rows, vec![vec![OutputValue::Nat64(1)]]);
2474 assert_eq!(second.continuation, None);
2475
2476 let total_limit = session
2477 .execute_public_live_page(&query.clone().limit(2), None)
2478 .expect("total live-page limit should execute");
2479 assert_eq!(
2480 total_limit.rows,
2481 vec![vec![OutputValue::Nat64(3)], vec![OutputValue::Nat64(2)]],
2482 );
2483 assert_eq!(
2484 total_limit.continuation, None,
2485 "query LIMIT is a total traversal window rather than a page size",
2486 );
2487
2488 let three_row_window = query.clone().limit(3);
2489 let limited_first = session
2490 .execute_public_live_page(&three_row_window, None)
2491 .expect("first total-window page should execute");
2492 let limited_cursor = limited_first
2493 .continuation
2494 .as_deref()
2495 .expect("a partially consumed total window should continue");
2496 let limited_second = session
2497 .execute_public_live_page(&three_row_window, Some(limited_cursor))
2498 .expect("remaining total window should preserve the plan signature");
2499 assert_eq!(limited_second.rows, vec![vec![OutputValue::Nat64(1)]]);
2500 assert_eq!(limited_second.continuation, None);
2501
2502 let mixed_order = DynamicQuery::new(ENTITY_NAME)
2503 .select(["id"])
2504 .order_by(desc("parent_id"))
2505 .order_by(asc("id"));
2506 let mixed_first = session
2507 .execute_trusted_live_page(&mixed_order, None)
2508 .expect("mixed-direction nullable order should execute");
2509 assert_eq!(
2510 mixed_first.rows,
2511 vec![vec![OutputValue::Nat64(2)], vec![OutputValue::Nat64(1)]],
2512 );
2513 let mixed_cursor = mixed_first
2514 .continuation
2515 .as_deref()
2516 .expect("duplicate null order values should retain continuation");
2517 let mixed_second = session
2518 .execute_trusted_live_page(&mixed_order, Some(mixed_cursor))
2519 .expect("mixed-direction nullable order should resume");
2520 assert_eq!(mixed_second.rows, vec![vec![OutputValue::Nat64(3)]]);
2521 assert_eq!(mixed_second.continuation, None);
2522
2523 let mismatched_window = session
2524 .execute_public_live_page(&query.clone().limit(3), Some(cursor))
2525 .expect_err("a changed total limit must invalidate the continuation");
2526 assert_eq!(
2527 mismatched_window.diagnostic_code(),
2528 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2529 );
2530
2531 let mut tampered = cursor.as_bytes().to_vec();
2532 let last = tampered.len().saturating_sub(1);
2533 tampered[last] = if tampered[last] == b'0' { b'1' } else { b'0' };
2534 let tampered = String::from_utf8(tampered).expect("hex cursor should remain UTF-8");
2535 let error = session
2536 .execute_public_live_page(&query, Some(tampered.as_str()))
2537 .expect_err("tampered cursor must fail closed");
2538 assert_eq!(
2539 error.diagnostic_code(),
2540 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
2541 );
2542 }
2543
2544 #[test]
2545 fn live_pages_resume_across_changed_output_work_envelopes() {
2546 let session = initialize();
2547 session
2548 .execute_trusted_dynamic_mutation_batch(vec![
2549 insert(1, None),
2550 insert(2, None),
2551 insert(3, None),
2552 ])
2553 .expect("output-envelope rows should insert");
2554 let query = DynamicQuery::new(ENTITY_NAME)
2555 .select(["id"])
2556 .order_by(desc("code"));
2557 let first = session
2558 .execute_trusted_live_page_with_result_bytes_limit_for_tests(&query, None, 32)
2559 .expect("small output envelope should publish the first bounded page");
2560 assert_eq!(first.rows, vec![vec![OutputValue::Nat64(3)]]);
2561 let continuation = first
2562 .continuation
2563 .expect("small output envelope should leave authenticated progress");
2564
2565 let second = session
2566 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
2567 &query,
2568 Some(continuation.as_str()),
2569 64,
2570 )
2571 .unwrap_or_else(|error| {
2572 panic!(
2573 "larger output envelope should resume the same query: {error:?}, facts={:?}",
2574 error.diagnostic_facts(),
2575 )
2576 });
2577 assert_eq!(
2578 second.rows,
2579 vec![vec![OutputValue::Nat64(2)], vec![OutputValue::Nat64(1)]]
2580 );
2581 let second_continuation = second
2582 .continuation
2583 .as_deref()
2584 .expect("an exact-full page still needs to prove physical exhaustion");
2585 assert_ne!(first.work.envelope_identity, second.work.envelope_identity);
2586
2587 let terminal = session
2588 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
2589 &query,
2590 Some(second_continuation),
2591 48,
2592 )
2593 .expect("a third finite envelope should prove exhaustion without replaying rows");
2594 assert!(terminal.rows.is_empty());
2595 assert_eq!(terminal.continuation, None);
2596 assert_ne!(
2597 second.work.envelope_identity,
2598 terminal.work.envelope_identity
2599 );
2600
2601 assert_eq!(
2602 [first.rows, second.rows, terminal.rows].concat(),
2603 vec![
2604 vec![OutputValue::Nat64(3)],
2605 vec![OutputValue::Nat64(2)],
2606 vec![OutputValue::Nat64(1)],
2607 ]
2608 );
2609 }
2610
2611 #[test]
2612 fn distinct_live_pages_resume_adjacent_groups_and_global_replay_end_to_end() {
2613 let session = initialize();
2614 session
2615 .execute_trusted_dynamic_mutation_batch(vec![
2616 insert(1, None),
2617 insert(2, None),
2618 insert(3, Some(1)),
2619 insert(4, Some(2)),
2620 insert(5, Some(1)),
2621 insert(6, Some(3)),
2622 insert(7, Some(2)),
2623 ])
2624 .expect("DISTINCT continuation rows should insert atomically");
2625
2626 let adjacent = DynamicQuery::new(ENTITY_NAME)
2627 .select(["parent_id"])
2628 .order_by(asc("parent_id"))
2629 .order_by(asc("id"))
2630 .distinct_for_internal_execution();
2631 let global = DynamicQuery::new(ENTITY_NAME)
2632 .select(["parent_id"])
2633 .order_by(asc("id"))
2634 .distinct_for_internal_execution();
2635
2636 let traverse = |query: &DynamicQuery, strategy: &str| {
2637 let mut continuation = None;
2638 let mut rows = Vec::new();
2639 let mut cursors = std::collections::BTreeSet::new();
2640 let mut pages = 0_u32;
2641 let mut entries_visited = 0_u64;
2642 loop {
2643 let page = session
2644 .execute_trusted_live_page(query, continuation.as_deref())
2645 .unwrap_or_else(|error| {
2646 panic!("{strategy} DISTINCT page should execute: {error:?}")
2647 });
2648 pages = pages.saturating_add(1);
2649 entries_visited = entries_visited.saturating_add(page.work.entries_visited);
2650 assert_eq!(page.row_count as usize, page.rows.len());
2651 assert_eq!(page.work.result_rows, page.row_count);
2652 rows.extend(page.rows);
2653 let Some(cursor) = page.continuation else {
2654 break;
2655 };
2656 assert!(
2657 cursors.insert(cursor.clone()),
2658 "{strategy} DISTINCT continuation must advance monotonically",
2659 );
2660 continuation = Some(cursor);
2661 assert!(pages < 8, "{strategy} DISTINCT traversal must terminate");
2662 }
2663
2664 (rows, pages, entries_visited)
2665 };
2666
2667 let expected = vec![
2668 vec![OutputValue::Null],
2669 vec![OutputValue::Nat64(1)],
2670 vec![OutputValue::Nat64(2)],
2671 vec![OutputValue::Nat64(3)],
2672 ];
2673 let (adjacent_rows, adjacent_pages, adjacent_entries) = traverse(&adjacent, "adjacent");
2674 let (global_rows, global_pages, global_entries) = traverse(&global, "global");
2675
2676 assert_eq!(adjacent_rows, expected);
2677 assert_eq!(global_rows, expected);
2678 assert_eq!(adjacent_pages, 2);
2679 assert_eq!(global_pages, 2);
2680 assert!(adjacent_entries > 0);
2681 assert!(global_entries > 0);
2682 }
2683
2684 #[test]
2685 fn selective_live_pages_publish_monotonic_empty_physical_progress() {
2686 let session = initialize();
2687 session
2688 .execute_trusted_dynamic_mutation_batch(
2689 (1..=9)
2690 .map(|id| {
2691 let parent = match id {
2692 1 => Some(2),
2693 9 => Some(1),
2694 _ => None,
2695 };
2696 insert(id, parent)
2697 })
2698 .collect(),
2699 )
2700 .expect("selective live-page rows should insert");
2701 let query = DynamicQuery::new(ENTITY_NAME)
2702 .select(["id"])
2703 .filter(FilterExpr::eq("parent_id", 1_u64))
2704 .order_by(asc("id"))
2705 .limit(1);
2706
2707 let first = session
2708 .execute_trusted_live_page(&query, None)
2709 .expect("first selective page should stop with physical progress");
2710 assert!(first.rows.is_empty());
2711 assert_eq!(first.work.entries_visited, 4);
2712 let first_cursor = first
2713 .continuation
2714 .expect("filtered physical progress must return a continuation");
2715
2716 let second = session
2717 .execute_trusted_live_page(&query, Some(first_cursor.as_str()))
2718 .expect("second selective page should resume after the first physical frontier");
2719 assert!(second.rows.is_empty());
2720 assert_eq!(second.work.entries_visited, 4);
2721 let second_cursor = second
2722 .continuation
2723 .expect("second filtered frontier must remain resumable");
2724 assert_ne!(second_cursor, first_cursor);
2725
2726 let third = session
2727 .execute_trusted_live_page(&query, Some(second_cursor.as_str()))
2728 .expect("final selective page should return the late match");
2729 assert_eq!(third.rows, vec![vec![OutputValue::Nat64(9)]]);
2730 assert_eq!(third.work.entries_visited, 1);
2731 assert_eq!(third.continuation, None);
2732
2733 let descending = DynamicQuery::new(ENTITY_NAME)
2734 .select(["id"])
2735 .filter(FilterExpr::eq("parent_id", 2_u64))
2736 .order_by(desc("id"))
2737 .limit(1);
2738 let descending_first = session
2739 .execute_trusted_live_page(&descending, None)
2740 .expect("descending selective page should stop with physical progress");
2741 assert!(descending_first.rows.is_empty());
2742 let descending_first_cursor = descending_first
2743 .continuation
2744 .expect("descending filtered progress must return a continuation");
2745 let descending_second = session
2746 .execute_trusted_live_page(&descending, Some(descending_first_cursor.as_str()))
2747 .expect("descending progress should resume after its physical frontier");
2748 assert!(descending_second.rows.is_empty());
2749 let descending_second_cursor = descending_second
2750 .continuation
2751 .expect("descending second frontier must remain resumable");
2752 assert_ne!(descending_second_cursor, descending_first_cursor);
2753 let descending_third = session
2754 .execute_trusted_live_page(&descending, Some(descending_second_cursor.as_str()))
2755 .expect("descending final page should return the late match");
2756 assert_eq!(descending_third.rows, vec![vec![OutputValue::Nat64(1)]]);
2757 assert_eq!(descending_third.continuation, None);
2758 }
2759
2760 #[test]
2761 fn accepted_relation_edges_drive_catalog_and_describe_introspection() {
2762 let session = initialize();
2763 let entities = session
2764 .show_entities()
2765 .expect("accepted entity catalog should resolve");
2766 let source = entities
2767 .iter()
2768 .find(|entity| entity.entity_name() == ENTITY_NAME)
2769 .expect("relation source should be listed");
2770 assert_eq!(source.relations(), 1);
2771
2772 let description = session
2773 .try_describe_entity_by_name(ENTITY_NAME)
2774 .expect("accepted relation source should describe");
2775 let [relation] = description.relations() else {
2776 panic!("accepted relation edge should produce one relation row");
2777 };
2778 assert_eq!(relation.field(), "parent_id");
2779 assert_eq!(relation.target_path(), ENTITY_SOURCE);
2780 assert_eq!(relation.target_entity_name(), ENTITY_NAME);
2781 assert_eq!(relation.target_store_path(), STORE_PATH);
2782 assert_eq!(
2783 relation.cardinality(),
2784 crate::db::EntityRelationCardinality::Single,
2785 );
2786 }
2787
2788 #[test]
2789 fn mixed_relation_validation_uses_the_complete_final_row_overlay() {
2790 let session = initialize();
2791 session
2792 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, Some(1))])
2793 .expect("the initial relation should commit");
2794
2795 let blocked = session
2796 .execute_trusted_dynamic_mutation(&delete(1))
2797 .expect_err("an unaffected committed source must block target deletion");
2798 assert_relation_violation(&blocked);
2799
2800 let deleted = session
2801 .execute_trusted_dynamic_mutation_batch(vec![delete(2), delete(1)])
2802 .expect("a source and its target should delete atomically");
2803 assert_eq!(
2804 deleted.rows,
2805 vec![expected_row(2, Some(1)), expected_row(1, None)],
2806 );
2807
2808 session
2809 .execute_trusted_dynamic_mutation_batch(vec![insert(3, None), insert(4, Some(3))])
2810 .expect("the update-away fixture should commit");
2811 let updated_away = session
2812 .execute_trusted_dynamic_mutation_batch(vec![update_parent(4, None), delete(3)])
2813 .expect("an updated final source may release a deleted target");
2814 assert_eq!(
2815 updated_away.rows,
2816 vec![expected_row(4, None), expected_row(3, None)],
2817 );
2818
2819 session
2820 .execute_trusted_dynamic_mutation_batch(vec![insert(5, None), insert(6, Some(5))])
2821 .expect("the retained-reference fixture should commit");
2822 let retained = session
2823 .execute_trusted_dynamic_mutation_batch(vec![update_parent(6, Some(5)), delete(5)])
2824 .expect_err("a final updated source must still block target deletion");
2825 assert_relation_violation(&retained);
2826
2827 session
2828 .execute_trusted_dynamic_mutation(&insert(7, None))
2829 .expect("the inserted-reference fixture target should commit");
2830 let inserted_reference = session
2831 .execute_trusted_dynamic_mutation_batch(vec![insert(8, Some(7)), delete(7)])
2832 .expect_err("a final inserted source must not reference a deleted target");
2833 assert_relation_violation(&inserted_reference);
2834
2835 let inserted_target = session
2836 .execute_trusted_dynamic_mutation_batch(vec![insert(10, Some(9)), insert(9, None)])
2837 .expect("an inserted relation should see its batch-final target");
2838 assert_eq!(
2839 inserted_target.rows,
2840 vec![expected_row(10, Some(9)), expected_row(9, None)],
2841 );
2842
2843 session
2844 .execute_trusted_dynamic_mutation(&insert(11, None))
2845 .expect("the updated-reference fixture source should commit");
2846 let updated_target = session
2847 .execute_trusted_dynamic_mutation_batch(vec![
2848 update_parent(11, Some(12)),
2849 insert(12, None),
2850 ])
2851 .expect("an updated relation should see its batch-final target");
2852 assert_eq!(
2853 updated_target.rows,
2854 vec![expected_row(11, Some(12)), expected_row(12, None)],
2855 );
2856 }
2857
2858 #[test]
2859 fn mixed_batch_rejects_cross_entity_missing_and_collision_then_honors_replace() {
2860 let session = initialize();
2861 session
2862 .execute_trusted_dynamic_mutation(&insert(1, None))
2863 .expect("the primary mixed fixture row should commit");
2864 session
2865 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
2866 entity: OTHER_ENTITY_NAME.to_string(),
2867 patch: other_patch(Some(1), 10),
2868 })
2869 .expect("the secondary mixed fixture row should commit");
2870
2871 let mixed_entity = session
2872 .execute_trusted_dynamic_mutation_batch(vec![
2873 update_code(1, 11),
2874 DynamicMutation::Update {
2875 entity: OTHER_ENTITY_NAME.to_string(),
2876 key: InputValue::Nat64(1),
2877 patch: other_patch(None, 11),
2878 },
2879 ])
2880 .expect_err("one atomic batch must not cross accepted entities");
2881 assert_eq!(mixed_entity.class(), ErrorClass::Conflict);
2882 assert_eq!(
2883 mixed_entity.diagnostic_facts(),
2884 vec![
2885 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
2886 (
2887 icydb_diagnostic_code::DiagnosticFactTag::ExpectedEntityTag,
2888 ENTITY_TAG.value(),
2889 ),
2890 (
2891 icydb_diagnostic_code::DiagnosticFactTag::ActualEntityTag,
2892 OTHER_ENTITY_TAG.value(),
2893 ),
2894 ],
2895 );
2896
2897 let missing = session
2898 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 12), delete(99)])
2899 .expect_err("a late missing delete must reject the earlier staged update");
2900 assert_eq!(missing.class(), ErrorClass::NotFound);
2901
2902 session
2903 .execute_trusted_dynamic_mutation(&insert(2, None))
2904 .expect("the collision fixture should commit");
2905 let collision = session
2906 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 13), insert(2, None)])
2907 .expect_err("an insert collision must reject the earlier staged update");
2908 assert_eq!(collision.class(), ErrorClass::Conflict);
2909 let failures_unchanged = session
2910 .execute_trusted_dynamic_mutation(&update_code(1, 1))
2911 .expect("failed batches must preserve the original unique value");
2912 assert_eq!(failures_unchanged.affected_rows, 0);
2913
2914 let replaced = session
2915 .execute_trusted_dynamic_mutation_batch(vec![
2916 update_code(1, 14),
2917 DynamicMutation::Replace {
2918 entity: ENTITY_NAME.to_string(),
2919 key: InputValue::Nat64(99),
2920 patch: patch(None, None, Some(99)),
2921 },
2922 ])
2923 .expect("ordinary caller-key replace should insert its absent final row");
2924 assert_eq!(
2925 replaced.rows,
2926 vec![
2927 expected_row_with_code(1, None, 14),
2928 expected_row_with_code(99, None, 99),
2929 ],
2930 );
2931
2932 let unchanged = session
2933 .execute_trusted_dynamic_mutation(&update_code(1, 14))
2934 .expect("the successful mixed replace must publish its preceding update");
2935 assert_eq!(unchanged.affected_rows, 0);
2936 let other_unchanged = session
2937 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
2938 entity: OTHER_ENTITY_NAME.to_string(),
2939 key: InputValue::Nat64(1),
2940 patch: other_patch(None, 10),
2941 })
2942 .expect("cross-entity rejection must preserve the secondary row");
2943 assert_eq!(other_unchanged.affected_rows, 0);
2944 }
2945
2946 #[test]
2947 fn mixed_batch_unique_swap_and_delete_release_use_the_final_overlay() {
2948 let session = initialize();
2949 session
2950 .execute_trusted_dynamic_mutation_batch(vec![
2951 insert_with_code(1, None, 10),
2952 insert_with_code(2, None, 20),
2953 ])
2954 .expect("the unique-overlay fixture should commit");
2955
2956 let swapped = session
2957 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 20), update_code(2, 10)])
2958 .expect("two final rows should atomically swap unique memberships");
2959 assert_eq!(
2960 swapped.rows,
2961 vec![
2962 expected_row_with_code(1, None, 20),
2963 expected_row_with_code(2, None, 10),
2964 ],
2965 );
2966
2967 let released = session
2968 .execute_trusted_dynamic_mutation_batch(vec![delete(1), insert_with_code(3, None, 20)])
2969 .expect("a delete should release unique membership to a final inserted row");
2970 assert_eq!(
2971 released.rows,
2972 vec![
2973 expected_row_with_code(1, None, 20),
2974 expected_row_with_code(3, None, 20),
2975 ],
2976 );
2977 }
2978}
2979
2980#[cfg(test)]
2981mod identity_pre_key_tests {
2982 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2983 use super::DynamicTypedEntityBinding;
2984 use super::{
2985 AcceptedMutationIntentPatch, AcceptedRowLayoutRuntimeContract, AcceptedStructuralMutation,
2986 AcceptedStructuralMutationTarget, DbSession, DynamicMutation, DynamicStructuralPatch,
2987 DynamicTypedFieldBindingRequest, DynamicTypedFieldType, DynamicTypedMutation,
2988 DynamicWriteCell, FieldSlot, MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
2989 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
2990 MutationProgressRecordOp, add_structural_mutation_staged_bytes,
2991 checked_pre_key_candidate_count, insert_key_exists_after_generation,
2992 validate_structural_mutation_result_bytes,
2993 };
2994 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2995 use crate::db::data::DecodedDataStoreKey;
2996 #[cfg(all(feature = "sql", feature = "diagnostics"))]
2997 use crate::db::executor::budget::{
2998 HardExecutionBudget, HardExecutionContext, HardExecutionFailureHeadroom,
2999 with_query_execution_budget_for_tests,
3000 };
3001 use crate::db::mutation_job::{MutationJobRecord, MutationJobTransition};
3002 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3003 use crate::db::{
3004 CompareProofAndAdvanceError, DynamicQuery, ExhaustiveReadError, RawDataStoreKey,
3005 ReadSetRevisionError, ResumableJobAdvance, ResumableJobAdvanceRequest,
3006 ResumableJobAdvanceStatus, ResumableJobError, ResumableJobId, ResumableJobIdempotencyKey,
3007 ResumableJobStatus, asc,
3008 };
3009 use crate::{
3010 db::{
3011 GeneratedStartupDriverStep, MutationJobAdvanceRequest, MutationJobId,
3012 MutationJobIdempotencyKey, MutationJobPhase, MutationJobStatus,
3013 commit::{
3014 database_incarnation_id, forget_recovered_domain_for_tests,
3015 install_startup_recovery_wakeup,
3016 },
3017 data::DataStore,
3018 drive_generated_startup_recovery_page,
3019 executor::{MutationCommitInterruption, interrupt_next_mutation_commit_for_tests},
3020 index::IndexStore,
3021 integrity::{
3022 InsertMutationJobResult, PhysicalUnitCheckpoint, QuickIntegrityStatus,
3023 RowInspectionLimits, execute_quick_integrity, execute_row_integrity_page,
3024 with_mutation_progress_store,
3025 },
3026 journal::{
3027 JournalBatch, JournalRecord, JournalSequence, JournalTailStore,
3028 encode_journal_batch,
3029 },
3030 registry::{
3031 StoreAllocationIdentities, StoreAllocationIdentity, StoreRegistry,
3032 StoreRuntimeStorageCapabilities,
3033 },
3034 schema::{
3035 AcceptedFieldKind, AcceptedSchemaRevision, FieldId, FieldInsertGeneration,
3036 FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
3037 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
3038 PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy,
3039 SchemaIndexId, SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
3040 accepted_schema_candidate_with_field_bindings_for_tests,
3041 },
3042 write_context::MutationMode,
3043 },
3044 error::{ErrorClass, ErrorOrigin, InternalError},
3045 testing::test_memory,
3046 traits::{CanisterKind, Path},
3047 types::{EntityTag, Timestamp},
3048 value::{InputValue, OutputValue, Value},
3049 };
3050 use icydb_schema::{FieldSourceKey, ScalarType};
3051 use std::{
3052 cell::{Cell, RefCell},
3053 collections::BTreeMap,
3054 time::Instant,
3055 };
3056
3057 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
3058 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
3059 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
3060 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
3061 const ENTITY_NAME: &str = "IdentityRow";
3062 const ENTITY_TAG: EntityTag = EntityTag::new(93);
3063 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
3064 const UNRELATED_STORE_PATH: &str = "session::write::identity_pre_key_tests::UnrelatedStore";
3065
3066 struct TestCanister;
3067
3068 impl Path for TestCanister {
3069 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
3070 }
3071
3072 impl CanisterKind for TestCanister {
3073 const COMMIT_MEMORY_ID: u8 = 45;
3074 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
3075 const STARTUP_MEMORY_ID: u8 = 49;
3076 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.startup.control.v1";
3077 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 46;
3078 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3079 "icydb.identity_pre_key_tests.integrity.progress.v1";
3080 }
3081
3082 thread_local! {
3083 static STARTUP_WAKEUPS: Cell<u32> = const { Cell::new(0) };
3084 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3085 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3086 static SCHEMA_STORE: RefCell<SchemaStore> =
3087 const { RefCell::new(SchemaStore::init_heap()) };
3088 static UNRELATED_DATA_STORE: RefCell<DataStore> =
3089 const { RefCell::new(DataStore::init_heap()) };
3090 static UNRELATED_INDEX_STORE: RefCell<IndexStore> =
3091 const { RefCell::new(IndexStore::init_heap()) };
3092 static UNRELATED_SCHEMA_STORE: RefCell<SchemaStore> =
3093 const { RefCell::new(SchemaStore::init_heap()) };
3094 static STORE_REGISTRY: StoreRegistry = {
3095 let mut registry = StoreRegistry::new();
3096 registry.register_store(
3097 STORE_PATH,
3098 &DATA_STORE,
3099 &INDEX_STORE,
3100 &SCHEMA_STORE,
3101 StoreAllocationIdentities::absent(),
3102 StoreRuntimeStorageCapabilities::heap(),
3103 ).expect("identity pre-key test store should register");
3104 registry.register_store(
3105 UNRELATED_STORE_PATH,
3106 &UNRELATED_DATA_STORE,
3107 &UNRELATED_INDEX_STORE,
3108 &UNRELATED_SCHEMA_STORE,
3109 StoreAllocationIdentities::absent(),
3110 StoreRuntimeStorageCapabilities::heap(),
3111 ).expect("unrelated identity test store should register");
3112 registry
3113 };
3114 static JOURNALED_DATA_STORE: RefCell<DataStore> =
3115 RefCell::new(DataStore::init_journaled(test_memory(186)));
3116 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
3117 RefCell::new(IndexStore::init_journaled(test_memory(187)));
3118 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
3119 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
3120 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
3121 RefCell::new(JournalTailStore::init(test_memory(189)));
3122 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
3123 let mut registry = StoreRegistry::new();
3124 registry.register_journaled_store(
3125 JOURNALED_STORE_PATH,
3126 &JOURNALED_DATA_STORE,
3127 &JOURNALED_INDEX_STORE,
3128 &JOURNALED_SCHEMA_STORE,
3129 &JOURNALED_TAIL_STORE,
3130 StoreAllocationIdentities::new_journaled(
3131 StoreAllocationIdentity::new(186, "icydb.test.identity-range.data.v1"),
3132 StoreAllocationIdentity::new(187, "icydb.test.identity-range.index.v1"),
3133 StoreAllocationIdentity::new(188, "icydb.test.identity-range.schema.v1"),
3134 StoreAllocationIdentity::new(189, "icydb.test.identity-range.journal.v1"),
3135 ),
3136 StoreRuntimeStorageCapabilities::journaled(),
3137 ).expect("identity range journaled store should register");
3138 registry
3139 };
3140 }
3141
3142 fn record_startup_wakeup() {
3143 STARTUP_WAKEUPS.with(|wakeups| wakeups.set(wakeups.get().saturating_add(1)));
3144 }
3145
3146 struct JournaledTestCanister;
3147
3148 impl Path for JournaledTestCanister {
3149 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
3150 }
3151
3152 impl CanisterKind for JournaledTestCanister {
3153 const COMMIT_MEMORY_ID: u8 = 190;
3154 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
3155 const STARTUP_MEMORY_ID: u8 = 192;
3156 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_range_tests.startup.control.v1";
3157 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 191;
3158 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3159 "icydb.identity_range_tests.integrity.progress.v1";
3160 }
3161
3162 fn source_key(source: &str) -> FieldSourceKey {
3163 FieldSourceKey::try_new(source).expect("identity test field source should admit")
3164 }
3165
3166 fn identity_snapshot(store_path: &str) -> PersistedSchemaSnapshot {
3167 let fields = vec![
3168 PersistedFieldSnapshot::new_initial_with_write_policy(
3169 FieldId::new(1),
3170 "id".to_string(),
3171 SchemaFieldSlot::new(0),
3172 AcceptedFieldKind::Nat64,
3173 Vec::new(),
3174 false,
3175 SchemaInsertDefault::None,
3176 SchemaFieldWritePolicy::from_model_policies(
3177 Some(FieldInsertGeneration::Identity),
3178 None,
3179 ),
3180 FieldStorageDecode::ByKind,
3181 LeafCodec::Scalar(ScalarCodec::Nat64),
3182 ),
3183 PersistedFieldSnapshot::new_initial(
3184 FieldId::new(2),
3185 "payload".to_string(),
3186 SchemaFieldSlot::new(1),
3187 AcceptedFieldKind::Nat64,
3188 Vec::new(),
3189 false,
3190 SchemaInsertDefault::None,
3191 FieldStorageDecode::ByKind,
3192 LeafCodec::Scalar(ScalarCodec::Nat64),
3193 ),
3194 ];
3195 PersistedSchemaSnapshot::new_with_indexes(
3196 SchemaVersion::initial(),
3197 ENTITY_SOURCE.to_string(),
3198 ENTITY_NAME.to_string(),
3199 FieldId::new(1),
3200 SchemaRowLayout::initial(
3201 fields
3202 .iter()
3203 .map(|field| (field.id(), field.slot()))
3204 .collect(),
3205 ),
3206 fields,
3207 vec![PersistedIndexSnapshot::new(
3208 SchemaIndexId::new(1).expect("identity test index ID should admit"),
3209 1,
3210 "by_payload".to_string(),
3211 store_path.to_string(),
3212 false,
3213 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
3214 FieldId::new(2),
3215 SchemaFieldSlot::new(1),
3216 vec!["payload".to_string()],
3217 AcceptedFieldKind::Nat64,
3218 false,
3219 )]),
3220 None,
3221 )],
3222 )
3223 }
3224
3225 fn initialize() -> DbSession<TestCanister> {
3226 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3227 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3228 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3229 UNRELATED_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3230 UNRELATED_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3231 UNRELATED_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3232 let session = DbSession::<TestCanister>::new(
3233 &STORE_REGISTRY,
3234 &crate::db::RequestExecutionRoot::__new_runtime_root(),
3235 );
3236 session
3237 .db
3238 .drive_startup_recovery_page()
3239 .expect("identity pre-key test database should initialize");
3240 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3241 STORE_PATH,
3242 AcceptedSchemaRevision::INITIAL,
3243 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH))]),
3244 BTreeMap::from([
3245 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3246 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3247 ]),
3248 );
3249 let store = session
3250 .db
3251 .store_handle(STORE_PATH)
3252 .expect("identity pre-key test store should resolve");
3253 crate::db::commit::publish_accepted_schema_candidate(
3254 STORE_PATH,
3255 store,
3256 AcceptedSchemaRevision::NONE,
3257 &candidate,
3258 )
3259 .expect("identity candidate should publish with explicit zero state");
3260 session
3261 }
3262
3263 fn initialize_journaled_with_root() -> (
3264 DbSession<JournaledTestCanister>,
3265 crate::db::RequestExecutionRoot,
3266 ) {
3267 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
3268 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
3269 session
3270 .db
3271 .drive_startup_recovery_page()
3272 .expect("journaled identity database should initialize");
3273 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3274 JOURNALED_STORE_PATH,
3275 AcceptedSchemaRevision::INITIAL,
3276 BTreeMap::from([(ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH))]),
3277 BTreeMap::from([
3278 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3279 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
3280 ]),
3281 );
3282 let store = session
3283 .db
3284 .store_handle(JOURNALED_STORE_PATH)
3285 .expect("journaled identity store should resolve");
3286 crate::db::commit::publish_accepted_schema_candidate(
3287 JOURNALED_STORE_PATH,
3288 store,
3289 AcceptedSchemaRevision::NONE,
3290 &candidate,
3291 )
3292 .expect("journaled identity candidate should publish");
3293 (session, root)
3294 }
3295
3296 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
3297 initialize_journaled_with_root().0
3298 }
3299
3300 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
3301 AcceptedMutationIntentPatch::new()
3302 .set_authored(FieldSlot::from_validated_index(1), InputValue::Nat64(value))
3303 }
3304
3305 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
3306 DynamicStructuralPatch::new(vec![(
3307 "payload".to_string(),
3308 DynamicWriteCell::Value(InputValue::Nat64(value)),
3309 )])
3310 }
3311
3312 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
3313 vec![OutputValue::Nat64(id), OutputValue::Nat64(payload)]
3314 }
3315
3316 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3317 fn exact_key_binding<C: CanisterKind>(session: &DbSession<C>) -> DynamicTypedEntityBinding {
3318 session
3319 .issue_typed_entity_binding(
3320 ENTITY_SOURCE,
3321 &[
3322 DynamicTypedFieldBindingRequest::new(
3323 ID_SOURCE.to_string(),
3324 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3325 false,
3326 ),
3327 DynamicTypedFieldBindingRequest::new(
3328 PAYLOAD_SOURCE.to_string(),
3329 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
3330 false,
3331 ),
3332 ],
3333 )
3334 .expect("exact-key test binding should issue")
3335 }
3336
3337 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3338 fn insert_exact_key_fixture<C: CanisterKind>(session: &DbSession<C>, payload: u64) -> u64 {
3339 let output = session
3340 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
3341 entity: ENTITY_NAME.to_string(),
3342 patch: dynamic_payload_patch(payload),
3343 })
3344 .expect("exact-key fixture insert should commit");
3345 match output.rows.as_slice() {
3346 [row] => match row.as_slice() {
3347 [OutputValue::Nat64(id), OutputValue::Nat64(actual_payload)]
3348 if *actual_payload == payload =>
3349 {
3350 *id
3351 }
3352 _ => panic!("exact-key fixture should return its identity and payload"),
3353 },
3354 _ => panic!("exact-key fixture insert should return one row"),
3355 }
3356 }
3357
3358 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3359 fn identity_row_stored_bytes<C: CanisterKind>(
3360 session: &DbSession<C>,
3361 store_path: &'static str,
3362 key: u64,
3363 ) -> u64 {
3364 let data_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(key))
3365 .expect("identity row key should encode");
3366 let raw_key = data_key.to_raw().expect("identity raw key should encode");
3367 let store = session
3368 .db
3369 .recovered_store(store_path)
3370 .expect("identity store should resolve");
3371 store.with_data(|data_store| {
3372 u64::try_from(
3373 data_store
3374 .get(&raw_key)
3375 .expect("inserted identity row should exist")
3376 .len(),
3377 )
3378 .expect("bounded row length should fit u64")
3379 })
3380 }
3381
3382 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3383 fn with_stored_bytes_limit<T>(
3384 limit: u64,
3385 shape_fingerprint_prefix: u64,
3386 operation: impl FnOnce() -> Result<T, crate::db::query::intent::QueryError>,
3387 ) -> Result<T, crate::db::query::intent::QueryError> {
3388 let budget = HardExecutionBudget::uniform_for_tests(
3389 u64::MAX,
3390 HardExecutionFailureHeadroom::new(500, 256),
3391 )
3392 .with_limit_for_tests(
3393 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::StoredBytesRead,
3394 limit,
3395 );
3396 let context = HardExecutionContext::new(
3397 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3398 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3399 shape_fingerprint_prefix,
3400 );
3401
3402 with_query_execution_budget_for_tests(budget, context, operation)
3403 }
3404
3405 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3406 fn assert_exact_key_batch<C: CanisterKind>(session: &DbSession<C>) {
3407 let first = insert_exact_key_fixture(session, 41);
3408 let second = insert_exact_key_fixture(session, 42);
3409 let missing = u64::MAX;
3410 let binding = exact_key_binding(session);
3411 let gets_before = DataStore::current_get_call_count();
3412 let result = session
3413 .execute_public_exact_key_batch_for_typed_binding(
3414 &binding,
3415 &[second, missing, first, second],
3416 )
3417 .expect("exact-key batch should execute")
3418 .expect("exact-key binding should remain current");
3419
3420 assert_eq!(result.positions, vec![0, 1, 2, 0]);
3421 assert_eq!(
3422 result.distinct_rows,
3423 vec![
3424 Some(expected_dynamic_row(second, 42)),
3425 None,
3426 Some(expected_dynamic_row(first, 41)),
3427 ],
3428 );
3429 assert_eq!(
3430 DataStore::current_get_call_count().saturating_sub(gets_before),
3431 3,
3432 "four input positions with one duplicate must perform three physical reads",
3433 );
3434 }
3435
3436 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3437 #[test]
3438 fn exact_key_batches_preserve_semantics_across_heap_and_journaled_stores() {
3439 assert_exact_key_batch(&initialize());
3440 assert_exact_key_batch(&initialize_journaled());
3441 }
3442
3443 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3444 fn assert_primary_range_materialization_fetches_once<C: CanisterKind>(
3445 session: &DbSession<C>,
3446 store_path: &'static str,
3447 ) {
3448 let key = insert_exact_key_fixture(session, 41);
3449 let stored_bytes = identity_row_stored_bytes(session, store_path, key);
3450
3451 let scalar = DynamicQuery::new(ENTITY_NAME)
3452 .select(["id", "payload"])
3453 .order_by(asc("id"))
3454 .limit(1);
3455 let gets_before = DataStore::current_get_call_count();
3456 let scalar_page = with_stored_bytes_limit(stored_bytes, 0x7072_696d_6172_792d, || {
3457 session.execute_trusted_live_page(&scalar, None)
3458 })
3459 .expect("one scalar primary-range row should fit one payload-read allowance");
3460 assert_eq!(scalar_page.row_count, 1);
3461 assert_eq!(
3462 DataStore::current_get_call_count().saturating_sub(gets_before),
3463 1,
3464 "scalar primary traversal should fetch its emitted row exactly once",
3465 );
3466
3467 let grouped = DynamicQuery::new(ENTITY_NAME)
3468 .group_by("payload")
3469 .aggregate(crate::db::count())
3470 .grouped_limits(10, 16 * 1_024)
3471 .limit(1);
3472 let gets_before = DataStore::current_get_call_count();
3473 let grouped_page = with_stored_bytes_limit(stored_bytes, 0x6772_6f75_7065_642d, || {
3474 session.execute_trusted_dynamic_grouped_query(&grouped)
3475 })
3476 .expect("one grouped primary-range row should fit one payload-read allowance");
3477 assert_eq!(grouped_page.row_count, 1);
3478 assert_eq!(
3479 DataStore::current_get_call_count().saturating_sub(gets_before),
3480 1,
3481 "grouped primary traversal should fetch its source row exactly once",
3482 );
3483 }
3484
3485 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3486 #[test]
3487 fn row_materialization_fetches_each_required_payload_at_most_once() {
3488 assert_primary_range_materialization_fetches_once(&initialize(), STORE_PATH);
3489 assert_primary_range_materialization_fetches_once(
3490 &initialize_journaled(),
3491 JOURNALED_STORE_PATH,
3492 );
3493 }
3494
3495 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3496 #[test]
3497 fn ordered_grouped_pages_close_a_group_spanning_physical_refills_before_resume() {
3498 let session = initialize();
3499 let mut patches = Vec::new();
3500 for _ in 0..70 {
3501 patches.push(dynamic_payload_patch(10));
3502 }
3503 for _ in 0..3 {
3504 patches.push(dynamic_payload_patch(20));
3505 }
3506 patches.push(dynamic_payload_patch(30));
3507 let inserted = session
3508 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, patches)
3509 .expect("ordered grouped continuation rows should insert");
3510 assert_eq!(inserted.rows.len(), 74);
3511
3512 let query = DynamicQuery::new(ENTITY_NAME)
3513 .group_by("payload")
3514 .aggregate(crate::db::count())
3515 .aggregate(crate::db::sum("id"))
3516 .order_by(asc("payload"))
3517 .grouped_limits(4, 16 * 1_024)
3518 .limit(1);
3519 let expected = [
3520 (10_u64, 70_u64, crate::types::Decimal::new(2_485, 0)),
3521 (20, 3, crate::types::Decimal::new(216, 0)),
3522 (30, 1, crate::types::Decimal::new(74, 0)),
3523 ];
3524 let mut continuation: Option<String> = None;
3525 let mut seen_cursors = std::collections::BTreeSet::new();
3526
3527 for (page_index, (group_key, row_count, id_sum)) in expected.into_iter().enumerate() {
3528 let request = continuation.as_ref().map_or_else(
3529 || query.clone(),
3530 |cursor| query.clone().cursor(cursor.clone()),
3531 );
3532 let entries_before = IndexStore::current_entry_read_count();
3533 let rows_before = DataStore::current_get_call_count();
3534 let page = session
3535 .execute_trusted_dynamic_grouped_query(&request)
3536 .unwrap_or_else(|error| {
3537 panic!("ordered grouped page {page_index} should execute: {error:?}")
3538 });
3539 let entries_read =
3540 IndexStore::current_entry_read_count().saturating_sub(entries_before);
3541 let rows_read = DataStore::current_get_call_count().saturating_sub(rows_before);
3542
3543 assert_eq!(page.row_count, 1);
3544 let [row] = page.rows.as_slice() else {
3545 panic!("ordered grouped page must contain exactly one closed group")
3546 };
3547 assert_eq!(row.group_key(), &[OutputValue::Nat64(group_key)]);
3548 assert_eq!(
3549 row.aggregate_values(),
3550 &[OutputValue::Nat64(row_count), OutputValue::Decimal(id_sum),],
3551 );
3552 if page_index == 0 {
3553 assert!(
3554 entries_read.saturating_add(rows_read) >= 70,
3555 "the first closed group must span the maintained 64-entry physical refill",
3556 );
3557 }
3558
3559 continuation = page.next_cursor;
3560 if page_index + 1 < expected.len() {
3561 let cursor = continuation
3562 .as_ref()
3563 .expect("another closed group should retain continuation");
3564 assert!(
3565 seen_cursors.insert(cursor.clone()),
3566 "ordered grouped continuation must advance monotonically",
3567 );
3568 } else {
3569 assert_eq!(continuation, None);
3570 }
3571 }
3572 }
3573
3574 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3575 #[test]
3576 fn exhaustive_pages_require_and_recompare_the_complete_source_proof() {
3577 let session = initialize();
3578 let first = insert_exact_key_fixture(&session, 41);
3579 let second = insert_exact_key_fixture(&session, 42);
3580 let third = insert_exact_key_fixture(&session, 43);
3581 let query = DynamicQuery::new(ENTITY_NAME)
3582 .select(["id", "payload"])
3583 .order_by(asc("id"));
3584
3585 let page = session
3586 .execute_trusted_exhaustive_page(&query, None, None)
3587 .expect("initial exhaustive page should capture its source proof");
3588 assert_eq!(
3589 page.rows,
3590 vec![
3591 expected_dynamic_row(first, 41),
3592 expected_dynamic_row(second, 42),
3593 ],
3594 );
3595 let continuation = page
3596 .continuation
3597 .as_deref()
3598 .expect("unreturned row should retain exhaustive continuation");
3599 assert!(matches!(
3600 session.execute_trusted_exhaustive_page(&query, Some(continuation), None),
3601 Err(ExhaustiveReadError::Revision(
3602 ReadSetRevisionError::ResumeProofRequired
3603 )),
3604 ));
3605 let resumed = session
3606 .execute_trusted_exhaustive_page(&query, Some(continuation), Some(&page.proof))
3607 .expect("unchanged proof should resume exhaustive traversal");
3608 assert_eq!(resumed.rows, vec![expected_dynamic_row(third, 43)]);
3609 assert_eq!(resumed.continuation, None);
3610
3611 let stale_page = session
3612 .execute_trusted_exhaustive_page(&query, None, None)
3613 .expect("fresh exhaustive page should capture current revision");
3614 let stale_continuation = stale_page
3615 .continuation
3616 .as_deref()
3617 .expect("fresh three-row traversal should retain continuation");
3618 let _ = insert_exact_key_fixture(&session, 44);
3619 assert!(matches!(
3620 session.execute_trusted_exhaustive_page(
3621 &query,
3622 Some(stale_continuation),
3623 Some(&stale_page.proof),
3624 ),
3625 Err(ExhaustiveReadError::Revision(
3626 ReadSetRevisionError::StoreDataChanged { .. }
3627 )),
3628 ));
3629 }
3630
3631 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3632 #[test]
3633 fn heap_sources_cannot_back_durable_resumable_jobs() {
3634 let session = initialize();
3635 let proof = session
3636 .capture_read_set_revision_proof(&[ENTITY_NAME])
3637 .expect("heap source proof should capture for one-call exhaustive reads");
3638 let job_id = ResumableJobId::try_from_bytes([70; 32])
3639 .expect("nonzero heap test job identity should admit");
3640
3641 assert!(matches!(
3642 session.start_resumable_job(job_id, proof, Vec::new()),
3643 Err(ResumableJobError::SourceProof(
3644 ReadSetRevisionError::DurableStoreRequired { .. }
3645 )),
3646 ));
3647 }
3648
3649 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3650 #[test]
3651 fn proof_and_progress_controls_charge_one_shared_request_scope() {
3652 let (session, root) = initialize_journaled_with_root();
3653 let resource = icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions;
3654 let before = root.observed(resource);
3655 let proof = session
3656 .capture_read_set_revision_proof(&[ENTITY_NAME])
3657 .expect("proof capture should use the retained request scope");
3658 let job_id = ResumableJobId::try_from_bytes([75; 32])
3659 .expect("nonzero accounting job identity should admit");
3660 session
3661 .start_resumable_job(job_id, proof, Vec::new())
3662 .expect("job start should use the same retained request scope");
3663 let _ = session
3664 .resumable_job_state(job_id)
3665 .expect("job load should use the same retained request scope");
3666
3667 assert_eq!(root.observed(resource).saturating_sub(before), 3);
3668 }
3669
3670 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3671 #[test]
3672 fn source_proofs_ignore_unrelated_stores_but_bind_access_state_changes() {
3673 let session = initialize();
3674 let proof = session
3675 .capture_read_set_revision_proof(&[ENTITY_NAME])
3676 .expect("source proof should cover only the entity's physical store");
3677 let shared_store_proof = session
3678 .capture_read_set_revision_proof(&[ENTITY_NAME, ENTITY_NAME])
3679 .expect("entities sharing one physical source should deduplicate");
3680 assert_eq!(shared_store_proof, proof);
3681 assert_eq!(shared_store_proof.stores().len(), 1);
3682 let unrelated = session
3683 .db
3684 .store_handle(UNRELATED_STORE_PATH)
3685 .expect("unrelated registered store should resolve");
3686 unrelated.with_data_mut(|store| {
3687 let _ = store.remove(&RawDataStoreKey::from_persisted_bytes(vec![1]));
3688 });
3689 session
3690 .verify_read_set_revision_proof(&proof)
3691 .expect("a nonparticipating store mutation must not invalidate the proof");
3692
3693 let source = session
3694 .db
3695 .store_handle(STORE_PATH)
3696 .expect("participating source store should resolve");
3697 source
3698 .mark_index_building()
3699 .expect("source access-state transition should advance its revision");
3700 assert!(matches!(
3701 session.verify_read_set_revision_proof(&proof),
3702 Err(ExhaustiveReadError::Revision(
3703 ReadSetRevisionError::StoreAccessChanged { .. }
3704 )),
3705 ));
3706 }
3707
3708 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3709 #[expect(
3710 clippy::too_many_lines,
3711 reason = "one lifecycle test proves successful replay plus pre-page and post-page source invalidation without sharing progress state across tests"
3712 )]
3713 #[test]
3714 fn journaled_job_advance_is_idempotent_and_revision_checked_on_both_sides() {
3715 let session = initialize_journaled();
3716 let proof = session
3717 .capture_read_set_revision_proof(&[ENTITY_NAME])
3718 .expect("journaled source proof should capture");
3719 let job_id =
3720 ResumableJobId::try_from_bytes([71; 32]).expect("nonzero job identity should admit");
3721 session
3722 .start_resumable_job(job_id, proof, vec![0])
3723 .expect("journaled job should start outside its protected source revision");
3724 let request = ResumableJobAdvanceRequest::new(
3725 job_id,
3726 0,
3727 ResumableJobIdempotencyKey::new("page-0")
3728 .expect("bounded idempotency key should admit"),
3729 );
3730 let calls = Cell::new(0_u8);
3731 let receipt = session
3732 .compare_proof_and_advance(&request, |state| {
3733 calls.set(calls.get() + 1);
3734 assert_eq!(state.application_state, vec![0]);
3735 Ok::<_, ()>(
3736 ResumableJobAdvance::new(Some("cursor-1".to_string()), vec![1], vec![9])
3737 .expect("bounded application advance should admit"),
3738 )
3739 })
3740 .expect("unchanged source should advance exactly once");
3741 assert_eq!(calls.get(), 1);
3742 assert_eq!(receipt.status, ResumableJobAdvanceStatus::Advanced);
3743 assert_eq!(receipt.committed_sequence, 1);
3744
3745 let replay = session
3746 .compare_proof_and_advance::<()>(&request, |_| {
3747 panic!("lost-response replay must not execute application work")
3748 })
3749 .expect("same request identity should return its persisted receipt");
3750 assert_eq!(replay, receipt);
3751 let retained = session
3752 .resumable_job_state(job_id)
3753 .expect("advanced state should remain durable");
3754 assert_eq!(retained.sequence, 1);
3755 assert_eq!(retained.application_state, vec![1]);
3756
3757 let _ = insert_exact_key_fixture(&session, 51);
3758 let pre_change_request = ResumableJobAdvanceRequest::new(
3759 job_id,
3760 1,
3761 ResumableJobIdempotencyKey::new("page-1")
3762 .expect("bounded idempotency key should admit"),
3763 );
3764 let pre_change_calls = Cell::new(0_u8);
3765 let invalidated = session
3766 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
3767 pre_change_calls.set(pre_change_calls.get() + 1);
3768 unreachable!("pre-page proof failure must reject before application work")
3769 })
3770 .expect("source drift should persist one replayable invalidation receipt");
3771 assert_eq!(pre_change_calls.get(), 0);
3772 assert_eq!(invalidated.status, ResumableJobAdvanceStatus::Invalidated);
3773 let invalidated_state = session
3774 .resumable_job_state(job_id)
3775 .expect("invalidated job should remain inspectable");
3776 assert_eq!(invalidated_state.status, ResumableJobStatus::Invalidated);
3777 assert_eq!(invalidated_state.continuation, None);
3778 assert_eq!(invalidated_state.application_state, vec![1]);
3779 assert_eq!(
3780 session
3781 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
3782 panic!("invalidation replay must not execute application work")
3783 })
3784 .expect("lost invalidation reply should replay exactly"),
3785 invalidated,
3786 );
3787
3788 let post_proof = session
3789 .capture_read_set_revision_proof(&[ENTITY_NAME])
3790 .expect("post-change journaled proof should capture");
3791 let post_job_id = ResumableJobId::try_from_bytes([72; 32])
3792 .expect("nonzero post-change job identity should admit");
3793 session
3794 .start_resumable_job(post_job_id, post_proof, vec![7])
3795 .expect("post-change journaled job should start");
3796 let post_request = ResumableJobAdvanceRequest::new(
3797 post_job_id,
3798 0,
3799 ResumableJobIdempotencyKey::new("post-page-0")
3800 .expect("bounded idempotency key should admit"),
3801 );
3802 let post_receipt = session
3803 .compare_proof_and_advance::<()>(&post_request, |_| {
3804 let _ = insert_exact_key_fixture(&session, 52);
3805 Ok(ResumableJobAdvance::new(None, vec![8], vec![10])
3806 .expect("bounded post-change candidate should admit"))
3807 })
3808 .expect("post-page drift should discard the candidate and persist invalidation");
3809 assert_eq!(post_receipt.status, ResumableJobAdvanceStatus::Invalidated);
3810 let post_state = session
3811 .resumable_job_state(post_job_id)
3812 .expect("post-page invalidation should remain inspectable");
3813 assert_eq!(post_state.status, ResumableJobStatus::Invalidated);
3814 assert_eq!(post_state.application_state, vec![7]);
3815 session
3816 .acknowledge_resumable_job(post_job_id, post_state.sequence)
3817 .expect("terminal job acknowledgement should remove retained progress");
3818 session
3819 .acknowledge_resumable_job(post_job_id, post_state.sequence)
3820 .expect("lost acknowledgement reply should be safely replayable");
3821 assert_eq!(
3822 session.resumable_job_state(post_job_id),
3823 Err(ResumableJobError::NotFound),
3824 );
3825
3826 let completed_job_id = ResumableJobId::try_from_bytes([74; 32])
3827 .expect("nonzero completed job identity should admit");
3828 let completed_proof = session
3829 .capture_read_set_revision_proof(&[ENTITY_NAME])
3830 .expect("completed-job source proof should capture");
3831 session
3832 .start_resumable_job(completed_job_id, completed_proof, Vec::new())
3833 .expect("completed-job fixture should start");
3834 let completed_request = ResumableJobAdvanceRequest::new(
3835 completed_job_id,
3836 0,
3837 ResumableJobIdempotencyKey::new("complete")
3838 .expect("bounded completion key should admit"),
3839 );
3840 let completed_receipt = session
3841 .compare_proof_and_advance::<()>(&completed_request, |_| {
3842 Ok(ResumableJobAdvance::new(None, vec![99], vec![100])
3843 .expect("bounded terminal advance should admit"))
3844 })
3845 .expect("null continuation should commit terminal completion");
3846 let completed_state = session
3847 .resumable_job_state(completed_job_id)
3848 .expect("completed state should remain replayable before acknowledgement");
3849 assert_eq!(completed_state.status, ResumableJobStatus::Completed);
3850 assert_eq!(
3851 session
3852 .compare_proof_and_advance::<()>(&completed_request, |_| {
3853 panic!("completed request replay must not execute application work")
3854 })
3855 .expect("completed request should replay until acknowledgement"),
3856 completed_receipt,
3857 );
3858 let after_completion = ResumableJobAdvanceRequest::new(
3859 completed_job_id,
3860 1,
3861 ResumableJobIdempotencyKey::new("after-complete")
3862 .expect("bounded post-completion key should admit"),
3863 );
3864 assert!(matches!(
3865 session.compare_proof_and_advance::<()>(&after_completion, |_| {
3866 panic!("completed jobs cannot execute another page")
3867 }),
3868 Err(CompareProofAndAdvanceError::Protocol(
3869 ResumableJobError::Completed
3870 )),
3871 ));
3872 session
3873 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
3874 .expect("completed job should acknowledge and free capacity");
3875 session
3876 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
3877 .expect("completion acknowledgement should be idempotent");
3878
3879 let stale_job_id = ResumableJobId::try_from_bytes([73; 32])
3880 .expect("nonzero stale-sequence job identity should admit");
3881 let stale_proof = session
3882 .capture_read_set_revision_proof(&[ENTITY_NAME])
3883 .expect("stale-sequence source proof should capture");
3884 session
3885 .start_resumable_job(stale_job_id, stale_proof, Vec::new())
3886 .expect("stale-sequence job should start");
3887 let stale_request = ResumableJobAdvanceRequest::new(
3888 stale_job_id,
3889 4,
3890 ResumableJobIdempotencyKey::new("stale").expect("bounded idempotency key should admit"),
3891 );
3892 assert!(matches!(
3893 session.compare_proof_and_advance::<()>(&stale_request, |_| {
3894 panic!("stale sequence must reject before application work")
3895 }),
3896 Err(CompareProofAndAdvanceError::Protocol(
3897 ResumableJobError::StaleSequence {
3898 expected: 4,
3899 actual: 0,
3900 }
3901 )),
3902 ));
3903 assert_eq!(
3904 session.acknowledge_resumable_job(stale_job_id, 0),
3905 Err(ResumableJobError::NotTerminal),
3906 );
3907 }
3908
3909 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3910 #[test]
3911 fn exact_key_batch_uses_typed_hard_execution_budget() {
3912 let session = initialize();
3913 let binding = exact_key_binding(&session);
3914 let budget =
3915 HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
3916 let error = session
3917 .execute_exact_key_batch_with_hard_budget_for_tests(&binding, &[u64::MAX], &budget)
3918 .expect_err("zero query budget should reject the exact-key route");
3919
3920 assert!(matches!(
3921 error.diagnostic().detail(),
3922 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3923 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3924 })
3925 ));
3926 let facts = error.diagnostic_facts();
3927 assert_eq!(
3928 &facts[..5],
3929 &[
3930 (
3931 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3932 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions.raw(),
3933 ),
3934 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 0),
3935 (icydb_diagnostic_code::DiagnosticFactTag::Actual, 1),
3936 (
3937 icydb_diagnostic_code::DiagnosticFactTag::ExecutionBudgetScope,
3938 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution.raw(),
3939 ),
3940 (
3941 icydb_diagnostic_code::DiagnosticFactTag::ExecutionLane,
3942 icydb_diagnostic_code::DiagnosticExecutionLane::PublicRead.raw(),
3943 ),
3944 ],
3945 );
3946 assert_eq!(
3947 facts[5].0,
3948 icydb_diagnostic_code::DiagnosticFactTag::QueryShapeFingerprintPrefix,
3949 );
3950 assert_ne!(facts[5].1, 0);
3951 }
3952
3953 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3954 fn assert_planned_query_exhausts(
3955 session: &DbSession<TestCanister>,
3956 query: &crate::db::DynamicQuery,
3957 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
3958 ) {
3959 let budget = HardExecutionBudget::uniform_for_tests(
3960 u64::MAX,
3961 HardExecutionFailureHeadroom::new(500, 256),
3962 )
3963 .with_limit_for_tests(resource, 0);
3964 let context = HardExecutionContext::new(
3965 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
3966 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
3967 0x7068_7973_6963_616c,
3968 );
3969 let error = with_query_execution_budget_for_tests(budget, context, || {
3970 session.execute_trusted_live_page(query, None)
3971 })
3972 .expect_err("the injected zero resource allowance should reject planned execution");
3973
3974 assert!(matches!(
3975 error.diagnostic().detail(),
3976 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3977 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3978 })
3979 ));
3980 assert_eq!(
3981 error.diagnostic_facts()[0],
3982 (
3983 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3984 resource.raw(),
3985 ),
3986 );
3987 }
3988
3989 #[cfg(all(feature = "sql", feature = "diagnostics"))]
3990 fn assert_grouped_query_exhausts(
3991 session: &DbSession<TestCanister>,
3992 query: &crate::db::DynamicQuery,
3993 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
3994 ) {
3995 let budget = HardExecutionBudget::uniform_for_tests(
3996 u64::MAX,
3997 HardExecutionFailureHeadroom::new(500, 256),
3998 )
3999 .with_limit_for_tests(resource, 0);
4000 let context = HardExecutionContext::new(
4001 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
4002 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
4003 0x6772_6f75_7065_642d,
4004 );
4005 let error = with_query_execution_budget_for_tests(budget, context, || {
4006 session.execute_trusted_dynamic_grouped_query(query)
4007 })
4008 .expect_err("the injected zero resource allowance should reject grouped execution");
4009
4010 assert!(matches!(
4011 error.diagnostic().detail(),
4012 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4013 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4014 })
4015 ));
4016 assert_eq!(
4017 error.diagnostic_facts()[0],
4018 (
4019 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4020 resource.raw(),
4021 ),
4022 );
4023 }
4024
4025 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4026 fn assert_sql_query_exhausts(
4027 session: &DbSession<TestCanister>,
4028 sql: &str,
4029 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
4030 ) {
4031 let budget = HardExecutionBudget::uniform_for_tests(
4032 u64::MAX,
4033 HardExecutionFailureHeadroom::new(500, 256),
4034 )
4035 .with_limit_for_tests(resource, 0);
4036 let context = HardExecutionContext::new(
4037 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
4038 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
4039 0x7371_6c2d_736f_7274,
4040 );
4041 let error = with_query_execution_budget_for_tests(budget, context, || {
4042 session.execute_trusted_sql_query(sql)
4043 })
4044 .expect_err("the injected zero resource allowance should reject SQL execution");
4045
4046 assert!(matches!(
4047 error.diagnostic().detail(),
4048 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4049 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
4050 })
4051 ));
4052 assert_eq!(
4053 error.diagnostic_facts()[0],
4054 (
4055 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
4056 resource.raw(),
4057 ),
4058 );
4059 }
4060
4061 #[cfg(all(feature = "sql", feature = "diagnostics"))]
4062 #[test]
4063 fn planned_read_routes_share_physical_resource_accounting() {
4064 let session = initialize();
4065 let first = insert_exact_key_fixture(&session, 41);
4066 insert_exact_key_fixture(&session, 42);
4067
4068 let fallback = crate::db::DynamicQuery::new(ENTITY_NAME)
4069 .filter(crate::db::FieldRef::new("id").eq(first))
4070 .select(["id", "payload"])
4071 .order_by(crate::db::asc("id"))
4072 .limit(1);
4073 assert_eq!(
4074 session
4075 .execute_trusted_live_page(&fallback, None)
4076 .expect("bounded fallback execution should preserve its result")
4077 .row_count,
4078 1,
4079 );
4080 assert_planned_query_exhausts(
4081 &session,
4082 &fallback,
4083 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::RowsVisited,
4084 );
4085
4086 let covering = crate::db::DynamicQuery::new(ENTITY_NAME)
4087 .filter(crate::db::FieldRef::new("payload").eq(41_u64))
4088 .select(["payload"])
4089 .order_by(crate::db::asc("payload"))
4090 .limit(1);
4091 assert_eq!(
4092 session
4093 .execute_trusted_live_page(&covering, None)
4094 .expect("bounded covering execution should preserve its result")
4095 .row_count,
4096 1,
4097 );
4098 assert_planned_query_exhausts(
4099 &session,
4100 &covering,
4101 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
4102 );
4103
4104 let residual = crate::db::DynamicQuery::new(ENTITY_NAME)
4105 .filter(crate::db::FieldRef::new("payload").eq_field("id"))
4106 .select(["id"])
4107 .order_by(crate::db::asc("id"))
4108 .limit(1);
4109 assert_eq!(
4110 session
4111 .execute_trusted_live_page(&residual, None)
4112 .expect("bounded residual execution should preserve its result")
4113 .row_count,
4114 0,
4115 );
4116 assert_planned_query_exhausts(
4117 &session,
4118 &residual,
4119 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
4120 );
4121
4122 assert_planned_query_exhausts(
4123 &session,
4124 &fallback,
4125 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::ResultBytes,
4126 );
4127
4128 let grouped = crate::db::DynamicQuery::new(ENTITY_NAME)
4129 .group_by("payload")
4130 .aggregate(crate::db::count())
4131 .order_by(crate::db::asc("payload"))
4132 .grouped_limits(10, 16 * 1_024)
4133 .limit(1);
4134 let grouped_result = session
4135 .execute_trusted_dynamic_grouped_query(&grouped)
4136 .expect("bounded grouped execution should preserve its result");
4137 assert_eq!(grouped_result.row_count, 1);
4138 assert!(grouped_result.next_cursor.is_some());
4139 assert_grouped_query_exhausts(
4140 &session,
4141 &grouped,
4142 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctEntries,
4143 );
4144 assert_grouped_query_exhausts(
4145 &session,
4146 &grouped,
4147 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::CursorSteps,
4148 );
4149
4150 assert_sql_query_exhausts(
4151 &session,
4152 "SELECT payload, COUNT(*) AS row_count FROM IdentityRow \
4153 GROUP BY payload ORDER BY row_count DESC, payload ASC LIMIT 1",
4154 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::SortEntries,
4155 );
4156 }
4157
4158 fn assert_dynamic_payload<C: CanisterKind>(
4159 session: &DbSession<C>,
4160 key: u64,
4161 expected_payload: u64,
4162 ) {
4163 let unchanged = session
4164 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4165 entity: ENTITY_NAME.to_string(),
4166 key: InputValue::Nat64(key),
4167 patch: dynamic_payload_patch(expected_payload),
4168 })
4169 .expect("the expected row should remain readable through a no-op update");
4170 assert_eq!(unchanged.affected_rows, 0);
4171 assert_eq!(
4172 unchanged.rows,
4173 vec![expected_dynamic_row(key, expected_payload)],
4174 );
4175 }
4176
4177 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
4178 values
4179 .iter()
4180 .map(|value| {
4181 AcceptedStructuralMutation::save(
4182 MutationMode::Insert,
4183 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
4184 payload_patch(*value),
4185 )
4186 })
4187 .collect()
4188 }
4189
4190 fn atomic_progress_fixture(
4191 identity_byte: u8,
4192 ) -> (
4193 MutationJobRecord,
4194 MutationJobRecord,
4195 MutationProgressRecordOp,
4196 ) {
4197 let job_id = MutationJobId::try_from_bytes([identity_byte; 32])
4198 .expect("nonzero atomic progress job id should admit");
4199 let before = MutationJobRecord::new(job_id, vec![1, identity_byte], vec![2])
4200 .expect("atomic progress predecessor should admit");
4201 let request = MutationJobAdvanceRequest::new(
4202 job_id,
4203 0,
4204 MutationJobIdempotencyKey::new(format!("atomic-{identity_byte}"))
4205 .expect("atomic progress replay key should admit"),
4206 );
4207 let (after, _) = before
4208 .apply_transition(
4209 &request,
4210 MutationJobTransition::new(
4211 MutationJobStatus::Active,
4212 MutationJobPhase::Forward,
4213 vec![3],
4214 1,
4215 1,
4216 0,
4217 ),
4218 )
4219 .expect("atomic progress successor should admit");
4220 let operation = MutationProgressRecordOp::replace(&before, &after)
4221 .expect("atomic progress replacement should admit");
4222 (before, after, operation)
4223 }
4224
4225 fn assert_identity_boundary(error: &InternalError) {
4226 assert_eq!(error.class(), ErrorClass::Unsupported);
4227 assert_eq!(error.origin(), ErrorOrigin::Identity);
4228 }
4229
4230 #[test]
4231 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
4232 let generated = insert_key_exists_after_generation(true);
4233 assert_eq!(generated.class(), ErrorClass::Corruption);
4234 assert_eq!(generated.origin(), ErrorOrigin::Identity);
4235
4236 let ordinary = insert_key_exists_after_generation(false);
4237 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
4238 }
4239
4240 #[cfg(target_pointer_width = "64")]
4241 #[test]
4242 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
4243 let error = checked_pre_key_candidate_count(
4244 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
4245 )
4246 .expect_err("candidate counts beyond u32 must reject");
4247 assert_identity_boundary(&error);
4248 }
4249
4250 #[test]
4251 #[expect(
4252 clippy::too_many_lines,
4253 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
4254 )]
4255 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
4256 let session = initialize();
4257 let seeded = session
4258 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4259 .expect("seed rows should commit");
4260 assert_eq!(seeded.affected_rows, 1);
4261
4262 let split = session
4263 .execute_trusted_dynamic_mutation_batch(vec![
4264 DynamicMutation::Update {
4265 entity: ENTITY_NAME.to_string(),
4266 key: InputValue::Nat64(1),
4267 patch: dynamic_payload_patch(60),
4268 },
4269 DynamicMutation::Insert {
4270 entity: ENTITY_NAME.to_string(),
4271 patch: dynamic_payload_patch(40),
4272 },
4273 ])
4274 .expect("one holding should split atomically");
4275 assert_eq!(split.affected_rows, 2);
4276 assert_eq!(
4277 split.rows,
4278 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
4279 "split after-images must retain input order and exact quantity",
4280 );
4281
4282 let rejected_split = session
4283 .execute_trusted_dynamic_mutation_batch(vec![
4284 DynamicMutation::Update {
4285 entity: ENTITY_NAME.to_string(),
4286 key: InputValue::Nat64(1),
4287 patch: dynamic_payload_patch(50),
4288 },
4289 DynamicMutation::Insert {
4290 entity: ENTITY_NAME.to_string(),
4291 patch: DynamicStructuralPatch::new(Vec::new()),
4292 },
4293 ])
4294 .expect_err("an invalid split output must reject the staged source update");
4295 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
4296 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
4297 assert_eq!(
4298 rejected_split.diagnostic_facts(),
4299 vec![
4300 (
4301 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4302 ENTITY_TAG.value(),
4303 ),
4304 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 2),
4305 (
4306 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4307 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4308 ),
4309 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1,),
4310 ],
4311 );
4312 assert_dynamic_payload(&session, 1, 60);
4313 assert_dynamic_payload(&session, 2, 40);
4314
4315 let transfer = session
4316 .execute_trusted_dynamic_mutation_batch(vec![
4317 DynamicMutation::Update {
4318 entity: ENTITY_NAME.to_string(),
4319 key: InputValue::Nat64(1),
4320 patch: dynamic_payload_patch(70),
4321 },
4322 DynamicMutation::Update {
4323 entity: ENTITY_NAME.to_string(),
4324 key: InputValue::Nat64(2),
4325 patch: dynamic_payload_patch(30),
4326 },
4327 ])
4328 .expect("distinct transfer patches should share one atomic batch");
4329 assert_eq!(
4330 transfer.rows,
4331 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
4332 "the transfer must preserve the exact total quantity",
4333 );
4334
4335 let merge = session
4336 .execute_trusted_dynamic_mutation_batch(vec![
4337 DynamicMutation::Delete {
4338 entity: ENTITY_NAME.to_string(),
4339 key: InputValue::Nat64(2),
4340 },
4341 DynamicMutation::Update {
4342 entity: ENTITY_NAME.to_string(),
4343 key: InputValue::Nat64(1),
4344 patch: dynamic_payload_patch(100),
4345 },
4346 ])
4347 .expect("two holdings should merge atomically");
4348 assert_eq!(
4349 merge.rows,
4350 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
4351 "delete before-images and update after-images must retain input order",
4352 );
4353
4354 let resplit = session
4355 .execute_trusted_dynamic_mutation_batch(vec![
4356 DynamicMutation::Update {
4357 entity: ENTITY_NAME.to_string(),
4358 key: InputValue::Nat64(1),
4359 patch: dynamic_payload_patch(60),
4360 },
4361 DynamicMutation::Insert {
4362 entity: ENTITY_NAME.to_string(),
4363 patch: dynamic_payload_patch(40),
4364 },
4365 ])
4366 .expect("the merged holding should split again");
4367 assert_eq!(
4368 resplit.rows,
4369 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
4370 );
4371
4372 let rejected_merge = session
4373 .execute_trusted_dynamic_mutation_batch(vec![
4374 DynamicMutation::Delete {
4375 entity: ENTITY_NAME.to_string(),
4376 key: InputValue::Nat64(3),
4377 },
4378 DynamicMutation::Update {
4379 entity: ENTITY_NAME.to_string(),
4380 key: InputValue::Nat64(99),
4381 patch: dynamic_payload_patch(100),
4382 },
4383 ])
4384 .expect_err("a late missing merge target must preserve the earlier staged delete");
4385 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
4386 assert_dynamic_payload(&session, 1, 60);
4387 assert_dynamic_payload(&session, 3, 40);
4388
4389 SCHEMA_STORE.with(|store| {
4390 let cursor = store
4391 .borrow()
4392 .identity_statement_cursor(
4393 database_incarnation_id().expect("database incarnation should remain readable"),
4394 ENTITY_TAG,
4395 FieldId::new(1),
4396 &AcceptedFieldKind::Nat64,
4397 )
4398 .expect("mixed Identity state should remain readable");
4399 assert_eq!(cursor.expected_high_water(), 3);
4400 assert!(!cursor.has_allocations());
4401 });
4402 }
4403
4404 #[test]
4405 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
4406 let session = initialize();
4407 session
4408 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
4409 .expect("the holding fixture should initialize");
4410
4411 let duplicate = session
4412 .execute_trusted_dynamic_mutation_batch(vec![
4413 DynamicMutation::Update {
4414 entity: ENTITY_NAME.to_string(),
4415 key: InputValue::Nat64(1),
4416 patch: dynamic_payload_patch(60),
4417 },
4418 DynamicMutation::Delete {
4419 entity: ENTITY_NAME.to_string(),
4420 key: InputValue::Nat64(1),
4421 },
4422 ])
4423 .expect_err("duplicate targets across operation kinds must reject");
4424 assert!(matches!(
4425 duplicate.diagnostic().detail(),
4426 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4427 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
4428 }),
4429 ));
4430 assert_eq!(
4431 duplicate.diagnostic_facts(),
4432 vec![
4433 (
4434 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4435 ENTITY_TAG.value(),
4436 ),
4437 (
4438 icydb_diagnostic_code::DiagnosticFactTag::FirstBatchPosition,
4439 0,
4440 ),
4441 (
4442 icydb_diagnostic_code::DiagnosticFactTag::DuplicateBatchPosition,
4443 1,
4444 ),
4445 ],
4446 );
4447 assert_dynamic_payload(&session, 1, 100);
4448 }
4449
4450 #[test]
4451 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
4452 let session = initialize();
4453 let empty = session
4454 .execute_trusted_dynamic_mutation_batch(Vec::new())
4455 .expect_err("an empty public batch must reject");
4456 assert!(matches!(
4457 empty.diagnostic().detail(),
4458 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4459 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
4460 }),
4461 ));
4462 assert_eq!(
4463 empty.diagnostic_facts(),
4464 vec![(icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 0,)],
4465 );
4466
4467 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
4468 .map(|_| DynamicMutation::Delete {
4469 entity: ENTITY_NAME.to_string(),
4470 key: InputValue::Nat64(1),
4471 })
4472 .collect();
4473 let over_bound = session
4474 .execute_trusted_dynamic_mutation_batch(requests)
4475 .expect_err("operation cap plus one must reject before row resolution");
4476 assert!(matches!(
4477 over_bound.diagnostic().detail(),
4478 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4479 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
4480 }),
4481 ));
4482 assert_eq!(
4483 over_bound.diagnostic_facts(),
4484 vec![
4485 (
4486 icydb_diagnostic_code::DiagnosticFactTag::ActualCount,
4487 (MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1) as u64,
4488 ),
4489 (
4490 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4491 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS as u64,
4492 ),
4493 ],
4494 );
4495 }
4496
4497 #[test]
4498 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
4499 let mut exact = 0;
4500 add_structural_mutation_staged_bytes(
4501 &mut exact,
4502 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
4503 )
4504 .expect("the exact staged-byte boundary should admit");
4505 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
4506
4507 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
4508 .expect_err("one byte above the staged-byte boundary must reject");
4509 assert!(matches!(
4510 error.diagnostic().detail(),
4511 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4512 boundary:
4513 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
4514 }),
4515 ));
4516 assert_eq!(
4517 error.diagnostic_facts(),
4518 vec![
4519 (
4520 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4521 (MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1) as u64,
4522 ),
4523 (
4524 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4525 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES as u64,
4526 ),
4527 ],
4528 );
4529
4530 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
4531 .expect("the exact result-byte boundary should admit");
4532 let error = validate_structural_mutation_result_bytes(
4533 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
4534 )
4535 .expect_err("one byte above the result-byte boundary must reject");
4536 assert!(matches!(
4537 error.diagnostic().detail(),
4538 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
4539 boundary:
4540 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
4541 }),
4542 ));
4543 assert_eq!(
4544 error.diagnostic_facts(),
4545 vec![
4546 (
4547 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
4548 (MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1) as u64,
4549 ),
4550 (
4551 icydb_diagnostic_code::DiagnosticFactTag::Limit,
4552 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES as u64,
4553 ),
4554 ],
4555 );
4556 }
4557
4558 #[expect(
4559 clippy::too_many_lines,
4560 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
4561 )]
4562 #[test]
4563 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
4564 let session = initialize();
4565 let catalog = session
4566 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4567 .expect("identity catalog should resolve");
4568 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4569 .expect("identity row layout should build");
4570 let initial_description = session
4571 .try_describe_entity_by_name(ENTITY_NAME)
4572 .expect("accepted Identity description should resolve");
4573 assert_eq!(
4574 initial_description.entity_tag(),
4575 catalog.identity().entity_tag().value()
4576 );
4577 assert_eq!(
4578 initial_description.accepted_schema_fingerprint_method(),
4579 catalog.fingerprint_method_version()
4580 );
4581 assert_eq!(
4582 initial_description.accepted_schema_fingerprint(),
4583 catalog.fingerprint()
4584 );
4585 let initial_identity = initial_description
4586 .identity()
4587 .expect("accepted Identity policy should be described");
4588 assert_eq!(initial_identity.field(), "id");
4589 assert_eq!(initial_identity.generator(), "Identity::next");
4590 assert_eq!(initial_identity.accepted_kind(), "nat64");
4591 assert_eq!(initial_identity.minimum(), 1);
4592 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
4593 assert_eq!(initial_identity.high_water(), 0);
4594 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
4595 assert!(!initial_identity.exhausted());
4596
4597 let rejected = session
4598 .execute_accepted_structural_save_batch(
4599 &catalog,
4600 &descriptor,
4601 batch(&[1_000, 2_000]),
4602 Timestamp::from_millis(6),
4603 |_| Err::<(), _>(InternalError::executor_unsupported()),
4604 )
4605 .expect_err("a rejected precommit result must not publish its tentative range");
4606 assert_eq!(rejected.class(), ErrorClass::Unsupported);
4607 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
4608
4609 let rows = session
4610 .execute_accepted_structural_save_batch(
4611 &catalog,
4612 &descriptor,
4613 batch(&[10, 20, 30]),
4614 Timestamp::from_millis(7),
4615 Ok,
4616 )
4617 .expect("one accepted batch should commit rows and one identity range");
4618 assert_eq!(
4619 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
4620 vec![
4621 vec![Value::Nat64(1), Value::Nat64(10)],
4622 vec![Value::Nat64(2), Value::Nat64(20)],
4623 vec![Value::Nat64(3), Value::Nat64(30)],
4624 ],
4625 );
4626
4627 let dynamic = session
4628 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4629 entity: ENTITY_NAME.to_string(),
4630 patch: DynamicStructuralPatch::new(vec![(
4631 "payload".to_string(),
4632 DynamicWriteCell::Value(InputValue::Nat64(40)),
4633 )]),
4634 })
4635 .expect("dynamic omission should commit through shared Identity generation");
4636 assert_eq!(dynamic.affected_rows, 1);
4637
4638 for (request, operation) in [
4639 (
4640 DynamicMutation::Insert {
4641 entity: ENTITY_NAME.to_string(),
4642 patch: DynamicStructuralPatch::new(vec![
4643 (
4644 "id".to_string(),
4645 DynamicWriteCell::Value(InputValue::Nat64(41)),
4646 ),
4647 (
4648 "payload".to_string(),
4649 DynamicWriteCell::Value(InputValue::Nat64(42)),
4650 ),
4651 ]),
4652 },
4653 icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
4654 ),
4655 (
4656 DynamicMutation::Update {
4657 entity: ENTITY_NAME.to_string(),
4658 key: InputValue::Nat64(1),
4659 patch: DynamicStructuralPatch::new(vec![(
4660 "id".to_string(),
4661 DynamicWriteCell::Default,
4662 )]),
4663 },
4664 icydb_diagnostic_code::DiagnosticMutationOperation::Update,
4665 ),
4666 ] {
4667 let error = session
4668 .execute_trusted_dynamic_mutation(&request)
4669 .expect_err("structural Identity authorship and regeneration must reject");
4670 assert_eq!(error.class(), ErrorClass::Unsupported);
4671 assert_eq!(error.origin(), ErrorOrigin::Executor);
4672 assert_eq!(
4673 error.diagnostic_facts(),
4674 vec![
4675 (
4676 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4677 ENTITY_TAG.value(),
4678 ),
4679 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
4680 (
4681 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4682 operation.raw(),
4683 ),
4684 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
4685 ],
4686 );
4687 }
4688
4689 let binding = session
4690 .issue_typed_entity_binding(
4691 ENTITY_SOURCE,
4692 &[
4693 DynamicTypedFieldBindingRequest::new(
4694 ID_SOURCE.to_string(),
4695 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4696 false,
4697 ),
4698 DynamicTypedFieldBindingRequest::new(
4699 PAYLOAD_SOURCE.to_string(),
4700 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
4701 false,
4702 ),
4703 ],
4704 )
4705 .expect("typed output should bind the Identity field");
4706 let typed_patch = binding
4707 .bind_write_fields(vec![(
4708 PAYLOAD_SOURCE.to_string(),
4709 DynamicWriteCell::Value(InputValue::Nat64(50)),
4710 )])
4711 .expect("typed payload should lower");
4712 let typed = session
4713 .execute_trusted_typed_mutation(
4714 &binding,
4715 &DynamicTypedMutation::Insert { patch: typed_patch },
4716 )
4717 .expect("typed omission should commit through shared Identity generation");
4718 assert_eq!(
4719 typed
4720 .expect("typed insert should return one mutation result")
4721 .affected_rows,
4722 1,
4723 );
4724 let explicit_typed_patch = binding
4725 .bind_write_fields(vec![
4726 (
4727 ID_SOURCE.to_string(),
4728 DynamicWriteCell::Value(InputValue::Nat64(51)),
4729 ),
4730 (
4731 PAYLOAD_SOURCE.to_string(),
4732 DynamicWriteCell::Value(InputValue::Nat64(52)),
4733 ),
4734 ])
4735 .expect("the low-level binding should retain exact authored intent");
4736 let explicit_typed_error = session
4737 .execute_trusted_typed_mutation(
4738 &binding,
4739 &DynamicTypedMutation::Insert {
4740 patch: explicit_typed_patch,
4741 },
4742 )
4743 .expect_err("typed Identity authorship must reject before allocation");
4744 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
4745 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
4746 assert_eq!(
4747 explicit_typed_error.diagnostic_facts(),
4748 vec![
4749 (
4750 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
4751 ENTITY_TAG.value(),
4752 ),
4753 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
4754 (
4755 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
4756 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
4757 ),
4758 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,),
4759 ],
4760 );
4761
4762 let replace_error = session
4763 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
4764 entity: ENTITY_NAME.to_string(),
4765 key: InputValue::Nat64(99),
4766 patch: DynamicStructuralPatch::new(vec![(
4767 "payload".to_string(),
4768 DynamicWriteCell::Value(InputValue::Nat64(60)),
4769 )]),
4770 })
4771 .expect_err("save-as-insert with a chosen Identity must reject");
4772 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
4773 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
4774
4775 #[cfg(feature = "sql")]
4776 {
4777 for sql in [
4778 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
4779 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
4780 ] {
4781 let _result = session
4782 .execute_trusted_sql_mutation(sql)
4783 .expect("SQL omission and DEFAULT should commit Identity generation");
4784 }
4785
4786 let error = session
4787 .execute_trusted_sql_mutation(
4788 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
4789 )
4790 .expect_err("an explicit SQL Identity value must reject before allocation");
4791 let diagnostic = error.diagnostic();
4792 assert_eq!(
4793 diagnostic.code(),
4794 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
4795 );
4796 assert!(matches!(
4797 diagnostic.detail(),
4798 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
4799 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
4800 }),
4801 ));
4802 }
4803
4804 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
4805 assert_eq!(
4806 DATA_STORE.with(|store| store.borrow().len()),
4807 expected_committed
4808 );
4809 SCHEMA_STORE.with(|store| {
4810 let cursor = store
4811 .borrow()
4812 .identity_statement_cursor(
4813 database_incarnation_id().expect("database incarnation should remain readable"),
4814 ENTITY_TAG,
4815 FieldId::new(1),
4816 &AcceptedFieldKind::Nat64,
4817 )
4818 .expect("committed writes must leave active state readable");
4819 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
4820 assert!(!cursor.has_allocations());
4821 });
4822 let committed_description = session
4823 .try_describe_entity_by_name(ENTITY_NAME)
4824 .expect("committed Identity description should resolve");
4825 let committed_identity = committed_description
4826 .identity()
4827 .expect("accepted Identity policy should remain described");
4828 assert_eq!(
4829 committed_identity.high_water(),
4830 u128::from(expected_committed),
4831 );
4832 assert_eq!(
4833 committed_identity.remaining(),
4834 u128::from(u64::MAX - expected_committed),
4835 );
4836 assert!(!committed_identity.exhausted());
4837 }
4838
4839 #[test]
4840 #[expect(
4841 clippy::too_many_lines,
4842 reason = "one ordered scenario proves target/progress atomicity, every interruption wake-up, state-only admission, and successful no-op wake-up behavior"
4843 )]
4844 fn mutation_progress_and_target_rows_recover_as_one_marker_transition() {
4845 let session = initialize_journaled();
4846 install_startup_recovery_wakeup(record_startup_wakeup);
4847 let catalog = session
4848 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4849 .expect("journaled atomic-progress catalog should resolve");
4850 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4851 .expect("journaled atomic-progress row layout should build");
4852
4853 for (ordinal, interruption) in [
4854 MutationCommitInterruption::MarkerPersisted,
4855 MutationCommitInterruption::JournalPublished,
4856 MutationCommitInterruption::RowsPublished,
4857 MutationCommitInterruption::ProgressReplaced,
4858 ]
4859 .into_iter()
4860 .enumerate()
4861 {
4862 let identity_byte = 31 + u8::try_from(ordinal).expect("small ordinal should fit");
4863 let (before, after, operation) = atomic_progress_fixture(identity_byte);
4864 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4865 match store.insert_mutation(&before)? {
4866 InsertMutationJobResult::Inserted => Ok(()),
4867 InsertMutationJobResult::Occupied(_) => {
4868 Err(crate::db::MutationJobError::IdentityConflict)
4869 }
4870 }
4871 })
4872 .expect("atomic predecessor should insert once");
4873
4874 let wakeups_before = STARTUP_WAKEUPS.with(Cell::get);
4875 interrupt_next_mutation_commit_for_tests(interruption);
4876 let interrupted = session.execute_accepted_structural_update_with_mutation_progress(
4877 &catalog,
4878 &descriptor,
4879 batch(&[700 + u64::try_from(ordinal).expect("small ordinal should fit")]),
4880 Timestamp::from_millis(17),
4881 operation,
4882 );
4883 assert!(
4884 interrupted.is_err(),
4885 "selected atomic boundary should interrupt"
4886 );
4887 assert_eq!(
4888 STARTUP_WAKEUPS.with(Cell::get),
4889 wakeups_before.saturating_add(1),
4890 "a normally returned retained-marker error must register its wake-up",
4891 );
4892
4893 forget_recovered_domain_for_tests(&session.db)
4894 .expect("interruption should reset volatile recovery ownership");
4895 let retained_before =
4896 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4897 store.load_mutation(before.state().job_id)
4898 })
4899 .expect("pre-driver progress should load");
4900 let row_count_before = JOURNALED_DATA_STORE.with(|store| store.borrow().len());
4901 let pending = session
4902 .db
4903 .ensure_recovered_state()
4904 .expect_err("ordinary admission must not drive retained-marker recovery");
4905 assert_eq!(
4906 pending.diagnostic().error_code(),
4907 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
4908 );
4909 assert_eq!(
4910 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4911 store.load_mutation(before.state().job_id)
4912 })
4913 .expect("post-admission progress should load"),
4914 retained_before,
4915 );
4916 assert_eq!(
4917 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
4918 row_count_before,
4919 "state-only admission must not mutate target rows",
4920 );
4921 assert!(
4922 session
4923 .db
4924 .drive_startup_recovery_page()
4925 .expect("dedicated driver should finish target and progress together"),
4926 );
4927 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4928 store.load_mutation(before.state().job_id)
4929 })
4930 .expect("recovered successor should load");
4931 assert_eq!(retained, after);
4932 assert_eq!(
4933 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
4934 u64::try_from(ordinal + 1).expect("small row count should fit"),
4935 );
4936 }
4937
4938 let (before, after, operation) = atomic_progress_fixture(39);
4939 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4940 match store.insert_mutation(&before)? {
4941 InsertMutationJobResult::Inserted => Ok(()),
4942 InsertMutationJobResult::Occupied(_) => {
4943 Err(crate::db::MutationJobError::IdentityConflict)
4944 }
4945 }
4946 })
4947 .expect("final predecessor should insert once");
4948 let wakeups_before_success = STARTUP_WAKEUPS.with(Cell::get);
4949 session
4950 .execute_accepted_structural_update_with_mutation_progress(
4951 &catalog,
4952 &descriptor,
4953 batch(&[799]),
4954 Timestamp::from_millis(18),
4955 operation,
4956 )
4957 .expect("uninterrupted atomic transition should clear its marker");
4958 assert_eq!(
4959 STARTUP_WAKEUPS.with(Cell::get),
4960 wakeups_before_success,
4961 "a successful commit must not schedule recovery work",
4962 );
4963 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4964 store.load_mutation(before.state().job_id)
4965 })
4966 .expect("final successor should load");
4967 assert_eq!(retained, after);
4968 forget_recovered_domain_for_tests(&session.db)
4969 .expect("post-clear recovery ownership should reset");
4970 let pending = session
4971 .db
4972 .ensure_recovered_state()
4973 .expect_err("an upgrade epoch must remain gated until its driver runs");
4974 assert_eq!(
4975 pending.diagnostic().error_code(),
4976 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
4977 );
4978 assert!(
4979 session
4980 .db
4981 .drive_startup_recovery_page()
4982 .expect("post-clear driver recovery should remain a no-op"),
4983 );
4984 }
4985
4986 #[test]
4987 fn mutation_progress_neither_side_mismatch_blocks_recovery() {
4988 let session = initialize_journaled();
4989 let catalog = session
4990 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
4991 .expect("journaled corruption catalog should resolve");
4992 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
4993 .expect("journaled corruption row layout should build");
4994 let (before, _after, operation) = atomic_progress_fixture(41);
4995 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
4996 match store.insert_mutation(&before)? {
4997 InsertMutationJobResult::Inserted => Ok(()),
4998 InsertMutationJobResult::Occupied(_) => {
4999 Err(crate::db::MutationJobError::IdentityConflict)
5000 }
5001 }
5002 })
5003 .expect("corruption predecessor should insert once");
5004
5005 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::MarkerPersisted);
5006 assert!(
5007 session
5008 .execute_accepted_structural_update_with_mutation_progress(
5009 &catalog,
5010 &descriptor,
5011 batch(&[811]),
5012 Timestamp::from_millis(19),
5013 operation,
5014 )
5015 .is_err(),
5016 "marker interruption should retain recovery authority",
5017 );
5018 let (unexpected, _) = before
5019 .apply_transition(
5020 &MutationJobAdvanceRequest::new(
5021 before.state().job_id,
5022 0,
5023 MutationJobIdempotencyKey::new("unexpected-third-state")
5024 .expect("unexpected replay key should admit"),
5025 ),
5026 MutationJobTransition::new(
5027 MutationJobStatus::Active,
5028 MutationJobPhase::Forward,
5029 vec![99],
5030 2,
5031 0,
5032 0,
5033 ),
5034 )
5035 .expect("unexpected but valid progress state should admit");
5036 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5037 store.replace_mutation(&unexpected)
5038 })
5039 .expect("test should install the neither-side state");
5040
5041 forget_recovered_domain_for_tests(&session.db)
5042 .expect("corrupt recovery ownership should reset");
5043 let error = session
5044 .db
5045 .drive_startup_recovery_page()
5046 .expect_err("neither-side progress must block recovery");
5047 assert_eq!(error.class(), ErrorClass::Corruption);
5048 assert_eq!(error.origin(), ErrorOrigin::Recovery);
5049 assert_eq!(
5050 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
5051 store.load_mutation(before.state().job_id)
5052 })
5053 .expect("unexpected state should remain inspectable to the test"),
5054 unexpected,
5055 );
5056 assert!(
5057 session.db.drive_startup_recovery_page().is_err(),
5058 "a retained corrupt marker must continue blocking database access",
5059 );
5060 }
5061
5062 #[test]
5063 #[expect(
5064 clippy::too_many_lines,
5065 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
5066 )]
5067 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
5068 let session = initialize_journaled();
5069 let catalog = session
5070 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5071 .expect("journaled identity catalog should resolve");
5072 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5073 .expect("journaled identity row layout should build");
5074
5075 for (ordinal, interruption) in [
5076 MutationCommitInterruption::MarkerPersisted,
5077 MutationCommitInterruption::JournalPublished,
5078 MutationCommitInterruption::RowsPublished,
5079 MutationCommitInterruption::StateMaterialized,
5080 ]
5081 .into_iter()
5082 .enumerate()
5083 {
5084 interrupt_next_mutation_commit_for_tests(interruption);
5085 let interrupted = session.execute_accepted_structural_save_batch(
5086 &catalog,
5087 &descriptor,
5088 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
5089 Timestamp::from_millis(8),
5090 Ok,
5091 );
5092 assert!(
5093 interrupted.is_err(),
5094 "the selected durable boundary should interrupt",
5095 );
5096
5097 let Err(pending) = session.execute_accepted_structural_save_batch(
5098 &catalog,
5099 &descriptor,
5100 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
5101 Timestamp::from_millis(9),
5102 Ok,
5103 ) else {
5104 panic!("ordinary mutation must not drive retained-marker recovery");
5105 };
5106 assert_eq!(
5107 pending.diagnostic().error_code(),
5108 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5109 );
5110 assert!(
5111 session
5112 .db
5113 .drive_startup_recovery_page()
5114 .expect("dedicated driver should recover before allocation"),
5115 );
5116
5117 let committed = session
5118 .execute_accepted_structural_save_batch(
5119 &catalog,
5120 &descriptor,
5121 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
5122 Timestamp::from_millis(9),
5123 Ok,
5124 )
5125 .expect("the next mutation must recover before allocating");
5126 let expected_high_water =
5127 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
5128 assert_eq!(
5129 committed
5130 .into_iter()
5131 .map(|row| row.values)
5132 .collect::<Vec<_>>(),
5133 vec![vec![
5134 Value::Nat64(expected_high_water),
5135 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
5136 ]],
5137 );
5138 assert_eq!(
5139 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
5140 expected_high_water,
5141 );
5142 JOURNALED_SCHEMA_STORE.with(|store| {
5143 let cursor = store
5144 .borrow()
5145 .identity_statement_cursor(
5146 database_incarnation_id()
5147 .expect("database incarnation should remain readable"),
5148 ENTITY_TAG,
5149 FieldId::new(1),
5150 &AcceptedFieldKind::Nat64,
5151 )
5152 .expect("guarded recovery must leave quiescent active state");
5153 assert_eq!(
5154 cursor.expected_high_water(),
5155 u128::from(expected_high_water),
5156 );
5157 assert!(!cursor.has_allocations());
5158 });
5159 }
5160
5161 for (ordinal, (interruption, deleted_key)) in [
5162 (MutationCommitInterruption::MarkerPersisted, 2),
5163 (MutationCommitInterruption::JournalPublished, 4),
5164 (MutationCommitInterruption::RowPrefixPublished, 6),
5165 (MutationCommitInterruption::RowsPublished, 8),
5166 (MutationCommitInterruption::StateMaterialized, 7),
5167 ]
5168 .into_iter()
5169 .enumerate()
5170 {
5171 let expected_payload =
5172 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
5173 interrupt_next_mutation_commit_for_tests(interruption);
5174 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
5175 DynamicMutation::Update {
5176 entity: ENTITY_NAME.to_string(),
5177 key: InputValue::Nat64(1),
5178 patch: dynamic_payload_patch(expected_payload),
5179 },
5180 DynamicMutation::Delete {
5181 entity: ENTITY_NAME.to_string(),
5182 key: InputValue::Nat64(deleted_key),
5183 },
5184 ]);
5185 assert!(
5186 interrupted.is_err(),
5187 "the selected caller-key mixed publication boundary should interrupt",
5188 );
5189 let pending = session
5190 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5191 entity: ENTITY_NAME.to_string(),
5192 key: InputValue::Nat64(1),
5193 patch: dynamic_payload_patch(expected_payload),
5194 })
5195 .expect_err("ordinary update must not drive retained-marker recovery");
5196 assert_eq!(
5197 pending.diagnostic().error_code(),
5198 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
5199 );
5200 assert!(
5201 session
5202 .db
5203 .drive_startup_recovery_page()
5204 .expect("dedicated driver should complete the mixed batch"),
5205 );
5206 let recovered_update = session
5207 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
5208 entity: ENTITY_NAME.to_string(),
5209 key: InputValue::Nat64(1),
5210 patch: dynamic_payload_patch(expected_payload),
5211 })
5212 .expect("guarded reentry should complete the marker-authorized mixed batch");
5213 assert_eq!(
5214 recovered_update.affected_rows, 0,
5215 "the recovered update must already expose its admitted final image",
5216 );
5217 let recovered_delete = session
5218 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
5219 entity: ENTITY_NAME.to_string(),
5220 key: InputValue::Nat64(deleted_key),
5221 })
5222 .expect_err("the recovered delete must already be materialized");
5223 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
5224 JOURNALED_SCHEMA_STORE.with(|store| {
5225 let cursor = store
5226 .borrow()
5227 .identity_statement_cursor(
5228 database_incarnation_id()
5229 .expect("database incarnation should remain readable"),
5230 ENTITY_TAG,
5231 FieldId::new(1),
5232 &AcceptedFieldKind::Nat64,
5233 )
5234 .expect("caller-key recovery must preserve active Identity state");
5235 assert_eq!(cursor.expected_high_water(), 8);
5236 assert!(!cursor.has_allocations());
5237 });
5238 }
5239
5240 forget_recovered_domain_for_tests(&session.db)
5241 .expect("the final journal tail should remain recoverable");
5242 session
5243 .db
5244 .drive_startup_recovery_page()
5245 .expect("derived rebuild must not allocate another identity");
5246
5247 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
5248 let index_generation = JOURNALED_INDEX_STORE.with(|store| store.borrow().generation());
5249 forget_recovered_domain_for_tests(&session.db)
5250 .expect("an empty-tail upgrade should reset recovery ownership");
5251 session
5252 .db
5253 .drive_startup_recovery_page()
5254 .expect("an empty-tail upgrade should admit without rebuilding stored rows or indexes");
5255 assert_eq!(
5256 JOURNALED_DATA_STORE.with(|store| store.borrow().generation()),
5257 data_generation,
5258 "empty-tail recovery must not traverse or rewrite authoritative rows",
5259 );
5260 assert_eq!(
5261 JOURNALED_INDEX_STORE.with(|store| store.borrow().generation()),
5262 index_generation,
5263 "empty-tail recovery must not clear or rebuild secondary indexes",
5264 );
5265
5266 let quick = execute_quick_integrity(&session.db, catalog.inspection_plan())
5267 .expect("quiescent Identity control inventory should be inspectable");
5268 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
5269 let row_page = execute_row_integrity_page(
5270 &session.db,
5271 catalog.inspection_plan(),
5272 PhysicalUnitCheckpoint::BeforeFirst,
5273 RowInspectionLimits::standard(),
5274 )
5275 .expect("Identity rows should remain within committed high-water");
5276 assert!(row_page.exhausted());
5277 assert!(row_page.findings().is_empty());
5278
5279 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
5280 assert!(
5281 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
5282 "derived index rebuild should restore witnesses without allocating identities",
5283 );
5284 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5285 JOURNALED_SCHEMA_STORE.with(|store| {
5286 let cursor = store
5287 .borrow()
5288 .identity_statement_cursor(
5289 database_incarnation_id().expect("database incarnation should remain readable"),
5290 ENTITY_TAG,
5291 FieldId::new(1),
5292 &AcceptedFieldKind::Nat64,
5293 )
5294 .expect("folded identity state should reopen without allocating");
5295 assert_eq!(cursor.expected_high_water(), 8);
5296 assert!(!cursor.has_allocations());
5297 });
5298 }
5299
5300 #[test]
5301 fn journaled_startup_recovery_resumes_between_complete_batches_without_reallocating_ids() {
5302 const SUBMISSION: &str = "generated/8899aabbccddeeff";
5303 let session = initialize_journaled();
5304 let catalog = session
5305 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5306 .expect("journaled identity catalog should resolve");
5307 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5308 .expect("journaled identity row layout should build");
5309
5310 for payload in 0_u64..129 {
5311 session
5312 .execute_accepted_structural_save_batch(
5313 &catalog,
5314 &descriptor,
5315 batch(&[payload]),
5316 Timestamp::from_millis(8),
5317 Ok,
5318 )
5319 .expect("journaled identity fixture row should commit");
5320 }
5321
5322 forget_recovered_domain_for_tests(&session.db)
5323 .expect("upgrade should reset recovery ownership");
5324 assert_eq!(
5325 drive_generated_startup_recovery_page(&session, &JOURNALED_STORE_REGISTRY, SUBMISSION,)
5326 .expect("the first bounded driver page should commit"),
5327 GeneratedStartupDriverStep::Recovering,
5328 "one page must not consume a tail larger than the production page bound",
5329 );
5330 assert!(JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5331 let mut pages = 1;
5332 loop {
5333 match drive_generated_startup_recovery_page(
5334 &session,
5335 &JOURNALED_STORE_REGISTRY,
5336 SUBMISSION,
5337 )
5338 .expect("each bounded driver page should commit")
5339 {
5340 GeneratedStartupDriverStep::Recovering => {
5341 pages += 1;
5342 assert!(pages <= 4, "the small fixture should finish promptly");
5343 }
5344 GeneratedStartupDriverStep::ApplyGeneratedSchema => break,
5345 GeneratedStartupDriverStep::Terminal => {
5346 panic!("recovery must not report terminal before schema handoff")
5347 }
5348 }
5349 }
5350 assert_eq!(pages, 1);
5351
5352 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
5353 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
5354 assert_dynamic_payload(&session, 1, 0);
5355 assert_dynamic_payload(&session, 129, 128);
5356 JOURNALED_SCHEMA_STORE.with(|store| {
5357 let cursor = store
5358 .borrow()
5359 .identity_statement_cursor(
5360 database_incarnation_id().expect("database incarnation should remain readable"),
5361 ENTITY_TAG,
5362 FieldId::new(1),
5363 &AcceptedFieldKind::Nat64,
5364 )
5365 .expect("paged recovery must preserve active Identity state");
5366 assert_eq!(cursor.expected_high_water(), 129);
5367 assert!(!cursor.has_allocations());
5368 });
5369 }
5370
5371 #[test]
5372 fn journaled_startup_recovery_completes_one_large_batch_atomically() {
5373 let session = initialize_journaled();
5374 let catalog = session
5375 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5376 .expect("journaled identity catalog should resolve");
5377 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5378 .expect("journaled identity row layout should build");
5379 let payloads = (0_u64..129).collect::<Vec<_>>();
5380 session
5381 .execute_accepted_structural_save_batch(
5382 &catalog,
5383 &descriptor,
5384 batch(&payloads),
5385 Timestamp::from_millis(9),
5386 Ok,
5387 )
5388 .expect("one large journal batch should commit");
5389
5390 forget_recovered_domain_for_tests(&session.db)
5391 .expect("upgrade should reset recovery ownership");
5392 assert!(
5393 session
5394 .db
5395 .drive_startup_recovery_page()
5396 .expect("the complete batch recovery page should commit"),
5397 );
5398
5399 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
5400 JOURNALED_TAIL_STORE.with(|tail| {
5401 let tail = tail.borrow();
5402 assert!(!tail.has_stored_batch());
5403 });
5404 assert_dynamic_payload(&session, 1, 0);
5405 assert_dynamic_payload(&session, 129, 128);
5406 }
5407
5408 #[test]
5409 fn complete_batch_validation_rejects_a_late_record_before_canonical_writes() {
5410 let session = initialize_journaled();
5411 let catalog = session
5412 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5413 .expect("journaled identity catalog should resolve");
5414 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5415 .expect("journaled identity row layout should build");
5416 session
5417 .execute_accepted_structural_save_batch(
5418 &catalog,
5419 &descriptor,
5420 batch(&[7]),
5421 Timestamp::from_millis(9),
5422 Ok,
5423 )
5424 .expect("journal batch predecessor should commit");
5425
5426 JOURNALED_TAIL_STORE.with(|tail| {
5427 let mut tail = tail.borrow_mut();
5428 let original = tail
5429 .next_batch_after(JournalSequence::new(0))
5430 .expect("journal batch should decode")
5431 .expect("journal batch should exist");
5432 let mut records = original.records().to_vec();
5433 records.push(
5434 JournalRecord::schema_put(JOURNALED_STORE_PATH, vec![0xff; 8])
5435 .expect("bounded semantic corruption should build"),
5436 );
5437 let corrupted = JournalBatch::new(
5438 original.batch_id(),
5439 original.commit_marker_id(),
5440 original.journal_sequence(),
5441 records,
5442 )
5443 .expect("current corrupt batch shape should build");
5444 let encoded = encode_journal_batch(&corrupted)
5445 .expect("current corrupt batch envelope should encode");
5446 tail.clear_batches_through(original.journal_sequence());
5447 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
5448 .expect("corrupt persisted batch should replace the predecessor");
5449 });
5450
5451 forget_recovered_domain_for_tests(&session.db)
5452 .expect("upgrade should reset recovery ownership");
5453 let error = session
5454 .db
5455 .drive_startup_recovery_page()
5456 .expect_err("late semantic corruption must fail before fold apply");
5457 assert_eq!(error.class(), ErrorClass::Corruption);
5458 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
5459 JOURNALED_TAIL_STORE.with(|tail| {
5460 let tail = tail.borrow();
5461 assert_eq!(
5462 tail.fold_watermark()
5463 .expect("watermark should remain readable")
5464 .highest_folded_journal_sequence(),
5465 JournalSequence::new(0),
5466 );
5467 assert!(tail.has_stored_batch());
5468 });
5469 }
5470
5471 #[test]
5472 #[ignore = "release-closeout native timing probe for one marker-authorized driver recovery"]
5473 fn identity_recovery_closeout_reports_driver_time() {
5474 let session = initialize_journaled();
5475 let catalog = session
5476 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
5477 .expect("journaled identity catalog should resolve");
5478 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
5479 .expect("journaled identity row layout should build");
5480
5481 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
5482 let interrupted = session.execute_accepted_structural_save_batch(
5483 &catalog,
5484 &descriptor,
5485 batch(&[1]),
5486 Timestamp::from_millis(10),
5487 Ok,
5488 );
5489 assert!(
5490 interrupted.is_err(),
5491 "the selected publication boundary should interrupt",
5492 );
5493
5494 let start = Instant::now();
5495 assert!(
5496 session
5497 .db
5498 .drive_startup_recovery_page()
5499 .expect("dedicated driver should recover before allocation"),
5500 );
5501 let committed = session
5502 .execute_accepted_structural_save_batch(
5503 &catalog,
5504 &descriptor,
5505 batch(&[2]),
5506 Timestamp::from_millis(11),
5507 Ok,
5508 )
5509 .expect("post-recovery allocation should commit");
5510 let elapsed = start.elapsed();
5511 assert_eq!(
5512 committed
5513 .into_iter()
5514 .map(|row| row.values)
5515 .collect::<Vec<_>>(),
5516 vec![vec![Value::Nat64(2), Value::Nat64(2)]],
5517 );
5518
5519 println!(
5520 "identity recovery closeout: driver_nanos={}",
5521 elapsed.as_nanos(),
5522 );
5523 }
5524}
5525
5526#[cfg(test)]
5527mod targeted_rule_mutation_tests {
5528 use super::{
5529 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedFieldBindingRequest,
5530 DynamicTypedFieldType, DynamicTypedMutation, DynamicWriteCell,
5531 };
5532 use crate::{
5533 db::{
5534 data::{DataStore, encode_input_value_for_candidate_field_contract},
5535 index::IndexStore,
5536 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
5537 schema::{
5538 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
5539 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
5540 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
5541 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
5542 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
5543 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
5544 SchemaRowLayout, SchemaStore, SchemaVersion,
5545 accepted_schema_candidate_with_catalogs_for_tests,
5546 build_record_newtype_composite_catalog_for_tests,
5547 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
5548 },
5549 },
5550 error::InternalError,
5551 traits::{CanisterKind, Path},
5552 types::EntityTag,
5553 value::InputValue,
5554 };
5555 use icydb_schema::{
5556 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
5557 };
5558 use std::{cell::RefCell, collections::BTreeMap};
5559
5560 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
5561 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
5562 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
5563 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
5564 const UPDATED_AT_SOURCE: &str =
5565 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
5566 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
5567 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
5568 const DEGREE_MEMBER_SOURCE: &str =
5569 "session::write::targeted_rule_mutation_tests::Profile::degree";
5570 const DEGREE_RULE_SOURCE: &str =
5571 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
5572
5573 struct TestCanister;
5574
5575 impl Path for TestCanister {
5576 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
5577 }
5578
5579 impl CanisterKind for TestCanister {
5580 const COMMIT_MEMORY_ID: u8 = 43;
5581 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
5582 const STARTUP_MEMORY_ID: u8 = 49;
5583 const STARTUP_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.startup.control.v1";
5584 const INTEGRITY_PROGRESS_MEMORY_ID: u8 = 44;
5585 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
5586 "icydb.targeted_mutation_tests.integrity.progress.v1";
5587 }
5588
5589 thread_local! {
5590 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
5591 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
5592 static SCHEMA_STORE: RefCell<SchemaStore> =
5593 const { RefCell::new(SchemaStore::init_heap()) };
5594 static STORE_REGISTRY: StoreRegistry = {
5595 let mut registry = StoreRegistry::new();
5596 registry.register_store(
5597 STORE_PATH,
5598 &DATA_STORE,
5599 &INDEX_STORE,
5600 &SCHEMA_STORE,
5601 StoreAllocationIdentities::absent(),
5602 StoreRuntimeStorageCapabilities::heap(),
5603 ).expect("targeted mutation test store should register");
5604 registry
5605 };
5606 }
5607
5608 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
5609 parse(raw.to_string()).expect("test source identity should admit")
5610 }
5611
5612 fn profile_input(degree: u64) -> InputValue {
5613 InputValue::Map(vec![(
5614 InputValue::Text("degree".to_string()),
5615 InputValue::Nat64(degree),
5616 )])
5617 }
5618
5619 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
5620 DynamicStructuralPatch::new(vec![
5621 (
5622 "id".to_string(),
5623 DynamicWriteCell::Value(InputValue::Nat64(id)),
5624 ),
5625 (
5626 "profile".to_string(),
5627 DynamicWriteCell::Value(profile_input(degree)),
5628 ),
5629 ])
5630 }
5631
5632 fn encoded_value(
5633 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
5634 composite_catalog: &AcceptedCompositeCatalog,
5635 name: &str,
5636 kind: &AcceptedFieldKind,
5637 storage_decode: FieldStorageDecode,
5638 leaf_codec: LeafCodec,
5639 value: InputValue,
5640 ) -> Vec<u8> {
5641 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
5642 encode_input_value_for_candidate_field_contract(
5643 enum_catalog,
5644 composite_catalog,
5645 field,
5646 value,
5647 &mut ValueAdmissionBudget::standard(),
5648 )
5649 .expect("test accepted value should encode")
5650 }
5651
5652 fn nat64_literal(
5653 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
5654 composite_catalog: &AcceptedCompositeCatalog,
5655 value: u64,
5656 ) -> AcceptedCheckLiteralV1 {
5657 let kind = AcceptedFieldKind::Nat64;
5658 AcceptedCheckLiteralV1::from_accepted_parts(
5659 kind.clone(),
5660 FieldStorageDecode::ByKind,
5661 LeafCodec::Scalar(ScalarCodec::Nat64),
5662 encoded_value(
5663 enum_catalog,
5664 composite_catalog,
5665 "degree_bound",
5666 &kind,
5667 FieldStorageDecode::ByKind,
5668 LeafCodec::Scalar(ScalarCodec::Nat64),
5669 InputValue::Nat64(value),
5670 ),
5671 )
5672 }
5673
5674 fn targeted_constraint_id(error: &InternalError) -> u32 {
5675 let facts = error.diagnostic_facts();
5676 assert!(facts.contains(&(
5677 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
5678 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
5679 )));
5680 assert!(facts.contains(&(icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,)));
5681 assert!(facts.contains(&(
5682 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
5683 icydb_diagnostic_code::DiagnosticConstraintKind::TargetedRule.raw(),
5684 )));
5685 assert_eq!(
5686 facts
5687 .iter()
5688 .filter(|(tag, _)| matches!(
5689 tag,
5690 icydb_diagnostic_code::DiagnosticFactTag::RootField
5691 | icydb_diagnostic_code::DiagnosticFactTag::RecordMember
5692 ))
5693 .copied()
5694 .collect::<Vec<_>>(),
5695 vec![
5696 (icydb_diagnostic_code::DiagnosticFactTag::RootField, 2),
5697 (
5698 icydb_diagnostic_code::DiagnosticFactTag::RecordMember,
5699 icydb_diagnostic_code::pack_u32_pair(1, 1),
5700 ),
5701 ]
5702 );
5703 let value = facts
5704 .iter()
5705 .find_map(|(tag, value)| {
5706 (*tag == icydb_diagnostic_code::DiagnosticFactTag::ConstraintId).then_some(*value)
5707 })
5708 .expect("targeted mutation should retain its accepted constraint ID");
5709 u32::try_from(value).expect("accepted constraint ID fits u32")
5710 }
5711
5712 #[expect(
5713 clippy::too_many_lines,
5714 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
5715 )]
5716 #[test]
5717 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
5718 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
5719 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
5720 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
5721
5722 let entity_tag = EntityTag::new(93);
5723 let enum_catalog = empty_accepted_enum_catalog_for_tests();
5724 let (composite_catalog, profile_type, degree_type, degree_member) =
5725 build_record_newtype_composite_catalog_for_tests(
5726 "tests::TargetedProfile".to_string(),
5727 "degree".to_string(),
5728 "tests::TargetedDegree".to_string(),
5729 AcceptedFieldKind::Nat64,
5730 &enum_catalog,
5731 )
5732 .expect("targeted mutation composites should close");
5733 let profile_kind = AcceptedFieldKind::Composite {
5734 type_id: profile_type,
5735 };
5736 let profile_default = encoded_value(
5737 &enum_catalog,
5738 &composite_catalog,
5739 "profile",
5740 &profile_kind,
5741 FieldStorageDecode::CatalogValue,
5742 LeafCodec::Structural,
5743 profile_input(12),
5744 );
5745 let fields = vec![
5746 PersistedFieldSnapshot::new_initial(
5747 FieldId::new(1),
5748 "id".to_string(),
5749 SchemaFieldSlot::new(0),
5750 AcceptedFieldKind::Nat64,
5751 Vec::new(),
5752 false,
5753 SchemaInsertDefault::None,
5754 FieldStorageDecode::ByKind,
5755 LeafCodec::Scalar(ScalarCodec::Nat64),
5756 ),
5757 PersistedFieldSnapshot::new_initial(
5758 FieldId::new(2),
5759 "profile".to_string(),
5760 SchemaFieldSlot::new(1),
5761 profile_kind,
5762 vec![PersistedNestedLeafSnapshot::new(
5763 vec!["degree".to_string()],
5764 AcceptedFieldKind::Composite {
5765 type_id: degree_type,
5766 },
5767 false,
5768 )],
5769 false,
5770 SchemaInsertDefault::SlotPayload(profile_default),
5771 FieldStorageDecode::CatalogValue,
5772 LeafCodec::Structural,
5773 ),
5774 PersistedFieldSnapshot::new_initial_with_write_policy(
5775 FieldId::new(3),
5776 "updated_at".to_string(),
5777 SchemaFieldSlot::new(2),
5778 AcceptedFieldKind::Timestamp,
5779 Vec::new(),
5780 false,
5781 SchemaInsertDefault::None,
5782 SchemaFieldWritePolicy::from_model_policies(
5783 None,
5784 Some(FieldWriteManagement::UpdatedAt),
5785 ),
5786 FieldStorageDecode::ByKind,
5787 LeafCodec::Scalar(ScalarCodec::Timestamp),
5788 ),
5789 ];
5790 let mut snapshot = PersistedSchemaSnapshot::new(
5791 SchemaVersion::initial(),
5792 ENTITY_SOURCE.to_string(),
5793 "TargetedMutation".to_string(),
5794 FieldId::new(1),
5795 SchemaRowLayout::initial(
5796 fields
5797 .iter()
5798 .map(|field| (field.id(), field.slot()))
5799 .collect(),
5800 ),
5801 fields,
5802 );
5803 let constraint_catalog = snapshot
5804 .constraint_catalog()
5805 .clone()
5806 .with_added_targeted_rule(
5807 "profile_degree_multiple".to_string(),
5808 ConstraintOrigin::Generated,
5809 AcceptedRuleTarget::new(
5810 FieldId::new(2),
5811 AcceptedNamedTypeIdentity::Composite(degree_type),
5812 ),
5813 AcceptedRuleOperation::MultipleOf {
5814 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
5815 },
5816 )
5817 .expect("targeted mutation rule should allocate");
5818 let targeted_rule_id = constraint_catalog
5819 .constraints()
5820 .last()
5821 .expect("targeted mutation rule should persist")
5822 .id();
5823 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
5824
5825 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
5826 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
5827 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
5828 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
5829 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
5830 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
5831 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
5832 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
5833 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
5834 BTreeMap::from([(entity_source, entity_tag)]),
5835 BTreeMap::from([
5836 ((entity_tag, id_source), FieldId::new(1)),
5837 ((entity_tag, profile_source), FieldId::new(2)),
5838 ((entity_tag, updated_at_source), FieldId::new(3)),
5839 ]),
5840 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
5841 BTreeMap::new(),
5842 BTreeMap::new(),
5843 )
5844 .with_initial_named_types_for_tests(
5845 BTreeMap::from([
5846 (
5847 profile_type_source,
5848 AcceptedNamedTypeIdentity::Composite(profile_type),
5849 ),
5850 (
5851 degree_type_source,
5852 AcceptedNamedTypeIdentity::Composite(degree_type),
5853 ),
5854 ]),
5855 BTreeMap::new(),
5856 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
5857 );
5858 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
5859 STORE_PATH,
5860 AcceptedSchemaRevision::INITIAL,
5861 enum_catalog,
5862 composite_catalog,
5863 source_bindings,
5864 BTreeMap::from([(entity_tag, snapshot)]),
5865 );
5866
5867 let session = DbSession::<TestCanister>::new(
5868 &STORE_REGISTRY,
5869 &crate::db::RequestExecutionRoot::__new_runtime_root(),
5870 );
5871 session
5872 .db
5873 .drive_startup_recovery_page()
5874 .expect("targeted mutation test database should initialize");
5875 let store = session
5876 .db
5877 .store_handle(STORE_PATH)
5878 .expect("targeted mutation test store should resolve");
5879 crate::db::commit::publish_accepted_schema_candidate(
5880 STORE_PATH,
5881 store,
5882 AcceptedSchemaRevision::NONE,
5883 &candidate,
5884 )
5885 .expect("targeted mutation candidate should publish");
5886
5887 let dynamic_error = session
5888 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5889 entity: "TargetedMutation".to_string(),
5890 patch: structural_patch(1, 12),
5891 })
5892 .expect_err("dynamic write must enforce the targeted rule");
5893 assert_eq!(
5894 targeted_constraint_id(&dynamic_error),
5895 targeted_rule_id.get()
5896 );
5897
5898 let binding = session
5899 .issue_typed_entity_binding(
5900 ENTITY_SOURCE,
5901 &[
5902 DynamicTypedFieldBindingRequest::new(
5903 ID_SOURCE.to_string(),
5904 DynamicTypedFieldType::Scalar(ScalarType::Nat64),
5905 false,
5906 ),
5907 DynamicTypedFieldBindingRequest::new(
5908 PROFILE_SOURCE.to_string(),
5909 DynamicTypedFieldType::Named(PROFILE_TYPE_SOURCE.to_string()),
5910 false,
5911 ),
5912 DynamicTypedFieldBindingRequest::new(
5913 UPDATED_AT_SOURCE.to_string(),
5914 DynamicTypedFieldType::Scalar(ScalarType::Timestamp),
5915 false,
5916 ),
5917 ],
5918 )
5919 .expect("targeted typed binding should issue");
5920 let typed_patch = binding
5921 .bind_write_fields(vec![
5922 (
5923 ID_SOURCE.to_string(),
5924 DynamicWriteCell::Value(InputValue::Nat64(2)),
5925 ),
5926 (
5927 PROFILE_SOURCE.to_string(),
5928 DynamicWriteCell::Value(profile_input(12)),
5929 ),
5930 ])
5931 .expect("targeted typed patch should bind");
5932 let typed_error = session
5933 .execute_trusted_typed_mutation(
5934 &binding,
5935 &DynamicTypedMutation::Insert { patch: typed_patch },
5936 )
5937 .expect_err("typed write must enforce the targeted rule");
5938 assert_eq!(targeted_constraint_id(&typed_error), targeted_rule_id.get());
5939
5940 #[cfg(feature = "sql")]
5941 {
5942 let sql_error = session
5943 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
5944 .expect_err("SQL default resolution must enforce the targeted rule");
5945 let crate::db::QueryError::Execute(execute) = sql_error else {
5946 panic!("targeted SQL write should fail at shared execution admission");
5947 };
5948 assert_eq!(
5949 targeted_constraint_id(execute.as_internal()),
5950 targeted_rule_id.get()
5951 );
5952 }
5953
5954 session
5955 .execute_trusted_dynamic_mutation_batch(vec![
5956 DynamicMutation::Insert {
5957 entity: "TargetedMutation".to_string(),
5958 patch: structural_patch(4, 5),
5959 },
5960 DynamicMutation::Insert {
5961 entity: "TargetedMutation".to_string(),
5962 patch: structural_patch(5, 12),
5963 },
5964 ])
5965 .expect_err("one invalid targeted value must reject the whole batch");
5966 assert_eq!(
5967 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
5968 Some(0),
5969 "no frontend or earlier valid batch row may escape targeted admission",
5970 );
5971
5972 let admitted = session
5973 .execute_trusted_dynamic_mutation_batch(vec![
5974 DynamicMutation::Insert {
5975 entity: "TargetedMutation".to_string(),
5976 patch: structural_patch(6, 5),
5977 },
5978 DynamicMutation::Insert {
5979 entity: "TargetedMutation".to_string(),
5980 patch: structural_patch(7, 10),
5981 },
5982 ])
5983 .expect("compliant targeted values should share one accepted batch");
5984 let [first, second] = admitted.rows.as_slice() else {
5985 panic!("the mixed targeted batch should return two rows");
5986 };
5987 let first_timestamp = first
5988 .get(2)
5989 .expect("the first mixed row should contain its managed timestamp");
5990 assert!(matches!(
5991 first_timestamp,
5992 crate::value::OutputValue::Timestamp(_)
5993 ));
5994 assert_eq!(
5995 second.get(2),
5996 Some(first_timestamp),
5997 "one accepted mixed batch must materialize one managed timestamp",
5998 );
5999 assert_eq!(
6000 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
6001 Some(2),
6002 );
6003 }
6004}