1#[cfg(test)]
8mod key_handoff_tests;
9#[cfg(test)]
10mod output_handoff_tests;
11
12use super::AcceptedSchemaCatalogContext;
13use crate::{
14 db::{
15 DbSession, DynamicMutation, DynamicMutationResult, DynamicStructuralPatch,
16 DynamicTypedBindingError, DynamicTypedEntityBinding, DynamicTypedMutation,
17 DynamicTypedStructuralPatch, DynamicWriteCell, TypedEntityDescriptor, TypedFieldType,
18 commit::{CommitRowOp, database_incarnation_id},
19 data::{
20 AcceptedMutationIntentPatch, AcceptedPreKeyInsert, DecodedDataStoreKey, FieldSlot,
21 RawRow, StructuralRowContract, StructuralSlotReader,
22 canonical_row_from_raw_row_with_accepted_decode_contract,
23 resolve_existing_replace_structural_patch_with_accepted_contract,
24 resolve_insert_structural_patch_with_accepted_contract,
25 resolve_update_structural_patch_with_accepted_contract,
26 },
27 executor::{
28 AcceptedMutationConstraintContext, AcceptedMutationConstraintScheduler,
29 budget::finish_current_execution_instruction_watermark,
30 commit_structural_row_ops_with_mutation_progress,
31 commit_structural_row_ops_with_window, mutation_key_exists_error,
32 },
33 integrity::MutationProgressRecordOp,
34 schema::{
35 AcceptedFieldKind, AcceptedIdentityAllocation, AcceptedRowLayoutRuntimeContract,
36 AcceptedRowLayoutRuntimeField, FieldId, FieldInsertGeneration, IdentityStatementCursor,
37 lower_field_type, output_value_from_runtime,
38 },
39 write_context::{AcceptedWriteContext, MutationMode},
40 },
41 error::{InternalError, MutationDiagnosticContext},
42 metrics::EntityMetricsSpan,
43 traits::CanisterKind,
44 types::{CurrentTimestamp, Timestamp},
45 value::{InputValue, Value},
46};
47use icydb_schema::{
48 EntitySourceKey, FieldSourceKey, FieldType, SchemaContractError, TypeSourceKey,
49};
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52struct AcceptedIdentityInsertField {
53 field_id: FieldId,
54 field_slot: usize,
55 accepted_kind: AcceptedFieldKind,
56}
57
58struct AcceptedStructuralMutationCommitOptions {
59 capture_output_values: bool,
60 packing: AcceptedStructuralMutationPacking,
61}
62
63impl AcceptedStructuralMutationCommitOptions {
64 const fn standard(capture_output_values: bool) -> Self {
65 Self {
66 capture_output_values,
67 packing: AcceptedStructuralMutationPacking::Complete,
68 }
69 }
70
71 #[cfg(test)]
72 const fn with_mutation_progress() -> Self {
73 Self {
74 capture_output_values: false,
75 packing: AcceptedStructuralMutationPacking::Complete,
76 }
77 }
78
79 const fn bounded_prefix() -> Self {
80 Self {
81 capture_output_values: false,
82 packing: AcceptedStructuralMutationPacking::BoundedPrefix,
83 }
84 }
85}
86
87#[derive(Clone, Copy)]
88enum AcceptedStructuralMutationPacking {
89 Complete,
90 BoundedPrefix,
91}
92
93pub(in crate::db::session) enum AcceptedStructuralMutationCommitDirective {
94 Standard,
95 WithMutationProgress(MutationProgressRecordOp),
96 Skip,
97}
98
99pub(in crate::db::session) enum AcceptedStructuralMutationTarget {
102 ResolveFromAfterImage,
103 Expected(Box<DecodedDataStoreKey>),
104 ExpectedLoaded(AcceptedLoadedStructuralRow),
105}
106
107pub(in crate::db::session) struct AcceptedLoadedStructuralRow {
110 key: Box<DecodedDataStoreKey>,
111 row: RawRow,
112}
113
114impl AcceptedLoadedStructuralRow {
115 pub(in crate::db::session) fn from_validated_parts(
116 key: DecodedDataStoreKey,
117 row: RawRow,
118 ) -> Self {
119 Self {
120 key: Box::new(key),
121 row,
122 }
123 }
124
125 fn into_parts(self) -> (DecodedDataStoreKey, RawRow) {
126 (*self.key, self.row)
127 }
128}
129
130impl AcceptedStructuralMutationTarget {
131 pub(in crate::db::session) fn expected(key: DecodedDataStoreKey) -> Self {
132 Self::Expected(Box::new(key))
133 }
134
135 pub(in crate::db::session) const fn expected_loaded(row: AcceptedLoadedStructuralRow) -> Self {
138 Self::ExpectedLoaded(row)
139 }
140}
141
142pub(in crate::db::session) enum AcceptedStructuralMutation {
145 Save {
146 mode: MutationMode,
147 target: AcceptedStructuralMutationTarget,
148 patch: AcceptedMutationIntentPatch,
149 },
150 Delete {
151 key: Box<DecodedDataStoreKey>,
152 },
153}
154
155impl AcceptedStructuralMutation {
156 pub(in crate::db::session) const fn save(
157 mode: MutationMode,
158 target: AcceptedStructuralMutationTarget,
159 patch: AcceptedMutationIntentPatch,
160 ) -> Self {
161 Self::Save {
162 mode,
163 target,
164 patch,
165 }
166 }
167
168 pub(in crate::db::session) fn delete(key: DecodedDataStoreKey) -> Self {
169 Self::Delete { key: Box::new(key) }
170 }
171}
172
173const MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS: usize = 4_096;
174const MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES: usize = 64;
175pub(in crate::db::session) const STRUCTURAL_MUTATION_BATCH_STAGED_BYTES_POLICY: u32 =
176 16 * 1024 * 1024;
177pub(in crate::db::session) const MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES: usize =
178 STRUCTURAL_MUTATION_BATCH_STAGED_BYTES_POLICY as usize;
179const MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES: usize = 1024 * 1024;
180
181struct AcceptedStructuralMutationBatchItem {
182 catalog: AcceptedSchemaCatalogContext,
183 mutation: AcceptedStructuralMutation,
184}
185
186struct AcceptedStructuralMutationEntityState {
187 entity_tag: crate::types::EntityTag,
188 identity_field: Option<AcceptedIdentityInsertField>,
189 identity_incarnation: Option<crate::db::integrity::DatabaseIncarnationId>,
190 identity_cursor: Option<IdentityStatementCursor>,
191 identity_insert_ordinal: u32,
192}
193
194#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195pub(in crate::db::session) struct AcceptedStructuralMutationPackingReport {
196 admitted_mutations: usize,
197 staged_bytes: usize,
198 stopped_before_candidate: bool,
199 candidate_exceeds_batch_policy: bool,
200}
201
202impl AcceptedStructuralMutationPackingReport {
203 #[must_use]
204 pub(in crate::db::session) const fn admitted_mutations(self) -> usize {
205 self.admitted_mutations
206 }
207
208 #[must_use]
209 pub(in crate::db::session) const fn stopped_before_candidate(self) -> bool {
210 self.stopped_before_candidate
211 }
212
213 #[must_use]
214 pub(in crate::db::session) const fn candidate_exceeds_batch_policy(self) -> bool {
215 self.candidate_exceeds_batch_policy
216 }
217}
218
219fn structural_mutation_staged_charge(
220 lengths: impl IntoIterator<Item = usize>,
221) -> Result<usize, InternalError> {
222 lengths.into_iter().try_fold(0_usize, |total, length| {
223 total.checked_add(length).ok_or_else(|| {
224 InternalError::mutation_batch_staged_bytes_exceeded(
225 None,
226 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
227 )
228 })
229 })
230}
231
232fn add_structural_mutation_staged_bytes(
233 total: &mut usize,
234 lengths: impl IntoIterator<Item = usize>,
235) -> Result<(), InternalError> {
236 let charge = structural_mutation_staged_charge(lengths)?;
237 *total = total.checked_add(charge).ok_or_else(|| {
238 InternalError::mutation_batch_staged_bytes_exceeded(
239 None,
240 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
241 )
242 })?;
243 if *total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
244 return Err(InternalError::mutation_batch_staged_bytes_exceeded(
245 Some(*total),
246 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES,
247 ));
248 }
249 Ok(())
250}
251
252fn admit_structural_mutation_staged_charge(
253 total: &mut usize,
254 lengths: impl IntoIterator<Item = usize>,
255 packing: AcceptedStructuralMutationPacking,
256) -> Result<AcceptedStructuralMutationStagedAdmission, InternalError> {
257 if matches!(packing, AcceptedStructuralMutationPacking::Complete) {
258 add_structural_mutation_staged_bytes(total, lengths)?;
259 return Ok(AcceptedStructuralMutationStagedAdmission::Admitted);
260 }
261
262 let charge = structural_mutation_staged_charge(lengths)?;
263 if charge > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
264 return Ok(AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy);
265 }
266 let Some(next_total) = total.checked_add(charge) else {
267 return Ok(AcceptedStructuralMutationStagedAdmission::PageFull);
268 };
269 if next_total > MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES {
270 return Ok(AcceptedStructuralMutationStagedAdmission::PageFull);
271 }
272 *total = next_total;
273 Ok(AcceptedStructuralMutationStagedAdmission::Admitted)
274}
275
276#[derive(Clone, Copy, Debug, Eq, PartialEq)]
277enum AcceptedStructuralMutationStagedAdmission {
278 Admitted,
279 PageFull,
280 CandidateExceedsPolicy,
281}
282
283fn validate_structural_mutation_result_bytes(encoded_bytes: usize) -> Result<(), InternalError> {
284 if encoded_bytes > MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES {
285 return Err(InternalError::mutation_batch_result_bytes_exceeded(
286 encoded_bytes,
287 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES,
288 ));
289 }
290 Ok(())
291}
292
293pub(in crate::db::session) struct AcceptedStructuralMutationRow {
295 values: Vec<Value>,
296 logical_changed: bool,
297}
298
299impl AcceptedStructuralMutationRow {
300 #[cfg(any(feature = "sql", test))]
301 pub(in crate::db::session) fn into_values(self) -> Vec<Value> {
302 self.values
303 }
304
305 pub(in crate::db::session) const fn logical_changed(&self) -> bool {
306 self.logical_changed
307 }
308}
309
310fn mutation_diagnostic_context(
311 catalog: &AcceptedSchemaCatalogContext,
312 mode: MutationMode,
313 batch_position: u32,
314) -> MutationDiagnosticContext {
315 MutationDiagnosticContext::new(
316 catalog.fingerprint_method_version(),
317 catalog.fingerprint(),
318 catalog.identity().entity_tag().value(),
319 mode.diagnostic_operation(),
320 batch_position,
321 )
322}
323
324const fn dynamic_write_context(operation_timestamp: Timestamp) -> AcceptedWriteContext {
325 AcceptedWriteContext::new(operation_timestamp)
326}
327
328fn insert_key_exists_after_generation(identity_generated: bool) -> InternalError {
329 if identity_generated {
330 InternalError::identity_state_corruption()
331 } else {
332 mutation_key_exists_error()
333 }
334}
335
336fn dynamic_key(
337 entity_tag: crate::types::EntityTag,
338 key: InputValue,
339) -> Result<DecodedDataStoreKey, InternalError> {
340 let value = key
341 .try_into_runtime_non_enum()
342 .ok_or_else(InternalError::executor_unsupported)?;
343 DecodedDataStoreKey::try_from_structural_key(entity_tag, &value)
344}
345
346fn lower_resolved_write_cell(
347 lowered: AcceptedMutationIntentPatch,
348 field: &AcceptedRowLayoutRuntimeField<'_>,
349 cell: DynamicWriteCell,
350 mode: MutationMode,
351 mutation_context: MutationDiagnosticContext,
352) -> Result<AcceptedMutationIntentPatch, InternalError> {
353 if !matches!(cell, DynamicWriteCell::Omitted)
354 && (field.write_policy().insert_generation().is_some()
355 || field.write_policy().write_management().is_some())
356 {
357 return Err(InternalError::mutation_database_owned_field_explicit(
358 mutation_context,
359 field.field_id().get(),
360 ));
361 }
362
363 let slot = FieldSlot::from_validated_index(usize::from(field.slot().get()));
364 Ok(match cell {
365 DynamicWriteCell::Omitted => lowered,
366 DynamicWriteCell::Default => match mode {
367 MutationMode::Insert | MutationMode::Replace => {
368 lowered.set_explicit_insert_default(slot)
369 }
370 MutationMode::Update => lowered.set_explicit_update_default(slot),
371 },
372 DynamicWriteCell::Null => lowered.set_authored(slot, InputValue::null()),
373 DynamicWriteCell::Value(value) => lowered.set_authored(slot, value),
374 })
375}
376
377fn lower_dynamic_patch(
378 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
379 patch: DynamicStructuralPatch,
380 mode: MutationMode,
381 mutation_context: MutationDiagnosticContext,
382) -> Result<AcceptedMutationIntentPatch, InternalError> {
383 let mut lowered = AcceptedMutationIntentPatch::new();
384 for (field_name, cell) in patch.into_fields() {
385 let slot = descriptor
386 .field_slot_index_by_name(&field_name)
387 .ok_or_else(InternalError::executor_unsupported)?;
388 let field = descriptor
389 .field_for_slot_index(slot)
390 .ok_or_else(InternalError::executor_invariant)?;
391 lowered = lower_resolved_write_cell(lowered, field, cell, mode, mutation_context)?;
392 }
393 Ok(lowered)
394}
395
396fn lower_dynamic_save_intent(
397 catalog: &AcceptedSchemaCatalogContext,
398 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
399 patch: DynamicStructuralPatch,
400 mode: MutationMode,
401 target: AcceptedStructuralMutationTarget,
402 batch_position: u32,
403) -> Result<AcceptedStructuralMutation, InternalError> {
404 Ok(AcceptedStructuralMutation::save(
405 mode,
406 target,
407 lower_dynamic_patch(
408 descriptor,
409 patch,
410 mode,
411 mutation_diagnostic_context(catalog, mode, batch_position),
412 )?,
413 ))
414}
415
416fn lower_dynamic_mutation_intent(
417 catalog: &AcceptedSchemaCatalogContext,
418 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
419 request: DynamicMutation,
420 batch_position: u32,
421) -> Result<AcceptedStructuralMutation, InternalError> {
422 let entity_tag = catalog.identity().entity_tag();
423 match request {
424 DynamicMutation::Insert { patch, .. } => lower_dynamic_save_intent(
425 catalog,
426 descriptor,
427 patch,
428 MutationMode::Insert,
429 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
430 batch_position,
431 ),
432 DynamicMutation::Update { key, patch, .. } => lower_dynamic_save_intent(
433 catalog,
434 descriptor,
435 patch,
436 MutationMode::Update,
437 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
438 batch_position,
439 ),
440 DynamicMutation::Replace { key, patch, .. } => lower_dynamic_save_intent(
441 catalog,
442 descriptor,
443 patch,
444 MutationMode::Replace,
445 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
446 batch_position,
447 ),
448 DynamicMutation::Delete { key, .. } => Ok(AcceptedStructuralMutation::delete(dynamic_key(
449 entity_tag, key,
450 )?)),
451 }
452}
453
454fn lower_typed_patch(
455 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
456 binding: &DynamicTypedEntityBinding,
457 patch: DynamicTypedStructuralPatch,
458 mode: MutationMode,
459 mutation_context: MutationDiagnosticContext,
460) -> Result<AcceptedMutationIntentPatch, InternalError> {
461 let mut lowered = AcceptedMutationIntentPatch::new();
462 for (descriptor_ordinal, cell) in patch.into_fields() {
463 let (field_id, slot) = binding
464 .field_identity_binding(descriptor_ordinal)
465 .ok_or_else(InternalError::store_invariant)?;
466 let slot_index = usize::from(slot);
467 let field = descriptor
468 .field_for_slot_index(slot_index)
469 .ok_or_else(InternalError::store_invariant)?;
470 if field.field_id().get() != field_id {
471 return Err(InternalError::store_invariant());
472 }
473 lowered = lower_resolved_write_cell(lowered, field, cell, mode, mutation_context)?;
474 }
475 Ok(lowered)
476}
477
478fn lower_typed_mutation_intent(
479 catalog: &AcceptedSchemaCatalogContext,
480 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
481 binding: &DynamicTypedEntityBinding,
482 request: DynamicTypedMutation,
483 batch_position: u32,
484) -> Result<Option<AcceptedStructuralMutation>, InternalError> {
485 let entity_tag = catalog.identity().entity_tag();
486 let (mode, target, patch) = match request {
487 DynamicTypedMutation::Insert { patch } => (
488 MutationMode::Insert,
489 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
490 patch,
491 ),
492 DynamicTypedMutation::Update { key, patch } => (
493 MutationMode::Update,
494 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
495 patch,
496 ),
497 DynamicTypedMutation::Replace { key, patch } => (
498 MutationMode::Replace,
499 AcceptedStructuralMutationTarget::expected(dynamic_key(entity_tag, key)?),
500 patch,
501 ),
502 DynamicTypedMutation::Delete { key } => {
503 return Ok(Some(AcceptedStructuralMutation::delete(dynamic_key(
504 entity_tag, key,
505 )?)));
506 }
507 };
508 if !patch.is_bound_to(binding) {
509 return Ok(None);
510 }
511 let patch = lower_typed_patch(
512 descriptor,
513 binding,
514 patch,
515 mode,
516 mutation_diagnostic_context(catalog, mode, batch_position),
517 )?;
518 Ok(Some(AcceptedStructuralMutation::save(mode, target, patch)))
519}
520
521fn preserve_dynamic_replacement_identity(
522 key: &DecodedDataStoreKey,
523 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
524 mut patch: AcceptedMutationIntentPatch,
525) -> Result<AcceptedMutationIntentPatch, InternalError> {
526 let primary_key_slots = descriptor.primary_key_slot_indices();
527 let runtime_key = key.primary_key_runtime_value();
528 let components = match runtime_key {
529 Value::List(values) if primary_key_slots.len() > 1 => values,
530 value if primary_key_slots.len() == 1 => vec![value],
531 _ => return Err(InternalError::executor_invariant()),
532 };
533 if components.len() != primary_key_slots.len() {
534 return Err(InternalError::executor_invariant());
535 }
536
537 for (slot, value) in primary_key_slots.iter().copied().zip(components) {
538 let _ = descriptor
539 .field_for_slot_index(slot)
540 .ok_or_else(InternalError::executor_invariant)?;
541 let has_explicit_intent = patch
542 .entries()
543 .iter()
544 .any(|entry| entry.slot().index() == slot);
545 if has_explicit_intent {
546 continue;
547 }
548 let value = InputValue::try_from_runtime_non_enum(&value)
549 .ok_or_else(InternalError::executor_invariant)?;
550 patch =
551 patch.set_preserved_replacement_identity(FieldSlot::from_validated_index(slot), value);
552 }
553
554 Ok(patch)
555}
556
557fn accepted_identity_insert_field(
561 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
562) -> Result<Option<AcceptedIdentityInsertField>, InternalError> {
563 let mut identity = None;
564 for field in descriptor.fields() {
565 if field.write_policy().insert_generation() != Some(FieldInsertGeneration::Identity) {
566 continue;
567 }
568 let field_slot = usize::from(field.slot().get());
569 if identity
570 .replace(AcceptedIdentityInsertField {
571 field_id: field.field_id(),
572 field_slot,
573 accepted_kind: field.kind().clone(),
574 })
575 .is_some()
576 || descriptor.primary_key_slot_indices() != [field_slot]
577 {
578 return Err(InternalError::identity_corruption());
579 }
580 }
581 Ok(identity)
582}
583
584fn checked_pre_key_candidate_count(count: usize) -> Result<u32, InternalError> {
585 u32::try_from(count).map_err(|_| InternalError::identity_candidate_count_exhausted())
586}
587
588fn validate_identity_materialization(
589 entity_tag: crate::types::EntityTag,
590 identity_field: &AcceptedIdentityInsertField,
591 candidate: &AcceptedPreKeyInsert,
592 allocation: &AcceptedIdentityAllocation,
593 data_key: &DecodedDataStoreKey,
594 reader: &StructuralSlotReader<'_>,
595) -> Result<(), InternalError> {
596 let owner = allocation.owner();
597 let slot_value = reader.required_cached_value(identity_field.field_slot)?;
598 if candidate.entity_tag() != entity_tag
599 || candidate.input_ordinal() != allocation.input_ordinal()
600 || owner.entity_tag() != entity_tag
601 || owner.field_id() != identity_field.field_id
602 || allocation.field_slot() != identity_field.field_slot
603 || slot_value != allocation.value()
604 || data_key.primary_key_runtime_value() != *allocation.value()
605 {
606 return Err(InternalError::identity_corruption());
607 }
608 Ok(())
609}
610
611fn data_key_from_validated_reader(
614 entity_tag: crate::types::EntityTag,
615 reader: &StructuralSlotReader<'_>,
616) -> Result<DecodedDataStoreKey, InternalError> {
617 let values = reader
618 .contract()
619 .primary_key_slot_indices()
620 .iter()
621 .map(|slot| reader.required_cached_value(*slot).cloned())
622 .collect::<Result<Vec<_>, _>>()?;
623
624 DecodedDataStoreKey::try_from_structural_key_values(entity_tag, &values)
625}
626
627fn validated_existing_row(
628 store: crate::db::registry::StoreHandle,
629 data_key: &DecodedDataStoreKey,
630 contract: &StructuralRowContract,
631) -> Result<Option<RawRow>, InternalError> {
632 let raw_key = data_key.to_raw()?;
633 let row = store.with_data(|data| data.get(&raw_key));
634 if let Some(row) = row.as_ref() {
635 let reader =
636 StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(row, contract)?;
637 reader.validate_primary_key(data_key)?;
638 }
639 Ok(row)
640}
641
642fn into_mutation_output_values(
645 mut reader: StructuralSlotReader<'_>,
646 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
647) -> Result<Vec<Value>, InternalError> {
648 let mut values = Vec::with_capacity(descriptor.fields().len());
649 for field in descriptor.fields() {
650 values.push(reader.take_required_value(usize::from(field.slot().get()))?);
651 }
652 Ok(values)
653}
654
655fn prepare_dynamic_mutation_result(
656 catalog: &AcceptedSchemaCatalogContext,
657 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
658 rows: Vec<AcceptedStructuralMutationRow>,
659 enforce_mixed_batch_result_bound: bool,
660) -> Result<DynamicMutationResult, InternalError> {
661 let affected_rows = rows.iter().try_fold(0_u32, |total, row| {
662 total
663 .checked_add(u32::from(row.logical_changed()))
664 .ok_or_else(InternalError::executor_invariant)
665 })?;
666 let columns = descriptor
667 .fields()
668 .iter()
669 .map(|field| field.name().to_string())
670 .collect();
671 let rows = rows
672 .into_iter()
673 .map(|row| {
674 row.values
675 .into_iter()
676 .map(|value| {
677 output_value_from_runtime(catalog.enum_catalog(), value)
678 .map_err(|_| InternalError::store_invariant())
679 })
680 .collect::<Result<Vec<_>, _>>()
681 })
682 .collect::<Result<Vec<_>, _>>()?;
683 let result = DynamicMutationResult {
684 entity: catalog.snapshot().entity_name().to_string(),
685 columns,
686 rows,
687 affected_rows,
688 };
689 if enforce_mixed_batch_result_bound {
690 let encoded =
691 candid::encode_one(&result).map_err(|_| InternalError::executor_invariant())?;
692 validate_structural_mutation_result_bytes(encoded.len())?;
693 }
694 Ok(result)
695}
696
697fn typed_descriptor_field_type(
698 field_type: TypedFieldType,
699) -> Result<FieldType, SchemaContractError> {
700 match field_type {
701 TypedFieldType::Scalar(scalar) => Ok(FieldType::Scalar(scalar)),
702 TypedFieldType::List(item) => Ok(FieldType::List(Box::new(typed_descriptor_field_type(
703 *item,
704 )?))),
705 TypedFieldType::Named(source_key) => {
706 TypeSourceKey::try_new(source_key.to_string()).map(FieldType::Named)
707 }
708 }
709}
710
711fn typed_adapter_field_kind_matches(
712 accepted: &AcceptedFieldKind,
713 expected: &AcceptedFieldKind,
714) -> bool {
715 if accepted == expected {
716 return true;
717 }
718 match (accepted, expected) {
719 (AcceptedFieldKind::Relation { key_kind, .. }, expected) => {
720 typed_adapter_field_kind_matches(key_kind, expected)
721 }
722 (AcceptedFieldKind::List(accepted), AcceptedFieldKind::List(expected)) => {
723 typed_adapter_field_kind_matches(accepted, expected)
724 }
725 _ => false,
726 }
727}
728
729impl<C: CanisterKind> DbSession<C> {
730 pub fn issue_typed_entity_binding(
732 &self,
733 descriptor: &TypedEntityDescriptor,
734 ) -> Result<DynamicTypedEntityBinding, DynamicTypedBindingError> {
735 let unavailable = |field_source| {
736 DynamicTypedBindingError::source_unavailable(descriptor.entity_source_key, field_source)
737 };
738 let entity_source = EntitySourceKey::try_new(descriptor.entity_source_key)
739 .map_err(|_| unavailable(None))?;
740 let catalog = self
741 .find_accepted_schema_catalog_context_for_entity_source_key(entity_source.as_str())?
742 .ok_or_else(|| unavailable(None))?;
743 let identity = catalog.identity();
744 if identity.entity_path() != entity_source.as_str() {
745 return Err(InternalError::store_invariant().into());
746 }
747 let store = self.db.recovered_store(identity.store_path())?;
748 store.with_schema(|schema| {
752 let bundle = schema
753 .borrow_accepted_schema_bundle_for_authority(
754 catalog.value_catalog_handle().authority(),
755 )?
756 .ok_or_else(InternalError::store_invariant)?;
757 let entity_tag = identity.entity_tag();
758 if bundle.source_bindings().entity(&entity_source) != Some(entity_tag)
759 || bundle.revision() != catalog.revision()
760 {
761 return Err(InternalError::store_invariant().into());
762 }
763 let snapshot = bundle
764 .entity_snapshots()
765 .get(&entity_tag)
766 .ok_or_else(InternalError::store_invariant)?;
767 if descriptor.primary_key_source_keys.len() != snapshot.primary_key_field_ids().len() {
768 return Err(DynamicTypedBindingError::IncompatibleField);
769 }
770 for (source_key, accepted_field_id) in descriptor
771 .primary_key_source_keys
772 .iter()
773 .zip(snapshot.primary_key_field_ids())
774 {
775 let source = FieldSourceKey::try_new((*source_key).to_string())
776 .map_err(|_| unavailable(Some(*source_key)))?;
777 let descriptor_field_id = bundle
778 .source_bindings()
779 .field(entity_tag, &source)
780 .ok_or_else(|| unavailable(Some(*source_key)))?;
781 if descriptor_field_id != *accepted_field_id {
782 return Err(DynamicTypedBindingError::IncompatibleField);
783 }
784 }
785 let row_contract = catalog.inspection_plan().row_contract();
786 let mut fields = Vec::with_capacity(descriptor.fields.len());
787 for field_descriptor in descriptor.fields {
788 let source = FieldSourceKey::try_new(field_descriptor.source_key.to_string())
789 .map_err(|_| unavailable(Some(field_descriptor.source_key)))?;
790 let field_id = bundle
791 .source_bindings()
792 .field(entity_tag, &source)
793 .ok_or_else(|| unavailable(Some(field_descriptor.source_key)))?;
794 let field = snapshot
795 .fields()
796 .iter()
797 .find(|field| field.id() == field_id)
798 .ok_or_else(InternalError::store_invariant)?;
799 let runtime_field = row_contract
800 .required_accepted_field_contract(usize::from(field.slot().get()))?;
801 if runtime_field.field_id() != field_id {
802 return Err(InternalError::store_invariant().into());
803 }
804 let field_type = typed_descriptor_field_type(field_descriptor.field_type)
805 .map_err(|_| unavailable(Some(field_descriptor.source_key)))?;
806 let expected_kind = lower_field_type(&field_type, bundle.source_bindings())
807 .map_err(|_| DynamicTypedBindingError::IncompatibleField)?;
808 if field.nullable() != field_descriptor.nullable
809 || !typed_adapter_field_kind_matches(field.kind(), &expected_kind)
810 {
811 return Err(DynamicTypedBindingError::IncompatibleField);
812 }
813 fields.push((
814 source.as_str().to_string(),
815 field_id.get(),
816 field.slot().get(),
817 field.name().to_string(),
818 ));
819 }
820 let adapter_names = bundle.typed_adapter_names()?;
821
822 DynamicTypedEntityBinding::new(
823 database_incarnation_id()?.to_bytes(),
824 entity_source.as_str().to_string(),
825 snapshot.entity_name().to_string(),
826 entity_tag.value(),
827 catalog.revision().get(),
828 catalog.fingerprint(),
829 row_contract.current_layout_version().get(),
830 fields,
831 adapter_names.named_types,
832 adapter_names.enum_variants,
833 adapter_names.composite_fields,
834 )
835 .map_err(Into::into)
836 })
837 }
838
839 pub(in crate::db::session) fn current_typed_entity_binding_catalog(
840 &self,
841 binding: &DynamicTypedEntityBinding,
842 ) -> Result<Option<AcceptedSchemaCatalogContext>, InternalError> {
843 self.db.ensure_recovered_state()?;
847 let incarnation = database_incarnation_id()?.to_bytes();
848 if incarnation != binding.database_incarnation {
849 return Ok(None);
850 }
851 let Some(catalog) = self.find_accepted_schema_catalog_context_for_entity_source_key(
852 binding.entity_source.as_str(),
853 )?
854 else {
855 return Ok(None);
856 };
857 self.typed_entity_binding_matches_catalog(binding, &catalog, incarnation)
858 .map(|current| current.then_some(catalog))
859 }
860
861 fn typed_entity_binding_matches_catalog(
862 &self,
863 binding: &DynamicTypedEntityBinding,
864 catalog: &AcceptedSchemaCatalogContext,
865 incarnation: [u8; 16],
866 ) -> Result<bool, InternalError> {
867 if incarnation != binding.database_incarnation {
868 return Ok(false);
869 }
870 let row_contract = catalog.inspection_plan().row_contract();
871 let identity = catalog.identity();
872 if identity.entity_path() != binding.entity_source.as_str()
873 || identity.entity_tag().value() != binding.entity_tag
874 || catalog.revision().get() != binding.accepted_revision
875 || catalog.fingerprint() != binding.accepted_fingerprint
876 || row_contract.current_layout_version().get() != binding.entity_generation
877 {
878 return Ok(false);
879 }
880 let entity_source = EntitySourceKey::try_new(binding.entity_source.clone())
881 .map_err(|_| InternalError::store_invariant())?;
882 let store = self.db.recovered_store(identity.store_path())?;
883 store.with_schema(|schema| {
886 let bundle = schema
887 .borrow_accepted_schema_bundle_for_authority(
888 catalog.value_catalog_handle().authority(),
889 )?
890 .ok_or_else(InternalError::store_invariant)?;
891 if bundle.revision() != catalog.revision()
892 || bundle.source_bindings().entity(&entity_source) != Some(identity.entity_tag())
893 {
894 return Ok(false);
895 }
896 let snapshot = bundle
897 .entity_snapshots()
898 .get(&identity.entity_tag())
899 .ok_or_else(InternalError::store_invariant)?;
900 for (source_key, expected_field_id, expected_slot) in binding.field_identity_bindings()
901 {
902 let source = FieldSourceKey::try_new(source_key)
903 .map_err(|_| InternalError::store_invariant())?;
904 let Some(field_id) = bundle
905 .source_bindings()
906 .field(identity.entity_tag(), &source)
907 else {
908 return Ok(false);
909 };
910 let Some(field) = snapshot
911 .fields()
912 .iter()
913 .find(|field| field.id() == field_id)
914 else {
915 return Err(InternalError::store_invariant());
916 };
917 if field_id.get() != expected_field_id || field.slot().get() != expected_slot {
918 return Ok(false);
919 }
920 }
921
922 Ok(true)
923 })
924 }
925
926 pub fn typed_entity_binding_is_current(
928 &self,
929 binding: &DynamicTypedEntityBinding,
930 ) -> Result<bool, InternalError> {
931 self.current_typed_entity_binding_catalog(binding)
932 .map(|catalog| catalog.is_some())
933 }
934
935 #[cfg(feature = "sql")]
938 pub(in crate::db::session) fn execute_accepted_structural_delete_batch(
939 &self,
940 catalog: &AcceptedSchemaCatalogContext,
941 capture_output_values: bool,
942 keys: Vec<DecodedDataStoreKey>,
943 precommit_validation: impl FnOnce(&[Vec<Value>]) -> Result<(), InternalError>,
944 ) -> Result<Vec<Vec<Value>>, InternalError> {
945 let mutations = keys
946 .into_iter()
947 .map(AcceptedStructuralMutation::delete)
948 .collect::<Vec<_>>();
949 let mutation_capacity = mutations.len();
950 let mut mutations = mutations.into_iter();
951 self.execute_accepted_structural_mutation_batch_inner(
952 catalog,
953 mutation_capacity,
954 0,
955 || {
956 Ok(mutations
957 .next()
958 .map(|mutation| AcceptedStructuralMutationBatchItem {
959 catalog: catalog.clone(),
960 mutation,
961 }))
962 },
963 Timestamp::now(),
964 AcceptedStructuralMutationCommitOptions::standard(capture_output_values),
965 |rows, _report| {
966 let rows = rows
967 .into_iter()
968 .map(AcceptedStructuralMutationRow::into_values)
969 .collect::<Vec<_>>();
970 precommit_validation(rows.as_slice())?;
971 Ok((rows, AcceptedStructuralMutationCommitDirective::Standard))
972 },
973 )
974 }
975
976 pub(in crate::db::session) fn execute_accepted_structural_save_batch<T>(
985 &self,
986 catalog: &AcceptedSchemaCatalogContext,
987 capture_output_values: bool,
988 mutations: Vec<AcceptedStructuralMutation>,
989 operation_timestamp: Timestamp,
990 precommit_preparation: impl FnOnce(
991 Vec<AcceptedStructuralMutationRow>,
992 ) -> Result<T, InternalError>,
993 ) -> Result<T, InternalError> {
994 let mutation_capacity = mutations.len();
995 let identity_candidate_count = mutations
996 .iter()
997 .filter(|mutation| {
998 matches!(
999 mutation,
1000 AcceptedStructuralMutation::Save {
1001 mode: MutationMode::Insert,
1002 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1003 ..
1004 }
1005 )
1006 })
1007 .count();
1008 let mut mutations = mutations.into_iter();
1009 self.execute_accepted_structural_mutation_batch_inner(
1010 catalog,
1011 mutation_capacity,
1012 identity_candidate_count,
1013 || {
1014 Ok(mutations
1015 .next()
1016 .map(|mutation| AcceptedStructuralMutationBatchItem {
1017 catalog: catalog.clone(),
1018 mutation,
1019 }))
1020 },
1021 operation_timestamp,
1022 AcceptedStructuralMutationCommitOptions::standard(capture_output_values),
1023 |rows, _report| {
1024 precommit_preparation(rows).map(|prepared| {
1025 (
1026 prepared,
1027 AcceptedStructuralMutationCommitDirective::Standard,
1028 )
1029 })
1030 },
1031 )
1032 }
1033
1034 #[cfg(test)]
1036 pub(in crate::db::session) fn execute_accepted_structural_update_with_mutation_progress(
1037 &self,
1038 catalog: &AcceptedSchemaCatalogContext,
1039 _descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1040 mutations: Vec<AcceptedStructuralMutation>,
1041 operation_timestamp: Timestamp,
1042 mutation_progress: MutationProgressRecordOp,
1043 ) -> Result<usize, InternalError> {
1044 let mutation_capacity = mutations.len();
1045 let mut mutations = mutations.into_iter();
1046 self.execute_accepted_structural_mutation_batch_inner(
1047 catalog,
1048 mutation_capacity,
1049 0,
1050 || {
1051 Ok(mutations
1052 .next()
1053 .map(|mutation| AcceptedStructuralMutationBatchItem {
1054 catalog: catalog.clone(),
1055 mutation,
1056 }))
1057 },
1058 operation_timestamp,
1059 AcceptedStructuralMutationCommitOptions::with_mutation_progress(),
1060 |rows, _report| {
1061 Ok((
1062 rows.len(),
1063 AcceptedStructuralMutationCommitDirective::WithMutationProgress(
1064 mutation_progress,
1065 ),
1066 ))
1067 },
1068 )
1069 }
1070
1071 #[cfg(any(feature = "sql", test))]
1074 pub(in crate::db::session) fn execute_accepted_structural_update_bounded_prefix<T>(
1075 &self,
1076 catalog: &AcceptedSchemaCatalogContext,
1077 _descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1078 mutation_capacity: usize,
1079 mut next_mutation: impl FnMut() -> Result<Option<AcceptedStructuralMutation>, InternalError>,
1080 operation_timestamp: Timestamp,
1081 precommit_preparation: impl FnOnce(
1082 AcceptedStructuralMutationPackingReport,
1083 ) -> Result<
1084 (T, AcceptedStructuralMutationCommitDirective),
1085 InternalError,
1086 >,
1087 ) -> Result<T, InternalError> {
1088 self.execute_accepted_structural_mutation_batch_inner(
1089 catalog,
1090 mutation_capacity,
1091 0,
1092 || {
1093 next_mutation().map(|mutation| {
1094 mutation.map(|mutation| AcceptedStructuralMutationBatchItem {
1095 catalog: catalog.clone(),
1096 mutation,
1097 })
1098 })
1099 },
1100 operation_timestamp,
1101 AcceptedStructuralMutationCommitOptions::bounded_prefix(),
1102 |rows, report| {
1103 if rows.len() != report.admitted_mutations() {
1104 return Err(InternalError::executor_invariant());
1105 }
1106 precommit_preparation(report)
1107 },
1108 )
1109 }
1110
1111 #[expect(
1112 clippy::too_many_arguments,
1113 clippy::too_many_lines,
1114 reason = "one phased owner keeps accepted authority, mutation context, precommit preparation, output capture, and commit staging inseparable"
1115 )]
1116 fn execute_accepted_structural_mutation_batch_inner<T>(
1117 &self,
1118 anchor_catalog: &AcceptedSchemaCatalogContext,
1119 mutation_capacity: usize,
1120 identity_candidate_count: usize,
1121 mut next_mutation: impl FnMut() -> Result<
1122 Option<AcceptedStructuralMutationBatchItem>,
1123 InternalError,
1124 >,
1125 operation_timestamp: Timestamp,
1126 options: AcceptedStructuralMutationCommitOptions,
1127 precommit_preparation: impl FnOnce(
1128 Vec<AcceptedStructuralMutationRow>,
1129 AcceptedStructuralMutationPackingReport,
1130 ) -> Result<
1131 (T, AcceptedStructuralMutationCommitDirective),
1132 InternalError,
1133 >,
1134 ) -> Result<T, InternalError> {
1135 let AcceptedStructuralMutationCommitOptions {
1136 capture_output_values,
1137 packing,
1138 } = options;
1139 let anchor_identity = anchor_catalog.identity();
1140 let accepted_root_identity = anchor_catalog.runtime_root_identity();
1141 let store_path = anchor_identity.store_path();
1142 let store = self.db.recovered_store(store_path)?;
1143 let write_context = dynamic_write_context(operation_timestamp);
1144 if mutation_capacity > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1145 return Err(InternalError::mutation_batch_too_many_items(
1146 mutation_capacity,
1147 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1148 ));
1149 }
1150 let _ = checked_pre_key_candidate_count(identity_candidate_count)?;
1151 let mut entity_states: Vec<AcceptedStructuralMutationEntityState> = Vec::new();
1152 let mut scheduler = AcceptedMutationConstraintScheduler::new(mutation_capacity);
1153 let mut output = Vec::with_capacity(mutation_capacity);
1154 let mut staged_bytes = 0_usize;
1155 let mut stopped_before_candidate = false;
1156 let mut candidate_exceeds_batch_policy = false;
1157 let mut input_index = 0_usize;
1158
1159 while let Some(item) = next_mutation()? {
1160 if input_index >= mutation_capacity {
1161 return Err(InternalError::mutation_batch_too_many_items(
1162 input_index.saturating_add(1),
1163 mutation_capacity,
1164 ));
1165 }
1166 let batch_input_ordinal = u32::try_from(input_index).map_err(|_| {
1167 InternalError::mutation_batch_too_many_items(
1168 mutation_capacity,
1169 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1170 )
1171 })?;
1172 input_index = input_index.saturating_add(1);
1173 let catalog = &item.catalog;
1174 let identity = catalog.identity();
1175 if catalog.runtime_root_identity() != accepted_root_identity
1176 || identity.store_path() != store_path
1177 {
1178 return Err(InternalError::query_executor_invariant());
1179 }
1180 let descriptor =
1181 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1182 let row_decode_contract =
1183 descriptor.row_decode_contract(catalog.value_catalog_handle().clone());
1184 let entity_path = identity.entity_path();
1185 let _metrics_span = EntityMetricsSpan::new(entity_path);
1186 let row_contract = StructuralRowContract::from_accepted_decode_contract(
1187 entity_path,
1188 row_decode_contract.clone(),
1189 );
1190 let entity_state_index = entity_states
1191 .iter()
1192 .position(|state| state.entity_tag == identity.entity_tag());
1193 let entity_state_index = if let Some(index) = entity_state_index {
1194 index
1195 } else {
1196 if entity_states.len() >= MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES {
1197 return Err(InternalError::mutation_batch_too_many_entities(
1198 entity_states.len().saturating_add(1),
1199 MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES,
1200 ));
1201 }
1202 let identity_field = accepted_identity_insert_field(&descriptor)?;
1203 let identity_incarnation = identity_field
1204 .as_ref()
1205 .map(|_| database_incarnation_id())
1206 .transpose()?;
1207 entity_states.push(AcceptedStructuralMutationEntityState {
1208 entity_tag: identity.entity_tag(),
1209 identity_field,
1210 identity_incarnation,
1211 identity_cursor: None,
1212 identity_insert_ordinal: 0,
1213 });
1214 entity_states.len().saturating_sub(1)
1215 };
1216 let identity_field = entity_states[entity_state_index].identity_field.clone();
1217 let identity_insert_ordinal = entity_states[entity_state_index].identity_insert_ordinal;
1218 let mutation = item.mutation;
1219 let AcceptedStructuralMutation::Save {
1220 mode,
1221 target,
1222 patch: authored_patch,
1223 } = mutation
1224 else {
1225 let AcceptedStructuralMutation::Delete { key } = mutation else {
1226 return Err(InternalError::executor_invariant());
1227 };
1228 let before = validated_existing_row(store, &key, &row_contract)?
1229 .ok_or_else(|| InternalError::store_not_found(&key))?;
1230 let raw_key = key.to_raw()?;
1231 let canonical_before = canonical_row_from_raw_row_with_accepted_decode_contract(
1232 entity_path,
1233 row_decode_contract.clone(),
1234 &before,
1235 )?;
1236 let admission = admit_structural_mutation_staged_charge(
1237 &mut staged_bytes,
1238 [
1239 raw_key.as_bytes().len(),
1240 canonical_before.as_raw_row().as_bytes().len(),
1241 ],
1242 packing,
1243 )?;
1244 match admission {
1245 AcceptedStructuralMutationStagedAdmission::Admitted => {}
1246 AcceptedStructuralMutationStagedAdmission::PageFull => {
1247 stopped_before_candidate = true;
1248 break;
1249 }
1250 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy => {
1251 stopped_before_candidate = true;
1252 candidate_exceeds_batch_policy = true;
1253 break;
1254 }
1255 }
1256 scheduler.schedule_delete(
1257 entity_path,
1258 identity.entity_tag(),
1259 catalog.fingerprint(),
1260 CommitRowOp::new(
1261 entity_path,
1262 raw_key,
1263 Some(canonical_before.as_raw_row().as_bytes().to_vec()),
1264 None,
1265 catalog.fingerprint(),
1266 ),
1267 batch_input_ordinal,
1268 )?;
1269 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1270 canonical_before.as_raw_row(),
1271 &row_contract,
1272 )?;
1273 let values = if capture_output_values {
1274 into_mutation_output_values(reader, &descriptor)?
1275 } else {
1276 Vec::new()
1277 };
1278 output.push(AcceptedStructuralMutationRow {
1279 values,
1280 logical_changed: true,
1281 });
1282 continue;
1283 };
1284 let mutation_context = mutation_diagnostic_context(catalog, mode, batch_input_ordinal);
1285 let (expected_key, preloaded_before, pre_key_insert, mut keyed_patch) = match target {
1286 AcceptedStructuralMutationTarget::ResolveFromAfterImage => {
1287 let candidate_ordinal =
1288 if identity_field.is_some() && matches!(mode, MutationMode::Insert) {
1289 identity_insert_ordinal
1290 } else {
1291 batch_input_ordinal
1292 };
1293 (
1294 None,
1295 None,
1296 Some(AcceptedPreKeyInsert::new(
1297 identity.entity_tag(),
1298 authored_patch,
1299 candidate_ordinal,
1300 )),
1301 None,
1302 )
1303 }
1304 AcceptedStructuralMutationTarget::Expected(key) => {
1305 (Some(*key), None, None, Some(authored_patch))
1306 }
1307 AcceptedStructuralMutationTarget::ExpectedLoaded(loaded) => {
1308 let (key, row) = loaded.into_parts();
1309 (Some(key), Some(row), None, Some(authored_patch))
1310 }
1311 };
1312 if matches!(mode, MutationMode::Replace)
1313 && let Some(key) = expected_key.as_ref()
1314 {
1315 let patch = keyed_patch
1316 .take()
1317 .ok_or_else(InternalError::executor_invariant)?;
1318 keyed_patch = Some(preserve_dynamic_replacement_identity(
1319 key,
1320 &descriptor,
1321 patch,
1322 )?);
1323 }
1324 let patch = pre_key_insert
1325 .as_ref()
1326 .map(AcceptedPreKeyInsert::fields)
1327 .or(keyed_patch.as_ref())
1328 .ok_or_else(InternalError::executor_invariant)?;
1329 let before = match (expected_key.as_ref(), preloaded_before) {
1330 (Some(_), Some(row)) => Some(row),
1331 (Some(key), None) => validated_existing_row(store, key, &row_contract)?,
1332 (None, None) => None,
1333 (None, Some(_)) => return Err(InternalError::executor_invariant()),
1334 };
1335 match mode {
1336 MutationMode::Insert if before.is_some() => {
1337 return Err(mutation_key_exists_error());
1338 }
1339 MutationMode::Update if before.is_none() => {
1340 let key = expected_key
1341 .as_ref()
1342 .ok_or_else(InternalError::executor_invariant)?;
1343 return Err(InternalError::store_not_found(key));
1344 }
1345 MutationMode::Insert | MutationMode::Replace | MutationMode::Update => {}
1346 }
1347
1348 let identity_allocation = if let Some(identity_field) = identity_field.as_ref()
1349 && matches!(mode, MutationMode::Insert)
1350 && before.is_none()
1351 {
1352 let candidate = pre_key_insert.as_ref().ok_or_else(|| {
1353 InternalError::mutation_database_owned_field_explicit(
1354 mutation_context,
1355 identity_field.field_id.get(),
1356 )
1357 })?;
1358 if entity_states[entity_state_index].identity_cursor.is_none() {
1359 let incarnation = entity_states[entity_state_index]
1360 .identity_incarnation
1361 .ok_or_else(InternalError::identity_state_corruption)?;
1362 entity_states[entity_state_index].identity_cursor =
1363 Some(store.with_schema(|schema_store| {
1364 schema_store.identity_statement_cursor(
1365 incarnation,
1366 identity.entity_tag(),
1367 identity_field.field_id,
1368 &identity_field.accepted_kind,
1369 )
1370 })?);
1371 }
1372 let allocation = entity_states[entity_state_index]
1373 .identity_cursor
1374 .as_mut()
1375 .ok_or_else(InternalError::identity_state_corruption)?
1376 .allocate(identity_field.field_slot, candidate.input_ordinal())?;
1377 entity_states[entity_state_index].identity_insert_ordinal = identity_insert_ordinal
1378 .checked_add(1)
1379 .ok_or_else(InternalError::identity_candidate_count_exhausted)?;
1380 Some(allocation)
1381 } else if let Some(identity_field) = identity_field.as_ref()
1382 && matches!(mode, MutationMode::Replace)
1383 && before.is_none()
1384 {
1385 return Err(InternalError::mutation_database_owned_field_explicit(
1386 mutation_context,
1387 identity_field.field_id.get(),
1388 ));
1389 } else {
1390 None
1391 };
1392
1393 let resolved = match (mode, before.as_ref()) {
1394 (MutationMode::Insert | MutationMode::Replace, None) => {
1395 resolve_insert_structural_patch_with_accepted_contract(
1396 entity_path,
1397 row_decode_contract.clone(),
1398 catalog.fingerprint(),
1399 catalog.accepted_row_constraints(),
1400 patch,
1401 write_context,
1402 mutation_context,
1403 identity_allocation.as_ref(),
1404 )?
1405 }
1406 (MutationMode::Update, Some(before)) => {
1407 resolve_update_structural_patch_with_accepted_contract(
1408 entity_path,
1409 row_decode_contract.clone(),
1410 catalog.fingerprint(),
1411 catalog.accepted_row_constraints(),
1412 before,
1413 patch,
1414 write_context,
1415 mutation_context,
1416 )?
1417 }
1418 (MutationMode::Replace, Some(before)) => {
1419 resolve_existing_replace_structural_patch_with_accepted_contract(
1420 entity_path,
1421 row_decode_contract.clone(),
1422 catalog.fingerprint(),
1423 catalog.accepted_row_constraints(),
1424 before,
1425 patch,
1426 write_context,
1427 mutation_context,
1428 )?
1429 }
1430 (MutationMode::Insert, Some(_)) | (MutationMode::Update, None) => {
1431 return Err(InternalError::executor_invariant());
1432 }
1433 };
1434 let (after, provenance) = resolved.into_parts();
1435 let reader = StructuralSlotReader::from_raw_row_with_validated_borrowed_contract(
1436 after.as_raw_row(),
1437 &row_contract,
1438 )?;
1439 let data_key = match expected_key {
1440 Some(key) => {
1441 reader.validate_primary_key(&key)?;
1442 key
1443 }
1444 None => data_key_from_validated_reader(identity.entity_tag(), &reader)?,
1445 };
1446 if let Some(allocation) = identity_allocation.as_ref() {
1447 validate_identity_materialization(
1448 identity.entity_tag(),
1449 identity_field
1450 .as_ref()
1451 .ok_or_else(InternalError::identity_corruption)?,
1452 pre_key_insert
1453 .as_ref()
1454 .ok_or_else(InternalError::identity_corruption)?,
1455 allocation,
1456 &data_key,
1457 &reader,
1458 )?;
1459 }
1460 if matches!(mode, MutationMode::Insert)
1461 && validated_existing_row(store, &data_key, &row_contract)?.is_some()
1462 {
1463 return Err(insert_key_exists_after_generation(
1464 identity_allocation.is_some(),
1465 ));
1466 }
1467 let raw_key = data_key.to_raw()?;
1468 let canonical_before = before
1469 .as_ref()
1470 .map(|before| {
1471 canonical_row_from_raw_row_with_accepted_decode_contract(
1472 entity_path,
1473 row_decode_contract.clone(),
1474 before,
1475 )
1476 })
1477 .transpose()?;
1478 let logical_changed = canonical_before.as_ref().is_none_or(|before| {
1479 before.as_raw_row().as_bytes() != after.as_raw_row().as_bytes()
1480 });
1481 let physical_changed = before
1482 .as_ref()
1483 .is_none_or(|before| before.as_bytes() != after.as_raw_row().as_bytes());
1484 let admission = admit_structural_mutation_staged_charge(
1485 &mut staged_bytes,
1486 [
1487 raw_key.as_bytes().len(),
1488 canonical_before
1489 .as_ref()
1490 .map_or(0, |before| before.as_raw_row().as_bytes().len()),
1491 after.as_raw_row().as_bytes().len(),
1492 ],
1493 packing,
1494 )?;
1495 match admission {
1496 AcceptedStructuralMutationStagedAdmission::Admitted => {}
1497 AcceptedStructuralMutationStagedAdmission::PageFull => {
1498 stopped_before_candidate = true;
1499 break;
1500 }
1501 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy => {
1502 stopped_before_candidate = true;
1503 candidate_exceeds_batch_policy = true;
1504 break;
1505 }
1506 }
1507 let row_op = physical_changed.then(|| {
1508 CommitRowOp::new(
1509 entity_path,
1510 raw_key.clone(),
1511 canonical_before
1512 .as_ref()
1513 .map(|before| before.as_raw_row().as_bytes().to_vec()),
1514 Some(after.as_raw_row().as_bytes().to_vec()),
1515 catalog.fingerprint(),
1516 )
1517 });
1518 scheduler.schedule_save_after_image(
1519 AcceptedMutationConstraintContext {
1520 entity_path,
1521 entity_tag: identity.entity_tag(),
1522 row_decode_contract: row_decode_contract.clone(),
1523 schema_fingerprint: catalog.fingerprint(),
1524 fingerprint_method: catalog.fingerprint_method_version(),
1525 row_constraints: catalog.accepted_row_constraints(),
1526 },
1527 mode,
1528 &data_key,
1529 after.as_raw_row(),
1530 provenance.as_slice(),
1531 row_op,
1532 batch_input_ordinal,
1533 )?;
1534 let values = if capture_output_values {
1535 into_mutation_output_values(reader, &descriptor)?
1536 } else {
1537 Vec::new()
1538 };
1539 output.push(AcceptedStructuralMutationRow {
1540 values,
1541 logical_changed,
1542 });
1543 }
1544
1545 let report = AcceptedStructuralMutationPackingReport {
1546 admitted_mutations: output.len(),
1547 staged_bytes,
1548 stopped_before_candidate,
1549 candidate_exceeds_batch_policy,
1550 };
1551 let batch = scheduler.finish();
1552 let (prepared, commit_directive) = precommit_preparation(output, report)?;
1553 finish_current_execution_instruction_watermark()?;
1554 let mut identity_ranges = Vec::with_capacity(entity_states.len());
1555 for state in entity_states {
1556 if let Some(range) = state
1557 .identity_cursor
1558 .map(IdentityStatementCursor::into_range_advance)
1559 .transpose()?
1560 .flatten()
1561 {
1562 identity_ranges.push(range);
1563 }
1564 }
1565 if !matches!(
1566 commit_directive,
1567 AcceptedStructuralMutationCommitDirective::Skip
1568 ) && batch.is_empty()
1569 && !identity_ranges.is_empty()
1570 {
1571 return Err(InternalError::identity_corruption());
1572 }
1573 match commit_directive {
1574 AcceptedStructuralMutationCommitDirective::Skip => {}
1575 AcceptedStructuralMutationCommitDirective::Standard if batch.is_empty() => {}
1576 AcceptedStructuralMutationCommitDirective::Standard => {
1577 commit_structural_row_ops_with_window(&self.db, batch, identity_ranges)?;
1578 }
1579 AcceptedStructuralMutationCommitDirective::WithMutationProgress(operation)
1580 if batch.is_empty() =>
1581 {
1582 let _ = operation;
1583 return Err(InternalError::executor_invariant());
1584 }
1585 AcceptedStructuralMutationCommitDirective::WithMutationProgress(operation) => {
1586 commit_structural_row_ops_with_mutation_progress(
1587 &self.db,
1588 batch,
1589 identity_ranges,
1590 operation,
1591 )?;
1592 }
1593 }
1594 Ok(prepared)
1595 }
1596
1597 fn execute_lowered_dynamic_mutation_batch(
1598 &self,
1599 catalog: &AcceptedSchemaCatalogContext,
1600 descriptor: &AcceptedRowLayoutRuntimeContract<'_>,
1601 mutations: Vec<AcceptedStructuralMutation>,
1602 enforce_mixed_batch_result_bound: bool,
1603 ) -> Result<DynamicMutationResult, InternalError> {
1604 self.execute_accepted_structural_save_batch(
1605 catalog,
1606 true,
1607 mutations,
1608 Timestamp::now(),
1609 |rows| {
1610 prepare_dynamic_mutation_result(
1611 catalog,
1612 descriptor,
1613 rows,
1614 enforce_mixed_batch_result_bound,
1615 )
1616 },
1617 )
1618 }
1619
1620 pub fn execute_trusted_dynamic_mutation(
1627 &self,
1628 request: &DynamicMutation,
1629 ) -> Result<DynamicMutationResult, InternalError> {
1630 if request.entity().is_empty() {
1631 return Err(InternalError::executor_unsupported());
1632 }
1633 let catalog =
1634 self.accepted_schema_catalog_context_for_entity_name(Some(request.entity()))?;
1635 let descriptor =
1636 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1637 let mutation = lower_dynamic_mutation_intent(&catalog, &descriptor, request.clone(), 0)?;
1638
1639 self.execute_lowered_dynamic_mutation_batch(&catalog, &descriptor, vec![mutation], false)
1640 }
1641
1642 pub fn execute_trusted_dynamic_mutation_batch(
1648 &self,
1649 requests: Vec<DynamicMutation>,
1650 ) -> Result<Vec<DynamicMutationResult>, InternalError> {
1651 self.execute_trusted_dynamic_mutation_batch_mixed(requests)
1652 }
1653
1654 fn execute_trusted_dynamic_mutation_batch_mixed(
1655 &self,
1656 requests: Vec<DynamicMutation>,
1657 ) -> Result<Vec<DynamicMutationResult>, InternalError> {
1658 if requests.is_empty() {
1659 return Err(InternalError::mutation_batch_empty());
1660 }
1661 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1662 return Err(InternalError::mutation_batch_too_many_items(
1663 requests.len(),
1664 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1665 ));
1666 }
1667 let first = requests
1668 .first()
1669 .ok_or_else(InternalError::mutation_batch_empty)?;
1670 if first.entity().is_empty() {
1671 return Err(InternalError::executor_unsupported());
1672 }
1673 let anchor_catalog =
1674 self.accepted_schema_catalog_context_for_entity_name(Some(first.entity()))?;
1675 let anchor_identity = anchor_catalog.identity();
1676 let mut entity_tags = std::collections::BTreeSet::new();
1677 let mut items = Vec::with_capacity(requests.len());
1678 let mut result_catalogs = Vec::with_capacity(requests.len());
1679 let mut identity_candidate_count = 0_usize;
1680
1681 let request_count = requests.len();
1682 for (batch_position, request) in requests.into_iter().enumerate() {
1683 let batch_position = u32::try_from(batch_position).map_err(|_| {
1684 InternalError::mutation_batch_too_many_items(
1685 request_count,
1686 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1687 )
1688 })?;
1689 if request.entity().is_empty() {
1690 return Err(InternalError::executor_unsupported());
1691 }
1692 let item_catalog = anchor_catalog
1693 .for_entity_name(request.entity())
1694 .ok_or_else(|| InternalError::unsupported_entity_path(request.entity()))?;
1695 let item_identity = item_catalog.identity();
1696 if item_identity.store_path() != anchor_identity.store_path() {
1697 return Err(InternalError::mutation_batch_store_mismatch(
1698 batch_position,
1699 anchor_identity.entity_tag().value(),
1700 item_identity.entity_tag().value(),
1701 ));
1702 }
1703 entity_tags.insert(item_identity.entity_tag());
1704 if entity_tags.len() > MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES {
1705 return Err(InternalError::mutation_batch_too_many_entities(
1706 entity_tags.len(),
1707 MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES,
1708 ));
1709 }
1710 let descriptor =
1711 AcceptedRowLayoutRuntimeContract::from_accepted_schema(item_catalog.snapshot())?;
1712 let mutation =
1713 lower_dynamic_mutation_intent(&item_catalog, &descriptor, request, batch_position)?;
1714 if matches!(
1715 mutation,
1716 AcceptedStructuralMutation::Save {
1717 mode: MutationMode::Insert,
1718 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1719 ..
1720 }
1721 ) {
1722 identity_candidate_count = identity_candidate_count.saturating_add(1);
1723 }
1724 result_catalogs.push(item_catalog.clone());
1725 items.push(AcceptedStructuralMutationBatchItem {
1726 catalog: item_catalog,
1727 mutation,
1728 });
1729 }
1730
1731 self.execute_lowered_mixed_mutation_batch(
1732 &anchor_catalog,
1733 items,
1734 result_catalogs,
1735 identity_candidate_count,
1736 )
1737 }
1738
1739 fn execute_lowered_mixed_mutation_batch(
1740 &self,
1741 anchor_catalog: &AcceptedSchemaCatalogContext,
1742 items: Vec<AcceptedStructuralMutationBatchItem>,
1743 result_catalogs: Vec<AcceptedSchemaCatalogContext>,
1744 identity_candidate_count: usize,
1745 ) -> Result<Vec<DynamicMutationResult>, InternalError> {
1746 if items.len() != result_catalogs.len() {
1747 return Err(InternalError::executor_invariant());
1748 }
1749 let mutation_count = items.len();
1750 let mut items = items.into_iter();
1751 self.execute_accepted_structural_mutation_batch_inner(
1752 anchor_catalog,
1753 mutation_count,
1754 identity_candidate_count,
1755 || Ok(items.next()),
1756 Timestamp::now(),
1757 AcceptedStructuralMutationCommitOptions::standard(true),
1758 |rows, _report| {
1759 if rows.len() != result_catalogs.len() {
1760 return Err(InternalError::executor_invariant());
1761 }
1762 let mut results = Vec::with_capacity(rows.len());
1763 for (row, catalog) in rows.into_iter().zip(result_catalogs.iter()) {
1764 let descriptor =
1765 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1766 results.push(prepare_dynamic_mutation_result(
1767 catalog,
1768 &descriptor,
1769 vec![row],
1770 false,
1771 )?);
1772 }
1773 let encoded = candid::encode_one(&results)
1774 .map_err(|_| InternalError::executor_invariant())?;
1775 validate_structural_mutation_result_bytes(encoded.len())?;
1776 Ok((results, AcceptedStructuralMutationCommitDirective::Standard))
1777 },
1778 )
1779 }
1780
1781 #[doc(hidden)]
1784 pub fn execute_trusted_typed_mutation(
1785 &self,
1786 binding: &DynamicTypedEntityBinding,
1787 request: DynamicTypedMutation,
1788 ) -> Result<Option<DynamicMutationResult>, InternalError> {
1789 let Some(catalog) = self.current_typed_entity_binding_catalog(binding)? else {
1790 return Ok(None);
1791 };
1792 let descriptor =
1793 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1794 let Some(mutation) =
1795 lower_typed_mutation_intent(&catalog, &descriptor, binding, request, 0)?
1796 else {
1797 return Ok(None);
1798 };
1799 self.execute_lowered_dynamic_mutation_batch(&catalog, &descriptor, vec![mutation], false)
1800 .map(Some)
1801 }
1802
1803 #[doc(hidden)]
1807 pub fn execute_trusted_same_entity_typed_mutation_batch(
1808 &self,
1809 binding: &DynamicTypedEntityBinding,
1810 requests: Vec<DynamicTypedMutation>,
1811 ) -> Result<Option<DynamicMutationResult>, InternalError> {
1812 if requests.is_empty() {
1813 return Err(InternalError::mutation_batch_empty());
1814 }
1815 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1816 return Err(InternalError::mutation_batch_too_many_items(
1817 requests.len(),
1818 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1819 ));
1820 }
1821 let Some(catalog) = self.current_typed_entity_binding_catalog(binding)? else {
1822 return Ok(None);
1823 };
1824 let descriptor =
1825 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1826 let mut mutations = Vec::with_capacity(requests.len());
1827 let request_count = requests.len();
1828 for (batch_position, request) in requests.into_iter().enumerate() {
1829 let batch_position = u32::try_from(batch_position).map_err(|_| {
1830 InternalError::mutation_batch_too_many_items(
1831 request_count,
1832 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1833 )
1834 })?;
1835 let Some(mutation) = lower_typed_mutation_intent(
1836 &catalog,
1837 &descriptor,
1838 binding,
1839 request,
1840 batch_position,
1841 )?
1842 else {
1843 return Ok(None);
1844 };
1845 mutations.push(mutation);
1846 }
1847
1848 self.execute_lowered_dynamic_mutation_batch(&catalog, &descriptor, mutations, true)
1849 .map(Some)
1850 }
1851
1852 #[doc(hidden)]
1856 pub fn execute_trusted_typed_mutation_batch(
1857 &self,
1858 requests: Vec<(DynamicTypedEntityBinding, DynamicTypedMutation)>,
1859 ) -> Result<Option<Vec<DynamicMutationResult>>, InternalError> {
1860 if requests.is_empty() {
1861 return Err(InternalError::mutation_batch_empty());
1862 }
1863 if requests.len() > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1864 return Err(InternalError::mutation_batch_too_many_items(
1865 requests.len(),
1866 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1867 ));
1868 }
1869 let first_binding = requests
1870 .first()
1871 .map(|(binding, _)| binding)
1872 .ok_or_else(InternalError::mutation_batch_empty)?;
1873 let Some(catalog) = self.current_typed_entity_binding_catalog(first_binding)? else {
1874 return Ok(None);
1875 };
1876 let anchor_identity = catalog.identity();
1877 let mut entity_tags = std::collections::BTreeSet::new();
1878 let mut items = Vec::with_capacity(requests.len());
1879 let mut result_catalogs = Vec::with_capacity(requests.len());
1880 let mut identity_candidate_count = 0_usize;
1881
1882 let request_count = requests.len();
1883 for (batch_position, (binding, request)) in requests.into_iter().enumerate() {
1884 let batch_position = u32::try_from(batch_position).map_err(|_| {
1885 InternalError::mutation_batch_too_many_items(
1886 request_count,
1887 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1888 )
1889 })?;
1890 let Some(item_catalog) = catalog.for_entity_path(binding.entity_source.as_str()) else {
1891 return Ok(None);
1892 };
1893 if !self.typed_entity_binding_matches_catalog(
1894 &binding,
1895 &item_catalog,
1896 database_incarnation_id()?.to_bytes(),
1897 )? {
1898 return Ok(None);
1899 }
1900 let item_identity = item_catalog.identity();
1901 if item_identity.store_path() != anchor_identity.store_path() {
1902 return Err(InternalError::mutation_batch_store_mismatch(
1903 batch_position,
1904 anchor_identity.entity_tag().value(),
1905 item_identity.entity_tag().value(),
1906 ));
1907 }
1908 entity_tags.insert(item_identity.entity_tag());
1909 if entity_tags.len() > MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES {
1910 return Err(InternalError::mutation_batch_too_many_entities(
1911 entity_tags.len(),
1912 MAX_STRUCTURAL_MUTATION_BATCH_ENTITIES,
1913 ));
1914 }
1915 let descriptor =
1916 AcceptedRowLayoutRuntimeContract::from_accepted_schema(item_catalog.snapshot())?;
1917 let Some(mutation) = lower_typed_mutation_intent(
1918 &item_catalog,
1919 &descriptor,
1920 &binding,
1921 request,
1922 batch_position,
1923 )?
1924 else {
1925 return Ok(None);
1926 };
1927 if matches!(
1928 mutation,
1929 AcceptedStructuralMutation::Save {
1930 mode: MutationMode::Insert,
1931 target: AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1932 ..
1933 }
1934 ) {
1935 identity_candidate_count = identity_candidate_count.saturating_add(1);
1936 }
1937 result_catalogs.push(item_catalog.clone());
1938 items.push(AcceptedStructuralMutationBatchItem {
1939 catalog: item_catalog,
1940 mutation,
1941 });
1942 }
1943
1944 self.execute_lowered_mixed_mutation_batch(
1945 &catalog,
1946 items,
1947 result_catalogs,
1948 identity_candidate_count,
1949 )
1950 .map(Some)
1951 }
1952
1953 pub fn execute_trusted_dynamic_insert_batch(
1959 &self,
1960 entity: &str,
1961 patches: Vec<DynamicStructuralPatch>,
1962 ) -> Result<DynamicMutationResult, InternalError> {
1963 let patch_count = patches.len();
1964 if patch_count == 0 {
1965 return Err(InternalError::mutation_batch_empty());
1966 }
1967 if patch_count > MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS {
1968 return Err(InternalError::mutation_batch_too_many_items(
1969 patch_count,
1970 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1971 ));
1972 }
1973 if entity.is_empty() {
1974 return Err(InternalError::executor_unsupported());
1975 }
1976 let catalog = self.accepted_schema_catalog_context_for_entity_name(Some(entity))?;
1977 let descriptor =
1978 AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())?;
1979 let mut mutations = Vec::with_capacity(patch_count);
1980 for (batch_position, patch) in patches.into_iter().enumerate() {
1983 let batch_position = u32::try_from(batch_position).map_err(|_| {
1984 InternalError::mutation_batch_too_many_items(
1985 patch_count,
1986 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS,
1987 )
1988 })?;
1989 mutations.push(lower_dynamic_save_intent(
1990 &catalog,
1991 &descriptor,
1992 patch,
1993 MutationMode::Insert,
1994 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
1995 batch_position,
1996 )?);
1997 }
1998
1999 self.execute_lowered_dynamic_mutation_batch(&catalog, &descriptor, mutations, false)
2000 }
2001}
2002
2003#[cfg(test)]
2004mod typed_adapter_tests {
2005 mod binding_diagnostics_tests;
2006 mod incarnation_tests;
2007 mod input_handoff_tests;
2008
2009 use super::{
2010 AcceptedFieldKind, DbSession, DynamicTypedBindingError, DynamicTypedEntityBinding,
2011 DynamicTypedMutation, DynamicWriteCell, TypedEntityDescriptor, TypedFieldType,
2012 typed_adapter_field_kind_matches, typed_descriptor_field_type,
2013 };
2014 use crate::{
2015 db::{
2016 TypedFieldDescriptor,
2017 data::DataStore,
2018 index::IndexStore,
2019 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
2020 schema::{
2021 AcceptedSchemaRevision, FieldId, FieldStorageDecode, LeafCodec,
2022 PersistedFieldSnapshot, PersistedSchemaSnapshot, ScalarCodec, SchemaFieldSlot,
2023 SchemaInsertDefault, SchemaRowLayout, SchemaStore, SchemaVersion,
2024 accepted_schema_candidate_with_field_bindings_for_tests,
2025 },
2026 },
2027 traits::{CanisterKind, Path},
2028 types::EntityTag,
2029 value::InputValue,
2030 };
2031 use icydb_schema::{FieldSourceKey, ScalarType};
2032 use std::{cell::RefCell, collections::BTreeMap};
2033
2034 const STORE_PATH: &str = "session::write::typed_adapter_tests::Store";
2035 const OTHER_STORE_PATH: &str = "session::write::typed_adapter_tests::OtherStore";
2036 const ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::Entity";
2037 const OTHER_ENTITY_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity";
2038 const ID_SOURCE: &str = "session::write::typed_adapter_tests::Entity::id";
2039 const VALUE_SOURCE: &str = "session::write::typed_adapter_tests::Entity::value";
2040 const REPLACEMENT_SOURCE: &str =
2041 "session::write::typed_adapter_tests::Entity::replacement_value";
2042 const OTHER_ID_SOURCE: &str = "session::write::typed_adapter_tests::OtherEntity::id";
2043 const ENTITY_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
2044 ENTITY_SOURCE,
2045 &[ID_SOURCE],
2046 &[
2047 TypedFieldDescriptor::new(ID_SOURCE, TypedFieldType::Scalar(ScalarType::Nat64), false),
2048 TypedFieldDescriptor::new(
2049 VALUE_SOURCE,
2050 TypedFieldType::Scalar(ScalarType::Nat64),
2051 false,
2052 ),
2053 ],
2054 );
2055 const OTHER_ENTITY_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
2056 OTHER_ENTITY_SOURCE,
2057 &[OTHER_ID_SOURCE],
2058 &[TypedFieldDescriptor::new(
2059 OTHER_ID_SOURCE,
2060 TypedFieldType::Scalar(ScalarType::Nat64),
2061 false,
2062 )],
2063 );
2064 const REPLACEMENT_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
2065 ENTITY_SOURCE,
2066 &[ID_SOURCE],
2067 &[
2068 TypedFieldDescriptor::new(ID_SOURCE, TypedFieldType::Scalar(ScalarType::Nat64), false),
2069 TypedFieldDescriptor::new(
2070 REPLACEMENT_SOURCE,
2071 TypedFieldType::Scalar(ScalarType::Nat64),
2072 false,
2073 ),
2074 ],
2075 );
2076
2077 struct TestCanister;
2078
2079 impl Path for TestCanister {
2080 const PATH: &'static str = "session::write::typed_adapter_tests::Canister";
2081 }
2082
2083 impl CanisterKind for TestCanister {
2084 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
2085 Ok(41)
2086 }
2087 const COMMIT_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.commit.v1";
2088 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
2089 Ok(49)
2090 }
2091 const STARTUP_STABLE_KEY: &'static str = "icydb.typed_adapter_tests.startup.control.v1";
2092 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
2093 Ok(42)
2094 }
2095 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
2096 "icydb.typed_adapter_tests.integrity.progress.v1";
2097 }
2098
2099 thread_local! {
2100 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2101 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2102 static SCHEMA_STORE: RefCell<SchemaStore> =
2103 const { RefCell::new(SchemaStore::init_heap()) };
2104 static OTHER_DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
2105 static OTHER_INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
2106 static OTHER_SCHEMA_STORE: RefCell<SchemaStore> =
2107 const { RefCell::new(SchemaStore::init_heap()) };
2108 static STORE_REGISTRY: StoreRegistry = {
2109 let mut registry = StoreRegistry::new();
2110 registry.register_store(
2111 STORE_PATH,
2112 &DATA_STORE,
2113 &INDEX_STORE,
2114 &SCHEMA_STORE,
2115 StoreAllocationIdentities::absent(),
2116 StoreRuntimeStorageCapabilities::heap(),
2117 ).expect("typed adapter test store should register");
2118 registry.register_store(
2119 OTHER_STORE_PATH,
2120 &OTHER_DATA_STORE,
2121 &OTHER_INDEX_STORE,
2122 &OTHER_SCHEMA_STORE,
2123 StoreAllocationIdentities::absent(),
2124 StoreRuntimeStorageCapabilities::heap(),
2125 ).expect("second typed adapter test store should register");
2126 registry
2127 };
2128 }
2129
2130 fn nat64_field(id: u32, name: &str, slot: u16) -> PersistedFieldSnapshot {
2131 PersistedFieldSnapshot::new_initial(
2132 FieldId::new(id),
2133 name.to_string(),
2134 SchemaFieldSlot::new(slot),
2135 AcceptedFieldKind::Nat64,
2136 Vec::new(),
2137 false,
2138 SchemaInsertDefault::None,
2139 FieldStorageDecode::ByKind,
2140 LeafCodec::Scalar(ScalarCodec::Nat64),
2141 )
2142 }
2143
2144 fn snapshot(
2145 entity_source: &str,
2146 entity_name: &str,
2147 fields: Vec<PersistedFieldSnapshot>,
2148 ) -> PersistedSchemaSnapshot {
2149 let layout = SchemaRowLayout::initial(
2150 fields
2151 .iter()
2152 .map(|field| (field.id(), field.slot()))
2153 .collect(),
2154 );
2155 PersistedSchemaSnapshot::new(
2156 SchemaVersion::initial(),
2157 entity_source.to_string(),
2158 entity_name.to_string(),
2159 FieldId::new(1),
2160 layout,
2161 fields,
2162 )
2163 }
2164
2165 fn field_source(source: &str) -> FieldSourceKey {
2166 FieldSourceKey::try_new(source).expect("typed field source should admit")
2167 }
2168
2169 fn publish(
2170 session: &DbSession<TestCanister>,
2171 expected: AcceptedSchemaRevision,
2172 revision: AcceptedSchemaRevision,
2173 snapshots: BTreeMap<EntityTag, PersistedSchemaSnapshot>,
2174 fields: BTreeMap<(EntityTag, FieldSourceKey), FieldId>,
2175 ) {
2176 publish_to_store(session, STORE_PATH, expected, revision, snapshots, fields);
2177 }
2178
2179 fn publish_to_store(
2180 session: &DbSession<TestCanister>,
2181 store_path: &'static str,
2182 expected: AcceptedSchemaRevision,
2183 revision: AcceptedSchemaRevision,
2184 snapshots: BTreeMap<EntityTag, PersistedSchemaSnapshot>,
2185 fields: BTreeMap<(EntityTag, FieldSourceKey), FieldId>,
2186 ) {
2187 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
2188 store_path, revision, snapshots, fields,
2189 );
2190 let store = session
2191 .db
2192 .store_handle(store_path)
2193 .expect("typed adapter test store should resolve");
2194 crate::db::commit::publish_accepted_schema_candidate(
2195 store_path, store, expected, &candidate,
2196 )
2197 .expect("typed binding candidate should publish");
2198 }
2199
2200 fn initialize_typed_session() -> DbSession<TestCanister> {
2201 let entity_tag = EntityTag::new(91);
2202 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2203 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2204 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2205 OTHER_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2206 OTHER_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2207 OTHER_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2208 let session = DbSession::<TestCanister>::new(
2209 &STORE_REGISTRY,
2210 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2211 );
2212 session
2213 .db
2214 .drive_startup_recovery_page()
2215 .expect("typed adapter test database should initialize");
2216 publish(
2217 &session,
2218 AcceptedSchemaRevision::NONE,
2219 AcceptedSchemaRevision::INITIAL,
2220 BTreeMap::from([(
2221 entity_tag,
2222 snapshot(
2223 ENTITY_SOURCE,
2224 "Entity",
2225 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2226 ),
2227 )]),
2228 BTreeMap::from([
2229 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2230 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
2231 ]),
2232 );
2233 session
2234 }
2235
2236 fn initialize_mixed_typed_session(other_store: bool) -> DbSession<TestCanister> {
2237 let entity_tag = EntityTag::new(91);
2238 let other_entity_tag = EntityTag::new(92);
2239 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2240 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2241 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2242 OTHER_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2243 OTHER_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2244 OTHER_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2245 let session = DbSession::<TestCanister>::new(
2246 &STORE_REGISTRY,
2247 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2248 );
2249 session
2250 .db
2251 .drive_startup_recovery_page()
2252 .expect("mixed typed adapter database should initialize");
2253
2254 let entity_snapshot = snapshot(
2255 ENTITY_SOURCE,
2256 "Entity",
2257 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2258 );
2259 let other_snapshot = snapshot(
2260 OTHER_ENTITY_SOURCE,
2261 "OtherEntity",
2262 vec![nat64_field(1, "id", 0)],
2263 );
2264 let entity_fields = BTreeMap::from([
2265 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2266 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
2267 ]);
2268 if other_store {
2269 publish(
2270 &session,
2271 AcceptedSchemaRevision::NONE,
2272 AcceptedSchemaRevision::INITIAL,
2273 BTreeMap::from([(entity_tag, entity_snapshot)]),
2274 entity_fields,
2275 );
2276 publish_to_store(
2277 &session,
2278 OTHER_STORE_PATH,
2279 AcceptedSchemaRevision::NONE,
2280 AcceptedSchemaRevision::INITIAL,
2281 BTreeMap::from([(other_entity_tag, other_snapshot)]),
2282 BTreeMap::from([(
2283 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2284 FieldId::new(1),
2285 )]),
2286 );
2287 } else {
2288 let mut fields = entity_fields;
2289 fields.insert(
2290 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2291 FieldId::new(1),
2292 );
2293 publish(
2294 &session,
2295 AcceptedSchemaRevision::NONE,
2296 AcceptedSchemaRevision::INITIAL,
2297 BTreeMap::from([
2298 (entity_tag, entity_snapshot),
2299 (other_entity_tag, other_snapshot),
2300 ]),
2301 fields,
2302 );
2303 }
2304 session
2305 }
2306
2307 fn typed_insert(
2308 binding: &DynamicTypedEntityBinding,
2309 id: u64,
2310 value: u64,
2311 ) -> DynamicTypedMutation {
2312 let patch = binding
2313 .bind_write_ordinals(vec![
2314 (0, DynamicWriteCell::Value(InputValue::nat64(id))),
2315 (1, DynamicWriteCell::Value(InputValue::nat64(value))),
2316 ])
2317 .expect("typed insert patch should bind");
2318 DynamicTypedMutation::Insert { patch }
2319 }
2320
2321 fn typed_other_insert(binding: &DynamicTypedEntityBinding, id: u64) -> DynamicTypedMutation {
2322 let patch = binding
2323 .bind_write_ordinals(vec![(0, DynamicWriteCell::Value(InputValue::nat64(id)))])
2324 .expect("other typed insert patch should bind");
2325 DynamicTypedMutation::Insert { patch }
2326 }
2327
2328 fn typed_delete(id: u64) -> DynamicTypedMutation {
2329 DynamicTypedMutation::Delete {
2330 key: InputValue::nat64(id),
2331 }
2332 }
2333
2334 fn typed_value_patch(
2335 binding: &DynamicTypedEntityBinding,
2336 value: u64,
2337 ) -> super::DynamicTypedStructuralPatch {
2338 binding
2339 .bind_write_ordinals(vec![(1, DynamicWriteCell::Value(InputValue::nat64(value)))])
2340 .expect("typed value patch should bind")
2341 }
2342
2343 fn assert_query_diagnostic(
2344 error: crate::db::QueryError,
2345 code: icydb_diagnostic_code::DiagnosticCode,
2346 origin: icydb_diagnostic_code::ErrorOrigin,
2347 detail: icydb_diagnostic_code::DiagnosticDetail,
2348 ) {
2349 let diagnostic = error.diagnostic();
2350 assert_eq!(diagnostic.code(), code);
2351 assert_eq!(diagnostic.origin(), origin);
2352 assert_eq!(diagnostic.detail(), Some(&detail));
2353 }
2354
2355 #[test]
2356 fn typed_adapter_kind_matching_is_exact_but_accepts_relation_key_wrappers() {
2357 let relation = AcceptedFieldKind::Relation {
2358 target_path: "test::Target".to_string(),
2359 target_entity_name: "Target".to_string(),
2360 target_entity_tag: EntityTag::new(7),
2361 target_store_path: "test::Store".to_string(),
2362 key_kind: Box::new(AcceptedFieldKind::Nat64),
2363 };
2364
2365 assert!(typed_adapter_field_kind_matches(
2366 &relation,
2367 &AcceptedFieldKind::Nat64,
2368 ));
2369 assert!(typed_adapter_field_kind_matches(
2370 &AcceptedFieldKind::List(Box::new(relation)),
2371 &AcceptedFieldKind::List(Box::new(AcceptedFieldKind::Nat64)),
2372 ));
2373 assert!(!typed_adapter_field_kind_matches(
2374 &AcceptedFieldKind::Nat64,
2375 &AcceptedFieldKind::Nat32,
2376 ));
2377 }
2378
2379 #[test]
2380 fn typed_adapter_field_contract_rejects_invalid_named_source_identity() {
2381 const NAT64: TypedFieldType = TypedFieldType::Scalar(ScalarType::Nat64);
2382
2383 assert!(matches!(
2384 typed_descriptor_field_type(TypedFieldType::Named("")),
2385 Err(icydb_schema::SchemaContractError::EmptyIdentity),
2386 ));
2387 assert!(matches!(
2388 typed_descriptor_field_type(TypedFieldType::Scalar(ScalarType::Nat16)),
2389 Ok(icydb_schema::FieldType::Scalar(ScalarType::Nat16)),
2390 ));
2391 assert!(matches!(
2392 typed_descriptor_field_type(TypedFieldType::List(&NAT64)),
2393 Ok(icydb_schema::FieldType::List(item))
2394 if *item == icydb_schema::FieldType::Scalar(ScalarType::Nat64),
2395 ));
2396 }
2397
2398 #[test]
2399 fn typed_descriptor_primary_key_must_match_accepted_source_order() {
2400 const PRIMARY_KEY_MISMATCH: TypedEntityDescriptor =
2401 TypedEntityDescriptor::new(ENTITY_SOURCE, &[VALUE_SOURCE], ENTITY_DESCRIPTOR.fields);
2402 const NULLABILITY_MISMATCH: TypedEntityDescriptor = TypedEntityDescriptor::new(
2403 ENTITY_SOURCE,
2404 &[ID_SOURCE],
2405 &[
2406 TypedFieldDescriptor::new(
2407 ID_SOURCE,
2408 TypedFieldType::Scalar(ScalarType::Nat64),
2409 false,
2410 ),
2411 TypedFieldDescriptor::new(
2412 VALUE_SOURCE,
2413 TypedFieldType::Scalar(ScalarType::Nat64),
2414 true,
2415 ),
2416 ],
2417 );
2418
2419 let session = initialize_typed_session();
2420 assert!(matches!(
2421 session.issue_typed_entity_binding(&PRIMARY_KEY_MISMATCH),
2422 Err(DynamicTypedBindingError::IncompatibleField),
2423 ));
2424 assert!(matches!(
2425 session.issue_typed_entity_binding(&NULLABILITY_MISMATCH),
2426 Err(DynamicTypedBindingError::IncompatibleField),
2427 ));
2428 }
2429
2430 #[test]
2431 fn typed_mutation_batch_is_bounded_and_atomic() {
2432 let session = initialize_typed_session();
2433 let binding = session
2434 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2435 .expect("typed batch binding should issue");
2436
2437 session
2438 .execute_trusted_typed_mutation_batch(Vec::new())
2439 .expect_err("empty typed batch should reject");
2440 let insert = typed_insert(&binding, 1, 10);
2441 let oversized = (0..=super::MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
2442 .map(|_| (binding.clone(), insert.clone()))
2443 .collect();
2444 session
2445 .execute_trusted_typed_mutation_batch(oversized)
2446 .expect_err("oversized typed batch should reject");
2447
2448 let duplicate = vec![
2449 (binding.clone(), insert.clone()),
2450 (binding.clone(), typed_insert(&binding, 1, 11)),
2451 ];
2452 session
2453 .execute_trusted_typed_mutation_batch(duplicate)
2454 .expect_err("late duplicate key should reject the whole typed batch");
2455 let empty = session
2456 .execute_trusted_live_page(&crate::db::DynamicQuery::new("Entity"), None)
2457 .expect("failed typed batch should leave the entity readable");
2458 assert!(empty.rows.is_empty());
2459
2460 let result = session
2461 .execute_trusted_typed_mutation_batch(vec![
2462 (binding.clone(), insert),
2463 (binding.clone(), typed_insert(&binding, 2, 20)),
2464 ])
2465 .expect("valid typed batch should execute")
2466 .expect("exact binding should remain current");
2467 assert_eq!(result.len(), 2);
2468 assert!(result.iter().all(|item| item.affected_rows == 1));
2469 assert_eq!(
2470 result
2471 .into_iter()
2472 .map(|item| item.rows.into_iter().next().expect("one row per request"))
2473 .collect::<Vec<_>>(),
2474 vec![
2475 vec![
2476 crate::value::OutputValue::nat64(1),
2477 crate::value::OutputValue::nat64(10),
2478 ],
2479 vec![
2480 crate::value::OutputValue::nat64(2),
2481 crate::value::OutputValue::nat64(20),
2482 ],
2483 ]
2484 );
2485
2486 let mut mismatched = binding.clone();
2487 mismatched.accepted_revision = mismatched.accepted_revision.saturating_add(1);
2488 let mismatch = session
2489 .execute_trusted_typed_mutation_batch(vec![
2490 (binding.clone(), typed_insert(&binding, 3, 30)),
2491 (mismatched.clone(), typed_insert(&binding, 4, 40)),
2492 ])
2493 .expect("mismatched typed batch should fail closed");
2494 assert!(mismatch.is_none());
2495 let stale = session
2496 .execute_trusted_typed_mutation_batch(vec![(mismatched, typed_insert(&binding, 5, 50))])
2497 .expect("stale typed batch should fail closed");
2498 assert!(stale.is_none());
2499 }
2500
2501 #[test]
2502 fn same_entity_typed_mutation_batch_rejects_empty_oversized_and_stale_input() {
2503 let session = initialize_typed_session();
2504 let binding = session
2505 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2506 .expect("typed batch binding should issue");
2507
2508 session
2509 .execute_trusted_same_entity_typed_mutation_batch(&binding, Vec::new())
2510 .expect_err("empty same-entity typed batch should reject");
2511 let insert = typed_insert(&binding, 1, 10);
2512 session
2513 .execute_trusted_same_entity_typed_mutation_batch(
2514 &binding,
2515 vec![insert.clone(); super::MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1],
2516 )
2517 .expect_err("oversized same-entity typed batch should reject");
2518
2519 let mut stale = binding;
2520 stale.accepted_revision = stale.accepted_revision.saturating_add(1);
2521 let result = session
2522 .execute_trusted_same_entity_typed_mutation_batch(&stale, vec![insert])
2523 .expect("stale same-entity typed admission should remain an adapter outcome");
2524 assert!(result.is_none());
2525 }
2526
2527 #[test]
2528 fn typed_mutation_batch_accepts_mixed_same_store_bindings_and_rejects_late_stale_input() {
2529 let session = initialize_mixed_typed_session(false);
2530 let binding = session
2531 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2532 .expect("first typed entity should bind");
2533 let other = session
2534 .issue_typed_entity_binding(&OTHER_ENTITY_DESCRIPTOR)
2535 .expect("second typed entity should bind");
2536
2537 let mut stale_other = other.clone();
2538 stale_other.accepted_revision = stale_other.accepted_revision.saturating_add(1);
2539 let stale = session
2540 .execute_trusted_typed_mutation_batch(vec![
2541 (binding.clone(), typed_insert(&binding, 1, 10)),
2542 (stale_other, typed_other_insert(&other, 1)),
2543 ])
2544 .expect("stale typed admission should remain an adapter outcome");
2545 assert!(stale.is_none());
2546 for entity in ["Entity", "OtherEntity"] {
2547 let rows = session
2548 .execute_trusted_live_page(&crate::db::DynamicQuery::new(entity), None)
2549 .expect("failed mixed admission should leave both entities readable");
2550 assert!(rows.rows.is_empty());
2551 }
2552
2553 let results = session
2554 .execute_trusted_typed_mutation_batch(vec![
2555 (other.clone(), typed_other_insert(&other, 2)),
2556 (binding.clone(), typed_insert(&binding, 3, 30)),
2557 ])
2558 .expect("same-store typed batch should execute")
2559 .expect("both typed bindings should remain current");
2560 assert_eq!(results.len(), 2);
2561 assert_eq!(results[0].entity, "OtherEntity");
2562 assert_eq!(
2563 results[0].rows,
2564 vec![vec![crate::value::OutputValue::nat64(2)]]
2565 );
2566 assert_eq!(results[1].entity, "Entity");
2567 assert_eq!(
2568 results[1].rows,
2569 vec![vec![
2570 crate::value::OutputValue::nat64(3),
2571 crate::value::OutputValue::nat64(30),
2572 ]],
2573 );
2574 }
2575
2576 #[test]
2577 fn typed_mutation_batch_rejects_cross_store_bindings_before_writes() {
2578 let session = initialize_mixed_typed_session(true);
2579 let binding = session
2580 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2581 .expect("first store typed entity should bind");
2582 let other = session
2583 .issue_typed_entity_binding(&OTHER_ENTITY_DESCRIPTOR)
2584 .expect("second store typed entity should bind");
2585
2586 let error = session
2587 .execute_trusted_typed_mutation_batch(vec![
2588 (binding.clone(), typed_insert(&binding, 1, 10)),
2589 (other.clone(), typed_other_insert(&other, 1)),
2590 ])
2591 .expect_err("typed cross-store rows must reject");
2592 assert!(matches!(
2593 error.diagnostic().detail(),
2594 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
2595 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStoreMismatch,
2596 })
2597 ));
2598 for entity in ["Entity", "OtherEntity"] {
2599 let rows = session
2600 .execute_trusted_live_page(&crate::db::DynamicQuery::new(entity), None)
2601 .expect("cross-store rejection should leave both entities readable");
2602 assert!(rows.rows.is_empty());
2603 }
2604 }
2605
2606 #[test]
2607 fn typed_mutation_batch_rechecks_late_field_identity_under_current_authority() {
2608 let session = initialize_typed_session();
2609 let binding = session
2610 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2611 .expect("entity should bind");
2612
2613 for (field_id, slot) in [(3, 1), (2, 2)] {
2616 let mismatched = DynamicTypedEntityBinding::new(
2617 binding.database_incarnation,
2618 binding.entity_source.clone(),
2619 binding.entity_label.clone(),
2620 binding.entity_tag,
2621 binding.accepted_revision,
2622 binding.accepted_fingerprint,
2623 binding.entity_generation,
2624 vec![
2625 (ID_SOURCE.to_string(), 1, 0, "id".to_string()),
2626 (
2627 VALUE_SOURCE.to_string(),
2628 field_id,
2629 slot,
2630 "value".to_string(),
2631 ),
2632 ],
2633 binding.named_types.clone(),
2634 binding.enum_variants.clone(),
2635 binding.composite_fields.clone(),
2636 )
2637 .expect("distinct field mapping should form an opaque binding");
2638 let result = session
2639 .execute_trusted_typed_mutation_batch(vec![
2640 (binding.clone(), typed_insert(&binding, 1, 10)),
2641 (mismatched, typed_insert(&binding, 2, 20)),
2642 ])
2643 .expect("mismatched mapping should remain an adapter rejection");
2644 assert!(result.is_none());
2645 DATA_STORE.with(|store| assert_eq!(store.borrow().len(), 0));
2646 }
2647
2648 let result = session
2649 .execute_trusted_typed_mutation_batch(vec![
2650 (binding.clone(), typed_insert(&binding, 1, 10)),
2651 (binding.clone(), typed_insert(&binding, 2, 20)),
2652 ])
2653 .expect("corrected batch should execute after rejected borrows")
2654 .expect("current binding should remain valid");
2655 assert_eq!(result.len(), 2);
2656 }
2657
2658 #[test]
2659 fn same_entity_typed_mutation_batch_preserves_mixed_result_order() {
2660 let session = initialize_typed_session();
2661 let binding = session
2662 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2663 .expect("typed batch binding should issue");
2664 session
2665 .execute_trusted_same_entity_typed_mutation_batch(
2666 &binding,
2667 vec![
2668 typed_insert(&binding, 1, 10),
2669 typed_insert(&binding, 2, 20),
2670 typed_insert(&binding, 4, 40),
2671 ],
2672 )
2673 .expect("typed fixture batch should execute")
2674 .expect("typed fixture binding should be current");
2675
2676 let result = session
2677 .execute_trusted_same_entity_typed_mutation_batch(
2678 &binding,
2679 vec![
2680 DynamicTypedMutation::Update {
2681 key: InputValue::nat64(1),
2682 patch: typed_value_patch(&binding, 11),
2683 },
2684 DynamicTypedMutation::Replace {
2685 key: InputValue::nat64(2),
2686 patch: typed_value_patch(&binding, 22),
2687 },
2688 typed_insert(&binding, 3, 30),
2689 typed_delete(4),
2690 ],
2691 )
2692 .expect("mixed typed batch should execute")
2693 .expect("mixed typed binding should remain current");
2694 assert_eq!(result.len(), 4);
2695 assert_eq!(result.affected_rows, 4);
2696 assert_eq!(
2697 result.rows,
2698 vec![
2699 vec![
2700 crate::value::OutputValue::nat64(1),
2701 crate::value::OutputValue::nat64(11),
2702 ],
2703 vec![
2704 crate::value::OutputValue::nat64(2),
2705 crate::value::OutputValue::nat64(22),
2706 ],
2707 vec![
2708 crate::value::OutputValue::nat64(3),
2709 crate::value::OutputValue::nat64(30),
2710 ],
2711 vec![
2712 crate::value::OutputValue::nat64(4),
2713 crate::value::OutputValue::nat64(40),
2714 ],
2715 ],
2716 );
2717 }
2718
2719 #[expect(clippy::too_many_lines)]
2722 #[test]
2723 fn typed_binding_uses_accepted_ids_and_slots_across_renames_and_name_reuse() {
2724 let entity_tag = EntityTag::new(91);
2725 let other_entity_tag = EntityTag::new(92);
2726 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2727 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2728 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2729 OTHER_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
2730 OTHER_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
2731 OTHER_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
2732
2733 let session = DbSession::<TestCanister>::new(
2734 &STORE_REGISTRY,
2735 &crate::db::RequestExecutionRoot::__new_runtime_root(),
2736 );
2737 session
2738 .db
2739 .drive_startup_recovery_page()
2740 .expect("typed adapter test database should initialize");
2741 publish(
2742 &session,
2743 AcceptedSchemaRevision::NONE,
2744 AcceptedSchemaRevision::INITIAL,
2745 BTreeMap::from([(
2746 entity_tag,
2747 snapshot(
2748 ENTITY_SOURCE,
2749 "Entity",
2750 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2751 ),
2752 )]),
2753 BTreeMap::from([
2754 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2755 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
2756 ]),
2757 );
2758
2759 let initial_catalog = session
2760 .find_accepted_schema_catalog_context_for_entity_source_key(ENTITY_SOURCE)
2761 .expect("initial source catalog lookup should inspect")
2762 .expect("initial source catalog should exist");
2763 assert_eq!(initial_catalog.identity().entity_tag(), entity_tag);
2764 let initial = session
2765 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2766 .expect("initial typed binding should issue");
2767 assert_eq!(initial.field_slot(ID_SOURCE), Some(0));
2768 assert_eq!(initial.field_slot(VALUE_SOURCE), Some(1));
2769 assert_eq!(initial.output_field_slot("value"), Some(1));
2770 let initial_patch = initial
2771 .bind_write_ordinals(vec![(1, DynamicWriteCell::Value(InputValue::nat64(7)))])
2772 .expect("source-bound patch should lower");
2773 assert_eq!(
2774 initial_patch.fields(),
2775 &[(1, DynamicWriteCell::Value(InputValue::nat64(7)))]
2776 );
2777 assert!(
2778 initial
2779 .bind_write_ordinals(vec![(2, DynamicWriteCell::Value(InputValue::nat64(8)),)])
2780 .is_none(),
2781 "out-of-range descriptor ordinals must fail closed",
2782 );
2783 assert!(
2784 initial
2785 .bind_write_ordinals(vec![
2786 (1, DynamicWriteCell::Omitted),
2787 (1, DynamicWriteCell::Default),
2788 ])
2789 .is_none(),
2790 "duplicate descriptor ordinals must fail closed",
2791 );
2792 assert!(
2793 initial
2794 .bind_write_ordinals(vec![
2795 (1, DynamicWriteCell::Omitted),
2796 (0, DynamicWriteCell::Default),
2797 ])
2798 .is_none(),
2799 "out-of-order descriptor ordinals must fail closed",
2800 );
2801
2802 publish(
2803 &session,
2804 AcceptedSchemaRevision::INITIAL,
2805 AcceptedSchemaRevision::new(2),
2806 BTreeMap::from([
2807 (
2808 entity_tag,
2809 snapshot(
2810 ENTITY_SOURCE,
2811 "RenamedEntity",
2812 vec![
2813 nat64_field(1, "id", 0),
2814 nat64_field(2, "renamed_value", 1),
2815 nat64_field(3, "value", 2),
2816 ],
2817 ),
2818 ),
2819 (
2820 other_entity_tag,
2821 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
2822 ),
2823 ]),
2824 BTreeMap::from([
2825 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2826 ((entity_tag, field_source(VALUE_SOURCE)), FieldId::new(2)),
2827 (
2828 (entity_tag, field_source(REPLACEMENT_SOURCE)),
2829 FieldId::new(3),
2830 ),
2831 (
2832 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2833 FieldId::new(1),
2834 ),
2835 ]),
2836 );
2837
2838 let stale_authority = session
2839 .ensure_accepted_schema_authority_is_current_for_store_path(
2840 STORE_PATH,
2841 initial_catalog.value_catalog_handle().authority(),
2842 )
2843 .expect_err("the initial accepted authority must be stale after revision two");
2844 assert_eq!(
2845 stale_authority.diagnostic_facts(),
2846 vec![
2847 (
2848 icydb_diagnostic_code::DiagnosticFactTag::ExpectedRevision,
2849 AcceptedSchemaRevision::INITIAL.get(),
2850 ),
2851 (
2852 icydb_diagnostic_code::DiagnosticFactTag::CurrentRevision,
2853 AcceptedSchemaRevision::new(2).get(),
2854 ),
2855 ],
2856 );
2857
2858 assert!(
2859 !session
2860 .typed_entity_binding_is_current(&initial)
2861 .expect("renamed binding currentness should inspect")
2862 );
2863 let renamed = session
2864 .issue_typed_entity_binding(&ENTITY_DESCRIPTOR)
2865 .expect("renamed source-bound adapter should rebind");
2866 assert_eq!(renamed.entity(), "RenamedEntity");
2867 assert_eq!(renamed.field_slot(VALUE_SOURCE), Some(1));
2868 assert_eq!(renamed.output_field_slot("renamed_value"), Some(1));
2869 assert_eq!(renamed.output_field_slot("value"), None);
2870
2871 publish(
2872 &session,
2873 AcceptedSchemaRevision::new(2),
2874 AcceptedSchemaRevision::new(3),
2875 BTreeMap::from([
2876 (
2877 entity_tag,
2878 snapshot(
2879 ENTITY_SOURCE,
2880 "RenamedEntity",
2881 vec![nat64_field(1, "id", 0), nat64_field(2, "value", 1)],
2882 ),
2883 ),
2884 (
2885 other_entity_tag,
2886 snapshot(OTHER_ENTITY_SOURCE, "Entity", vec![nat64_field(1, "id", 0)]),
2887 ),
2888 ]),
2889 BTreeMap::from([
2890 ((entity_tag, field_source(ID_SOURCE)), FieldId::new(1)),
2891 (
2892 (entity_tag, field_source(REPLACEMENT_SOURCE)),
2893 FieldId::new(2),
2894 ),
2895 (
2896 (other_entity_tag, field_source(OTHER_ID_SOURCE)),
2897 FieldId::new(1),
2898 ),
2899 ]),
2900 );
2901
2902 assert!(matches!(
2903 session.issue_typed_entity_binding(&ENTITY_DESCRIPTOR),
2904 Err(DynamicTypedBindingError::SourceUnavailable(context))
2905 if context.entity_source() == ENTITY_SOURCE
2906 && context.field_source() == Some(VALUE_SOURCE),
2907 ));
2908 assert!(
2909 !session
2910 .typed_entity_binding_is_current(&renamed)
2911 .expect("removed source binding should become stale")
2912 );
2913
2914 let replacement = session
2915 .issue_typed_entity_binding(&REPLACEMENT_DESCRIPTOR)
2916 .expect("explicit replacement source should bind");
2917 assert!(
2918 session
2919 .execute_trusted_typed_mutation(
2920 &replacement,
2921 DynamicTypedMutation::Insert {
2922 patch: initial_patch
2923 },
2924 )
2925 .expect("cross-binding patch should fail closed")
2926 .is_none()
2927 );
2928 let patch = replacement
2929 .bind_write_ordinals(vec![
2930 (0, DynamicWriteCell::Value(InputValue::nat64(1))),
2931 (1, DynamicWriteCell::Value(InputValue::nat64(9))),
2932 ])
2933 .expect("replacement source write should bind by accepted IDs and slots");
2934 let result = session
2935 .execute_trusted_typed_mutation(&replacement, DynamicTypedMutation::Insert { patch })
2936 .expect("typed insert should use the accepted mutation pipeline")
2937 .expect("replacement binding should remain current");
2938 assert_eq!(result.entity, "RenamedEntity");
2939 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
2940 assert_eq!(
2941 result.rows,
2942 vec![vec![
2943 crate::value::OutputValue::nat64(1),
2944 crate::value::OutputValue::nat64(9)
2945 ]]
2946 );
2947 assert_eq!(result.affected_rows, 1);
2948
2949 let second_patch = replacement
2950 .bind_write_ordinals(vec![
2951 (0, DynamicWriteCell::Value(InputValue::nat64(2))),
2952 (1, DynamicWriteCell::Value(InputValue::nat64(10))),
2953 ])
2954 .expect("second source-bound patch should lower");
2955 session
2956 .execute_trusted_typed_mutation(
2957 &replacement,
2958 DynamicTypedMutation::Insert {
2959 patch: second_patch,
2960 },
2961 )
2962 .expect("second typed insert should use the accepted mutation pipeline")
2963 .expect("replacement binding should remain current");
2964
2965 {
2966 let query = crate::db::DynamicQuery::new("RenamedEntity")
2967 .select(["id", "value"])
2968 .order_by(crate::db::asc("id"))
2969 .limit(1);
2970 let result = session
2971 .execute_trusted_live_page(&query, None)
2972 .expect("SQL-free dynamic execution should use accepted authority");
2973 assert_eq!(result.entity, "RenamedEntity");
2974 assert_eq!(result.columns, vec!["id".to_string(), "value".to_string()]);
2975 assert_eq!(
2976 result.rows,
2977 vec![vec![
2978 crate::value::OutputValue::nat64(1),
2979 crate::value::OutputValue::nat64(9)
2980 ]]
2981 );
2982 assert_eq!(result.row_count, 1);
2983 assert_query_diagnostic(
2984 session
2985 .execute_trusted_live_page(&query.cursor("00"), None)
2986 .expect_err("scalar execution must reject grouped cursor state"),
2987 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
2988 icydb_diagnostic_code::ErrorOrigin::Query,
2989 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
2990 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
2991 },
2992 );
2993 assert_query_diagnostic(
2994 session
2995 .execute_public_dynamic_grouped_query(
2996 &crate::db::DynamicQuery::new("RenamedEntity").grouped_limits(1, 1024),
2997 )
2998 .expect_err("grouped execution must reject scalar query state"),
2999 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
3000 icydb_diagnostic_code::ErrorOrigin::Query,
3001 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
3002 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
3003 },
3004 );
3005
3006 let grouped_query = crate::db::DynamicQuery::new("RenamedEntity")
3007 .filter(crate::db::FieldRef::new("id").eq(1_u64))
3008 .group_by("value")
3009 .aggregate(crate::db::count())
3010 .grouped_limits(1, 16 * 1024)
3011 .limit(1);
3012 let grouped = session
3013 .execute_public_dynamic_grouped_query(&grouped_query)
3014 .expect("SQL-free grouped execution should use accepted authority");
3015 let typed_grouped = session
3016 .execute_public_dynamic_grouped_query_for_typed_binding(
3017 &replacement,
3018 &grouped_query,
3019 )
3020 .expect("typed grouped execution should inspect accepted authority")
3021 .expect("replacement binding should remain current");
3022 assert_eq!(typed_grouped, grouped);
3023 assert!(
3024 session
3025 .execute_public_dynamic_grouped_query_for_typed_binding(
3026 &renamed,
3027 &grouped_query,
3028 )
3029 .expect("stale grouped binding should inspect accepted authority")
3030 .is_none(),
3031 "stale typed grouped bindings must fail closed before execution"
3032 );
3033 assert_eq!(grouped.entity, "RenamedEntity");
3034 assert_eq!(grouped.row_count, 1);
3035 assert_eq!(grouped.rows.len(), 1);
3036 assert_eq!(
3037 grouped.rows[0].group_key(),
3038 &[crate::value::OutputValue::nat64(9)]
3039 );
3040 assert_eq!(
3041 grouped.rows[0].aggregate_values(),
3042 &[crate::value::OutputValue::nat64(1)]
3043 );
3044 assert_eq!(grouped.next_cursor, None);
3045
3046 let grouped_state_error = session
3047 .execute_trusted_dynamic_grouped_query(&grouped_query.clone().grouped_limits(1, 1))
3048 .expect_err("grouped retained state must respect its explicit byte ceiling");
3049 assert!(matches!(
3050 grouped_state_error.diagnostic().detail(),
3051 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
3052 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
3053 })
3054 ));
3055 assert_eq!(
3056 grouped_state_error.diagnostic_facts()[0],
3057 (
3058 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
3059 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctStateBytes.raw(),
3060 ),
3061 );
3062
3063 assert_query_diagnostic(
3064 session
3065 .execute_public_dynamic_grouped_query(&grouped_query.clone().select(["value"]))
3066 .expect_err("grouped output must reject scalar selection"),
3067 icydb_diagnostic_code::DiagnosticCode::QueryIntent,
3068 icydb_diagnostic_code::ErrorOrigin::Query,
3069 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
3070 kind: icydb_diagnostic_code::QueryErrorKind::Intent,
3071 },
3072 );
3073 assert_query_diagnostic(
3074 session
3075 .execute_public_dynamic_grouped_query(
3076 &crate::db::DynamicQuery::new("RenamedEntity")
3077 .group_by("value")
3078 .aggregate(crate::db::count()),
3079 )
3080 .expect_err("public grouped execution must require explicit limits"),
3081 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
3082 icydb_diagnostic_code::ErrorOrigin::Query,
3083 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
3084 reason:
3085 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
3086 },
3087 );
3088 assert_query_diagnostic(
3089 session
3090 .execute_trusted_dynamic_grouped_query(
3091 &crate::db::DynamicQuery::new("RenamedEntity")
3092 .group_by("value")
3093 .aggregate(crate::db::count())
3094 .grouped_limits(0, 1024),
3095 )
3096 .expect_err("trusted grouped execution must reject zero limits"),
3097 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
3098 icydb_diagnostic_code::ErrorOrigin::Query,
3099 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
3100 reason:
3101 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryRequiresLimits,
3102 },
3103 );
3104 assert_query_diagnostic(
3105 session
3106 .execute_public_dynamic_grouped_query(&grouped_query.grouped_limits(101, 1024))
3107 .expect_err("public grouped execution must enforce its group budget"),
3108 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
3109 icydb_diagnostic_code::ErrorOrigin::Query,
3110 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
3111 reason:
3112 icydb_diagnostic_code::QueryReadAdmissionCode::GroupedQueryExceedsBudget,
3113 },
3114 );
3115
3116 let paged_query = crate::db::DynamicQuery::new("RenamedEntity")
3117 .group_by("value")
3118 .aggregate(crate::db::count())
3119 .grouped_limits(2, 16 * 1024)
3120 .limit(1);
3121 assert_query_diagnostic(
3122 session
3123 .execute_public_dynamic_grouped_query(&paged_query)
3124 .expect_err("public grouped execution must reject an unbounded full scan"),
3125 icydb_diagnostic_code::DiagnosticCode::QueryReadAdmission,
3126 icydb_diagnostic_code::ErrorOrigin::Query,
3127 icydb_diagnostic_code::DiagnosticDetail::QueryReadAdmission {
3128 reason:
3129 icydb_diagnostic_code::QueryReadAdmissionCode::UnboundedFullScanRejected,
3130 },
3131 );
3132 let first_page = session
3133 .execute_trusted_dynamic_grouped_query(&paged_query)
3134 .expect("SQL-free grouped first page should execute");
3135 assert_eq!(first_page.row_count, 1);
3136 assert_eq!(
3137 first_page.rows[0].group_key(),
3138 &[crate::value::OutputValue::nat64(9)]
3139 );
3140 let cursor = first_page
3141 .next_cursor
3142 .expect("first grouped page should return a continuation cursor");
3143 assert_query_diagnostic(
3144 session
3145 .execute_trusted_dynamic_grouped_query(
3146 &paged_query.clone().cursor(format!("{cursor}0")),
3147 )
3148 .expect_err("tampered grouped cursor must fail closed"),
3149 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
3150 icydb_diagnostic_code::ErrorOrigin::Cursor,
3151 icydb_diagnostic_code::DiagnosticDetail::QueryKind {
3152 kind: icydb_diagnostic_code::QueryErrorKind::InvalidContinuationCursor,
3153 },
3154 );
3155 let second_page = session
3156 .execute_trusted_dynamic_grouped_query(&paged_query.cursor(cursor))
3157 .expect("SQL-free grouped continuation should execute");
3158 assert_eq!(second_page.row_count, 1);
3159 assert_eq!(
3160 second_page.rows[0].group_key(),
3161 &[crate::value::OutputValue::nat64(10)]
3162 );
3163 assert_eq!(second_page.next_cursor, None);
3164 }
3165 }
3166}
3167
3168#[cfg(test)]
3169mod mixed_relation_batch_tests {
3170 use super::{DbSession, DynamicMutation, DynamicStructuralPatch, DynamicWriteCell};
3171 use crate::{
3172 db::{
3173 DynamicQuery, asc,
3174 data::DataStore,
3175 desc,
3176 index::IndexStore,
3177 query::expr::FilterExpr,
3178 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
3179 schema::{
3180 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
3181 FieldStorageDecode, FieldWriteManagement, LeafCodec, PersistedFieldSnapshot,
3182 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
3183 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
3184 SchemaFieldSlot, SchemaFieldWritePolicy, SchemaIndexId, SchemaInsertDefault,
3185 SchemaRowLayout, SchemaStore, SchemaVersion,
3186 accepted_schema_candidate_with_field_bindings_for_tests,
3187 },
3188 },
3189 error::{ErrorClass, ErrorOrigin},
3190 traits::{CanisterKind, Path},
3191 types::EntityTag,
3192 value::{InputValue, OutputValue},
3193 };
3194 use icydb_schema::FieldSourceKey;
3195 use std::{cell::RefCell, collections::BTreeMap};
3196
3197 const STORE_PATH: &str = "session::write::mixed_relation_batch_tests::Store";
3198 const ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node";
3199 const ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::id";
3200 const PARENT_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::parent_id";
3201 const CODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Node::code";
3202 const ENTITY_NAME: &str = "MixedRelationNode";
3203 const ENTITY_TAG: EntityTag = EntityTag::new(94);
3204 const OTHER_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other";
3205 const OTHER_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::id";
3206 const OTHER_VALUE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::value";
3207 const OTHER_NODE_SOURCE: &str = "session::write::mixed_relation_batch_tests::Other::node_id";
3208 const OTHER_ENTITY_NAME: &str = "MixedRelationOther";
3209 const OTHER_ENTITY_TAG: EntityTag = EntityTag::new(95);
3210 const CROSS_STORE_PATH: &str = "session::write::mixed_relation_batch_tests::OtherStore";
3211 const CROSS_ENTITY_SOURCE: &str = "session::write::mixed_relation_batch_tests::CrossStore";
3212 const CROSS_ID_SOURCE: &str = "session::write::mixed_relation_batch_tests::CrossStore::id";
3213 const CROSS_ENTITY_NAME: &str = "MixedCrossStore";
3214 const CROSS_ENTITY_TAG: EntityTag = EntityTag::new(2_000);
3215 fn batch_rows(results: &[crate::db::DynamicMutationResult]) -> Vec<Vec<OutputValue>> {
3216 results
3217 .iter()
3218 .flat_map(|result| result.rows.iter().cloned())
3219 .collect()
3220 }
3221
3222 struct TestCanister;
3223
3224 impl Path for TestCanister {
3225 const PATH: &'static str = "session::write::mixed_relation_batch_tests::Canister";
3226 }
3227
3228 impl CanisterKind for TestCanister {
3229 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3230 Ok(47)
3231 }
3232 const COMMIT_STABLE_KEY: &'static str = "icydb.mixed_relation_batch_tests.commit.v1";
3233 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3234 Ok(50)
3235 }
3236 const STARTUP_STABLE_KEY: &'static str =
3237 "icydb.mixed_relation_batch_tests.startup.control.v1";
3238 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
3239 Ok(48)
3240 }
3241 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
3242 "icydb.mixed_relation_batch_tests.integrity.progress.v1";
3243 }
3244
3245 thread_local! {
3246 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3247 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3248 static SCHEMA_STORE: RefCell<SchemaStore> =
3249 const { RefCell::new(SchemaStore::init_heap()) };
3250 static CROSS_DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
3251 static CROSS_INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
3252 static CROSS_SCHEMA_STORE: RefCell<SchemaStore> =
3253 const { RefCell::new(SchemaStore::init_heap()) };
3254 static STORE_REGISTRY: StoreRegistry = {
3255 let mut registry = StoreRegistry::new();
3256 registry.register_store(
3257 STORE_PATH,
3258 &DATA_STORE,
3259 &INDEX_STORE,
3260 &SCHEMA_STORE,
3261 StoreAllocationIdentities::absent(),
3262 StoreRuntimeStorageCapabilities::heap(),
3263 ).expect("mixed relation test store should register");
3264 registry.register_store(
3265 CROSS_STORE_PATH,
3266 &CROSS_DATA_STORE,
3267 &CROSS_INDEX_STORE,
3268 &CROSS_SCHEMA_STORE,
3269 StoreAllocationIdentities::absent(),
3270 StoreRuntimeStorageCapabilities::heap(),
3271 ).expect("cross-store test store should register");
3272 registry
3273 };
3274 }
3275
3276 fn source_key(source: &str) -> FieldSourceKey {
3277 FieldSourceKey::try_new(source).expect("mixed relation field source should admit")
3278 }
3279
3280 fn relation_snapshot() -> PersistedSchemaSnapshot {
3281 let fields = vec![
3282 PersistedFieldSnapshot::new_initial(
3283 FieldId::new(1),
3284 "id".to_string(),
3285 SchemaFieldSlot::new(0),
3286 AcceptedFieldKind::Nat64,
3287 Vec::new(),
3288 false,
3289 SchemaInsertDefault::None,
3290 FieldStorageDecode::ByKind,
3291 LeafCodec::Scalar(ScalarCodec::Nat64),
3292 ),
3293 PersistedFieldSnapshot::new_initial(
3294 FieldId::new(2),
3295 "parent_id".to_string(),
3296 SchemaFieldSlot::new(1),
3297 AcceptedFieldKind::Nat64,
3298 Vec::new(),
3299 true,
3300 SchemaInsertDefault::None,
3301 FieldStorageDecode::ByKind,
3302 LeafCodec::Scalar(ScalarCodec::Nat64),
3303 ),
3304 PersistedFieldSnapshot::new_initial(
3305 FieldId::new(3),
3306 "code".to_string(),
3307 SchemaFieldSlot::new(2),
3308 AcceptedFieldKind::Nat64,
3309 Vec::new(),
3310 false,
3311 SchemaInsertDefault::None,
3312 FieldStorageDecode::ByKind,
3313 LeafCodec::Scalar(ScalarCodec::Nat64),
3314 ),
3315 ];
3316 let relation = PersistedRelationEdgeSnapshot::new_direct(
3317 RelationId::new(1).expect("mixed relation identity should be non-zero"),
3318 "parent".to_string(),
3319 ENTITY_SOURCE.to_string(),
3320 vec![FieldId::new(2)],
3321 );
3322 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
3323 SchemaVersion::initial(),
3324 ENTITY_SOURCE.to_string(),
3325 ENTITY_NAME.to_string(),
3326 FieldId::new(1),
3327 SchemaRowLayout::initial(
3328 fields
3329 .iter()
3330 .map(|field| (field.id(), field.slot()))
3331 .collect(),
3332 ),
3333 fields,
3334 vec![PersistedIndexSnapshot::new(
3335 SchemaIndexId::new(1).expect("mixed unique index identity should be non-zero"),
3336 1,
3337 "by_code".to_string(),
3338 STORE_PATH.to_string(),
3339 true,
3340 PersistedIndexKeySnapshot::FieldPath(vec![PersistedIndexFieldPathSnapshot::new(
3341 FieldId::new(3),
3342 SchemaFieldSlot::new(2),
3343 vec!["code".to_string()],
3344 AcceptedFieldKind::Nat64,
3345 false,
3346 )]),
3347 None,
3348 )],
3349 )
3350 .with_relations(vec![relation]);
3351 let constraints = AcceptedConstraintCatalog::initial(
3352 snapshot.fields(),
3353 snapshot.indexes(),
3354 snapshot.relations(),
3355 )
3356 .expect("mixed relation constraints should close");
3357 snapshot.with_constraint_catalog(constraints)
3358 }
3359
3360 fn other_snapshot() -> PersistedSchemaSnapshot {
3361 let fields = vec![
3362 PersistedFieldSnapshot::new_initial(
3363 FieldId::new(1),
3364 "id".to_string(),
3365 SchemaFieldSlot::new(0),
3366 AcceptedFieldKind::Nat64,
3367 Vec::new(),
3368 false,
3369 SchemaInsertDefault::None,
3370 FieldStorageDecode::ByKind,
3371 LeafCodec::Scalar(ScalarCodec::Nat64),
3372 ),
3373 PersistedFieldSnapshot::new_initial(
3374 FieldId::new(2),
3375 "value".to_string(),
3376 SchemaFieldSlot::new(1),
3377 AcceptedFieldKind::Nat64,
3378 Vec::new(),
3379 false,
3380 SchemaInsertDefault::None,
3381 FieldStorageDecode::ByKind,
3382 LeafCodec::Scalar(ScalarCodec::Nat64),
3383 ),
3384 PersistedFieldSnapshot::new_initial(
3385 FieldId::new(3),
3386 "node_id".to_string(),
3387 SchemaFieldSlot::new(2),
3388 AcceptedFieldKind::Nat64,
3389 Vec::new(),
3390 true,
3391 SchemaInsertDefault::None,
3392 FieldStorageDecode::ByKind,
3393 LeafCodec::Scalar(ScalarCodec::Nat64),
3394 ),
3395 ];
3396 let relation = PersistedRelationEdgeSnapshot::new_direct(
3397 RelationId::new(1).expect("cross-entity relation identity should be non-zero"),
3398 "node".to_string(),
3399 ENTITY_SOURCE.to_string(),
3400 vec![FieldId::new(3)],
3401 );
3402 let snapshot = PersistedSchemaSnapshot::new(
3403 SchemaVersion::initial(),
3404 OTHER_ENTITY_SOURCE.to_string(),
3405 OTHER_ENTITY_NAME.to_string(),
3406 FieldId::new(1),
3407 SchemaRowLayout::initial(
3408 fields
3409 .iter()
3410 .map(|field| (field.id(), field.slot()))
3411 .collect(),
3412 ),
3413 fields,
3414 )
3415 .with_relations(vec![relation]);
3416 let constraints = AcceptedConstraintCatalog::initial(
3417 snapshot.fields(),
3418 snapshot.indexes(),
3419 snapshot.relations(),
3420 )
3421 .expect("cross-entity relation constraints should close");
3422 snapshot.with_constraint_catalog(constraints)
3423 }
3424
3425 fn bounded_entity_snapshot(index: usize) -> PersistedSchemaSnapshot {
3426 let fields = vec![
3427 PersistedFieldSnapshot::new_initial(
3428 FieldId::new(1),
3429 "id".to_string(),
3430 SchemaFieldSlot::new(0),
3431 AcceptedFieldKind::Nat64,
3432 Vec::new(),
3433 false,
3434 SchemaInsertDefault::None,
3435 FieldStorageDecode::ByKind,
3436 LeafCodec::Scalar(ScalarCodec::Nat64),
3437 ),
3438 PersistedFieldSnapshot::new_initial_with_write_policy(
3439 FieldId::new(2),
3440 "updated_at".to_string(),
3441 SchemaFieldSlot::new(1),
3442 AcceptedFieldKind::Timestamp,
3443 Vec::new(),
3444 false,
3445 SchemaInsertDefault::None,
3446 SchemaFieldWritePolicy::from_model_policies(
3447 None,
3448 Some(FieldWriteManagement::UpdatedAt),
3449 ),
3450 FieldStorageDecode::ByKind,
3451 LeafCodec::Scalar(ScalarCodec::Timestamp),
3452 ),
3453 ];
3454 PersistedSchemaSnapshot::new(
3455 SchemaVersion::initial(),
3456 format!("session::write::mixed_relation_batch_tests::Bounded{index}"),
3457 format!("MixedBounded{index}"),
3458 FieldId::new(1),
3459 SchemaRowLayout::initial(
3460 fields
3461 .iter()
3462 .map(|field| (field.id(), field.slot()))
3463 .collect(),
3464 ),
3465 fields,
3466 )
3467 }
3468
3469 fn cross_store_snapshot() -> PersistedSchemaSnapshot {
3470 let field = PersistedFieldSnapshot::new_initial(
3471 FieldId::new(1),
3472 "id".to_string(),
3473 SchemaFieldSlot::new(0),
3474 AcceptedFieldKind::Nat64,
3475 Vec::new(),
3476 false,
3477 SchemaInsertDefault::None,
3478 FieldStorageDecode::ByKind,
3479 LeafCodec::Scalar(ScalarCodec::Nat64),
3480 );
3481 PersistedSchemaSnapshot::new(
3482 SchemaVersion::initial(),
3483 CROSS_ENTITY_SOURCE.to_string(),
3484 CROSS_ENTITY_NAME.to_string(),
3485 FieldId::new(1),
3486 SchemaRowLayout::initial(vec![(field.id(), field.slot())]),
3487 vec![field],
3488 )
3489 }
3490
3491 fn initialize() -> DbSession<TestCanister> {
3492 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3493 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3494 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3495 CROSS_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
3496 CROSS_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
3497 CROSS_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
3498 let session = DbSession::<TestCanister>::new(
3499 &STORE_REGISTRY,
3500 &crate::db::RequestExecutionRoot::__new_runtime_root(),
3501 );
3502 session
3503 .db
3504 .drive_startup_recovery_page()
3505 .expect("mixed relation database should initialize");
3506 let mut snapshots = BTreeMap::from([
3507 (ENTITY_TAG, relation_snapshot()),
3508 (OTHER_ENTITY_TAG, other_snapshot()),
3509 ]);
3510 let mut field_bindings = BTreeMap::from([
3511 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
3512 ((ENTITY_TAG, source_key(PARENT_SOURCE)), FieldId::new(2)),
3513 ((ENTITY_TAG, source_key(CODE_SOURCE)), FieldId::new(3)),
3514 (
3515 (OTHER_ENTITY_TAG, source_key(OTHER_ID_SOURCE)),
3516 FieldId::new(1),
3517 ),
3518 (
3519 (OTHER_ENTITY_TAG, source_key(OTHER_VALUE_SOURCE)),
3520 FieldId::new(2),
3521 ),
3522 (
3523 (OTHER_ENTITY_TAG, source_key(OTHER_NODE_SOURCE)),
3524 FieldId::new(3),
3525 ),
3526 ]);
3527 for index in 0..65 {
3528 let tag = EntityTag::new(1_000 + index as u64);
3529 snapshots.insert(tag, bounded_entity_snapshot(index));
3530 field_bindings.insert(
3531 (
3532 tag,
3533 source_key(
3534 format!("session::write::mixed_relation_batch_tests::Bounded{index}::id")
3535 .as_str(),
3536 ),
3537 ),
3538 FieldId::new(1),
3539 );
3540 field_bindings.insert(
3541 (
3542 tag,
3543 source_key(
3544 format!(
3545 "session::write::mixed_relation_batch_tests::Bounded{index}::updated_at"
3546 )
3547 .as_str(),
3548 ),
3549 ),
3550 FieldId::new(2),
3551 );
3552 }
3553 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3554 STORE_PATH,
3555 AcceptedSchemaRevision::INITIAL,
3556 snapshots,
3557 field_bindings,
3558 );
3559 let store = session
3560 .db
3561 .store_handle(STORE_PATH)
3562 .expect("mixed relation store should resolve");
3563 crate::db::commit::publish_accepted_schema_candidate(
3564 STORE_PATH,
3565 store,
3566 AcceptedSchemaRevision::NONE,
3567 &candidate,
3568 )
3569 .expect("mixed relation candidate should publish");
3570 let cross_candidate = accepted_schema_candidate_with_field_bindings_for_tests(
3571 CROSS_STORE_PATH,
3572 AcceptedSchemaRevision::INITIAL,
3573 BTreeMap::from([(CROSS_ENTITY_TAG, cross_store_snapshot())]),
3574 BTreeMap::from([(
3575 (CROSS_ENTITY_TAG, source_key(CROSS_ID_SOURCE)),
3576 FieldId::new(1),
3577 )]),
3578 );
3579 let cross_store = session
3580 .db
3581 .store_handle(CROSS_STORE_PATH)
3582 .expect("cross-store fixture should resolve");
3583 crate::db::commit::publish_accepted_schema_candidate(
3584 CROSS_STORE_PATH,
3585 cross_store,
3586 AcceptedSchemaRevision::NONE,
3587 &cross_candidate,
3588 )
3589 .expect("cross-store candidate should publish");
3590 session
3591 }
3592
3593 fn patch(id: Option<u64>, parent: Option<u64>, code: Option<u64>) -> DynamicStructuralPatch {
3594 let mut fields = Vec::new();
3595 if let Some(id) = id {
3596 fields.push((
3597 "id".to_string(),
3598 DynamicWriteCell::Value(InputValue::nat64(id)),
3599 ));
3600 }
3601 fields.push((
3602 "parent_id".to_string(),
3603 parent.map_or(DynamicWriteCell::Null, |parent| {
3604 DynamicWriteCell::Value(InputValue::nat64(parent))
3605 }),
3606 ));
3607 if let Some(code) = code {
3608 fields.push((
3609 "code".to_string(),
3610 DynamicWriteCell::Value(InputValue::nat64(code)),
3611 ));
3612 }
3613 DynamicStructuralPatch::new(fields)
3614 }
3615
3616 fn insert(id: u64, parent: Option<u64>) -> DynamicMutation {
3617 insert_with_code(id, parent, id)
3618 }
3619
3620 fn insert_with_code(id: u64, parent: Option<u64>, code: u64) -> DynamicMutation {
3621 DynamicMutation::Insert {
3622 entity: ENTITY_NAME.to_string(),
3623 patch: patch(Some(id), parent, Some(code)),
3624 }
3625 }
3626
3627 fn update_parent(id: u64, parent: Option<u64>) -> DynamicMutation {
3628 DynamicMutation::Update {
3629 entity: ENTITY_NAME.to_string(),
3630 key: InputValue::nat64(id),
3631 patch: patch(None, parent, None),
3632 }
3633 }
3634
3635 fn update_code(id: u64, code: u64) -> DynamicMutation {
3636 DynamicMutation::Update {
3637 entity: ENTITY_NAME.to_string(),
3638 key: InputValue::nat64(id),
3639 patch: DynamicStructuralPatch::new(vec![(
3640 "code".to_string(),
3641 DynamicWriteCell::Value(InputValue::nat64(code)),
3642 )]),
3643 }
3644 }
3645
3646 fn delete(id: u64) -> DynamicMutation {
3647 DynamicMutation::Delete {
3648 entity: ENTITY_NAME.to_string(),
3649 key: InputValue::nat64(id),
3650 }
3651 }
3652
3653 fn expected_row(id: u64, parent: Option<u64>) -> Vec<OutputValue> {
3654 expected_row_with_code(id, parent, id)
3655 }
3656
3657 fn expected_row_with_code(id: u64, parent: Option<u64>, code: u64) -> Vec<OutputValue> {
3658 vec![
3659 OutputValue::nat64(id),
3660 parent.map_or_else(OutputValue::null, OutputValue::nat64),
3661 OutputValue::nat64(code),
3662 ]
3663 }
3664
3665 fn other_patch(id: Option<u64>, value: u64) -> DynamicStructuralPatch {
3666 other_patch_with_node(id, value, None)
3667 }
3668
3669 fn other_patch_with_node(
3670 id: Option<u64>,
3671 value: u64,
3672 node_id: Option<u64>,
3673 ) -> DynamicStructuralPatch {
3674 let mut fields = Vec::new();
3675 if let Some(id) = id {
3676 fields.push((
3677 "id".to_string(),
3678 DynamicWriteCell::Value(InputValue::nat64(id)),
3679 ));
3680 }
3681 fields.push((
3682 "value".to_string(),
3683 DynamicWriteCell::Value(InputValue::nat64(value)),
3684 ));
3685 fields.push((
3686 "node_id".to_string(),
3687 node_id.map_or(DynamicWriteCell::Null, |node_id| {
3688 DynamicWriteCell::Value(InputValue::nat64(node_id))
3689 }),
3690 ));
3691 DynamicStructuralPatch::new(fields)
3692 }
3693
3694 fn assert_relation_violation(error: &crate::error::InternalError) {
3695 assert!(error.diagnostic_facts().contains(&(
3696 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
3697 icydb_diagnostic_code::DiagnosticConstraintKind::Relation.raw(),
3698 )));
3699 }
3700
3701 #[test]
3702 fn live_pages_resume_mixed_projection_from_authenticated_hidden_order_values() {
3703 let session = initialize();
3704 session
3705 .execute_trusted_dynamic_mutation_batch(vec![
3706 insert_with_code(1, None, 10),
3707 insert_with_code(2, Some(1), 20),
3708 insert_with_code(3, None, 30),
3709 ])
3710 .expect("live-page rows should insert");
3711 let query = DynamicQuery::new(ENTITY_NAME)
3712 .select(["id"])
3713 .order_by(desc("code"));
3714
3715 let first = session
3716 .execute_public_live_page(&query, None)
3717 .expect("initial live page should execute");
3718 assert_eq!(
3719 first.rows,
3720 vec![vec![OutputValue::nat64(3)], vec![OutputValue::nat64(2)]]
3721 );
3722 let cursor = first
3723 .continuation
3724 .as_deref()
3725 .expect("unreturned matching row should produce continuation");
3726 let second = session
3727 .execute_public_live_page(&query, Some(cursor))
3728 .expect("authenticated live continuation should resume");
3729 assert_eq!(second.rows, vec![vec![OutputValue::nat64(1)]]);
3730 assert_eq!(second.continuation, None);
3731
3732 let total_limit = session
3733 .execute_public_live_page(&query.clone().limit(2), None)
3734 .expect("total live-page limit should execute");
3735 assert_eq!(
3736 total_limit.rows,
3737 vec![vec![OutputValue::nat64(3)], vec![OutputValue::nat64(2)]],
3738 );
3739 assert_eq!(
3740 total_limit.continuation, None,
3741 "query LIMIT is a total traversal window rather than a page size",
3742 );
3743
3744 let three_row_window = query.clone().limit(3);
3745 let limited_first = session
3746 .execute_public_live_page(&three_row_window, None)
3747 .expect("first total-window page should execute");
3748 let limited_cursor = limited_first
3749 .continuation
3750 .as_deref()
3751 .expect("a partially consumed total window should continue");
3752 let limited_second = session
3753 .execute_public_live_page(&three_row_window, Some(limited_cursor))
3754 .expect("remaining total window should preserve the plan signature");
3755 assert_eq!(limited_second.rows, vec![vec![OutputValue::nat64(1)]]);
3756 assert_eq!(limited_second.continuation, None);
3757
3758 let mixed_order = DynamicQuery::new(ENTITY_NAME)
3759 .select(["id"])
3760 .order_by(desc("parent_id"))
3761 .order_by(asc("id"));
3762 let mixed_first = session
3763 .execute_trusted_live_page(&mixed_order, None)
3764 .expect("mixed-direction nullable order should execute");
3765 assert_eq!(
3766 mixed_first.rows,
3767 vec![vec![OutputValue::nat64(2)], vec![OutputValue::nat64(1)]],
3768 );
3769 let mixed_cursor = mixed_first
3770 .continuation
3771 .as_deref()
3772 .expect("duplicate null order values should retain continuation");
3773 let mixed_second = session
3774 .execute_trusted_live_page(&mixed_order, Some(mixed_cursor))
3775 .expect("mixed-direction nullable order should resume");
3776 assert_eq!(mixed_second.rows, vec![vec![OutputValue::nat64(3)]]);
3777 assert_eq!(mixed_second.continuation, None);
3778
3779 let mismatched_window = session
3780 .execute_public_live_page(&query.clone().limit(3), Some(cursor))
3781 .expect_err("a changed total limit must invalidate the continuation");
3782 assert_eq!(
3783 mismatched_window.diagnostic_code(),
3784 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
3785 );
3786
3787 let mut tampered = cursor.as_bytes().to_vec();
3788 let last = tampered.len().saturating_sub(1);
3789 tampered[last] = if tampered[last] == b'0' { b'1' } else { b'0' };
3790 let tampered = String::from_utf8(tampered).expect("Base64 cursor should remain UTF-8");
3791 let error = session
3792 .execute_public_live_page(&query, Some(tampered.as_str()))
3793 .expect_err("tampered cursor must fail closed");
3794 assert_eq!(
3795 error.diagnostic_code(),
3796 icydb_diagnostic_code::DiagnosticCode::QueryInvalidContinuationCursor,
3797 );
3798 }
3799
3800 #[test]
3801 fn live_pages_resume_across_changed_output_work_envelopes() {
3802 let session = initialize();
3803 session
3804 .execute_trusted_dynamic_mutation_batch(vec![
3805 insert(1, None),
3806 insert(2, None),
3807 insert(3, None),
3808 ])
3809 .expect("output-envelope rows should insert");
3810 let query = DynamicQuery::new(ENTITY_NAME)
3811 .select(["id"])
3812 .order_by(desc("code"));
3813 let first = session
3814 .execute_trusted_live_page_with_result_bytes_limit_for_tests(&query, None, 32)
3815 .expect("small output envelope should publish the first bounded page");
3816 assert_eq!(first.rows, vec![vec![OutputValue::nat64(3)]]);
3817 let continuation = first
3818 .continuation
3819 .expect("small output envelope should leave authenticated progress");
3820
3821 let second = session
3822 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
3823 &query,
3824 Some(continuation.as_str()),
3825 64,
3826 )
3827 .unwrap_or_else(|error| {
3828 panic!(
3829 "larger output envelope should resume the same query: {error:?}, facts={:?}",
3830 error.diagnostic_facts(),
3831 )
3832 });
3833 assert_eq!(
3834 second.rows,
3835 vec![vec![OutputValue::nat64(2)], vec![OutputValue::nat64(1)]]
3836 );
3837 let second_continuation = second
3838 .continuation
3839 .as_deref()
3840 .expect("an exact-full page still needs to prove physical exhaustion");
3841 assert_ne!(first.work.envelope_identity, second.work.envelope_identity);
3842
3843 let terminal = session
3844 .execute_trusted_live_page_with_result_bytes_limit_for_tests(
3845 &query,
3846 Some(second_continuation),
3847 48,
3848 )
3849 .expect("a third finite envelope should prove exhaustion without replaying rows");
3850 assert!(terminal.rows.is_empty());
3851 assert_eq!(terminal.continuation, None);
3852 assert_ne!(
3853 second.work.envelope_identity,
3854 terminal.work.envelope_identity
3855 );
3856
3857 assert_eq!(
3858 [first.rows, second.rows, terminal.rows].concat(),
3859 vec![
3860 vec![OutputValue::nat64(3)],
3861 vec![OutputValue::nat64(2)],
3862 vec![OutputValue::nat64(1)],
3863 ]
3864 );
3865 }
3866
3867 #[test]
3868 fn distinct_live_pages_resume_adjacent_groups_and_global_replay_end_to_end() {
3869 let session = initialize();
3870 session
3871 .execute_trusted_dynamic_mutation_batch(vec![
3872 insert(1, None),
3873 insert(2, None),
3874 insert(3, Some(1)),
3875 insert(4, Some(2)),
3876 insert(5, Some(1)),
3877 insert(6, Some(3)),
3878 insert(7, Some(2)),
3879 ])
3880 .expect("DISTINCT continuation rows should insert atomically");
3881
3882 let adjacent = DynamicQuery::new(ENTITY_NAME)
3883 .select(["parent_id"])
3884 .order_by(asc("parent_id"))
3885 .order_by(asc("id"))
3886 .distinct_for_internal_execution();
3887 let global = DynamicQuery::new(ENTITY_NAME)
3888 .select(["parent_id"])
3889 .order_by(asc("id"))
3890 .distinct_for_internal_execution();
3891
3892 let traverse = |query: &DynamicQuery, strategy: &str| {
3893 let mut continuation = None;
3894 let mut rows = Vec::new();
3895 let mut cursors = std::collections::BTreeSet::new();
3896 let mut pages = 0_u32;
3897 let mut entries_visited = 0_u64;
3898 loop {
3899 let page = session
3900 .execute_trusted_live_page(query, continuation.as_deref())
3901 .unwrap_or_else(|error| {
3902 panic!("{strategy} DISTINCT page should execute: {error:?}")
3903 });
3904 pages = pages.saturating_add(1);
3905 entries_visited = entries_visited.saturating_add(page.work.entries_visited);
3906 assert_eq!(page.row_count as usize, page.rows.len());
3907 assert_eq!(page.work.result_rows, page.row_count);
3908 rows.extend(page.rows);
3909 let Some(cursor) = page.continuation else {
3910 break;
3911 };
3912 assert!(
3913 cursors.insert(cursor.clone()),
3914 "{strategy} DISTINCT continuation must advance monotonically",
3915 );
3916 continuation = Some(cursor);
3917 assert!(pages < 8, "{strategy} DISTINCT traversal must terminate");
3918 }
3919
3920 (rows, pages, entries_visited)
3921 };
3922
3923 let expected = vec![
3924 vec![OutputValue::null()],
3925 vec![OutputValue::nat64(1)],
3926 vec![OutputValue::nat64(2)],
3927 vec![OutputValue::nat64(3)],
3928 ];
3929 let (adjacent_rows, adjacent_pages, adjacent_entries) = traverse(&adjacent, "adjacent");
3930 let (global_rows, global_pages, global_entries) = traverse(&global, "global");
3931
3932 assert_eq!(adjacent_rows, expected);
3933 assert_eq!(global_rows, expected);
3934 assert_eq!(adjacent_pages, 2);
3935 assert_eq!(global_pages, 2);
3936 assert!(adjacent_entries > 0);
3937 assert!(global_entries > 0);
3938 }
3939
3940 #[test]
3941 fn selective_live_pages_publish_monotonic_empty_physical_progress() {
3942 let session = initialize();
3943 session
3944 .execute_trusted_dynamic_mutation_batch(
3945 (1..=9)
3946 .map(|id| {
3947 let parent = match id {
3948 1 => Some(2),
3949 9 => Some(1),
3950 _ => None,
3951 };
3952 insert(id, parent)
3953 })
3954 .collect(),
3955 )
3956 .expect("selective live-page rows should insert");
3957 let query = DynamicQuery::new(ENTITY_NAME)
3958 .select(["id"])
3959 .filter(FilterExpr::eq("parent_id", 1_u64))
3960 .order_by(asc("id"))
3961 .limit(1);
3962
3963 let first = session
3964 .execute_trusted_live_page(&query, None)
3965 .expect("first selective page should stop with physical progress");
3966 assert!(first.rows.is_empty());
3967 assert_eq!(first.work.entries_visited, 4);
3968 let first_cursor = first
3969 .continuation
3970 .expect("filtered physical progress must return a continuation");
3971
3972 let second = session
3973 .execute_trusted_live_page(&query, Some(first_cursor.as_str()))
3974 .expect("second selective page should resume after the first physical frontier");
3975 assert!(second.rows.is_empty());
3976 assert_eq!(second.work.entries_visited, 4);
3977 let second_cursor = second
3978 .continuation
3979 .expect("second filtered frontier must remain resumable");
3980 assert_ne!(second_cursor, first_cursor);
3981
3982 let third = session
3983 .execute_trusted_live_page(&query, Some(second_cursor.as_str()))
3984 .expect("final selective page should return the late match");
3985 assert_eq!(third.rows, vec![vec![OutputValue::nat64(9)]]);
3986 assert_eq!(third.work.entries_visited, 1);
3987 assert_eq!(third.continuation, None);
3988
3989 let descending = DynamicQuery::new(ENTITY_NAME)
3990 .select(["id"])
3991 .filter(FilterExpr::eq("parent_id", 2_u64))
3992 .order_by(desc("id"))
3993 .limit(1);
3994 let descending_first = session
3995 .execute_trusted_live_page(&descending, None)
3996 .expect("descending selective page should stop with physical progress");
3997 assert!(descending_first.rows.is_empty());
3998 let descending_first_cursor = descending_first
3999 .continuation
4000 .expect("descending filtered progress must return a continuation");
4001 let descending_second = session
4002 .execute_trusted_live_page(&descending, Some(descending_first_cursor.as_str()))
4003 .expect("descending progress should resume after its physical frontier");
4004 assert!(descending_second.rows.is_empty());
4005 let descending_second_cursor = descending_second
4006 .continuation
4007 .expect("descending second frontier must remain resumable");
4008 assert_ne!(descending_second_cursor, descending_first_cursor);
4009 let descending_third = session
4010 .execute_trusted_live_page(&descending, Some(descending_second_cursor.as_str()))
4011 .expect("descending final page should return the late match");
4012 assert_eq!(descending_third.rows, vec![vec![OutputValue::nat64(1)]]);
4013 assert_eq!(descending_third.continuation, None);
4014 }
4015
4016 #[test]
4017 fn accepted_relation_edges_drive_catalog_and_describe_introspection() {
4018 let session = initialize();
4019 let entities = session
4020 .show_entities()
4021 .expect("accepted entity catalog should resolve");
4022 let source = entities
4023 .iter()
4024 .find(|entity| entity.entity_name() == ENTITY_NAME)
4025 .expect("relation source should be listed");
4026 assert_eq!(source.relations(), 1);
4027
4028 let description = session
4029 .try_describe_entity_by_name(ENTITY_NAME)
4030 .expect("accepted relation source should describe");
4031 let [relation] = description.relations() else {
4032 panic!("accepted relation edge should produce one relation row");
4033 };
4034 assert_eq!(relation.field(), "parent_id");
4035 assert_eq!(relation.target_path(), ENTITY_SOURCE);
4036 assert_eq!(relation.target_entity_name(), ENTITY_NAME);
4037 assert_eq!(relation.target_store_path(), STORE_PATH);
4038 assert_eq!(
4039 relation.cardinality(),
4040 crate::db::EntityRelationCardinality::Single,
4041 );
4042 }
4043
4044 #[test]
4045 fn mixed_relation_validation_uses_the_complete_final_row_overlay() {
4046 let session = initialize();
4047 session
4048 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, Some(1))])
4049 .expect("the initial relation should commit");
4050
4051 let blocked = session
4052 .execute_trusted_dynamic_mutation(&delete(1))
4053 .expect_err("an unaffected committed source must block target deletion");
4054 assert_relation_violation(&blocked);
4055
4056 let deleted = session
4057 .execute_trusted_dynamic_mutation_batch(vec![delete(2), delete(1)])
4058 .expect("a source and its target should delete atomically");
4059 assert_eq!(
4060 batch_rows(&deleted),
4061 vec![expected_row(2, Some(1)), expected_row(1, None)],
4062 );
4063
4064 session
4065 .execute_trusted_dynamic_mutation_batch(vec![insert(3, None), insert(4, Some(3))])
4066 .expect("the update-away fixture should commit");
4067 let updated_away = session
4068 .execute_trusted_dynamic_mutation_batch(vec![update_parent(4, None), delete(3)])
4069 .expect("an updated final source may release a deleted target");
4070 assert_eq!(
4071 batch_rows(&updated_away),
4072 vec![expected_row(4, None), expected_row(3, None)],
4073 );
4074
4075 session
4076 .execute_trusted_dynamic_mutation_batch(vec![insert(5, None), insert(6, Some(5))])
4077 .expect("the retained-reference fixture should commit");
4078 let retained = session
4079 .execute_trusted_dynamic_mutation_batch(vec![update_parent(6, Some(5)), delete(5)])
4080 .expect_err("a final updated source must still block target deletion");
4081 assert_relation_violation(&retained);
4082
4083 session
4084 .execute_trusted_dynamic_mutation(&insert(7, None))
4085 .expect("the inserted-reference fixture target should commit");
4086 let inserted_reference = session
4087 .execute_trusted_dynamic_mutation_batch(vec![insert(8, Some(7)), delete(7)])
4088 .expect_err("a final inserted source must not reference a deleted target");
4089 assert_relation_violation(&inserted_reference);
4090
4091 let inserted_target = session
4092 .execute_trusted_dynamic_mutation_batch(vec![insert(10, Some(9)), insert(9, None)])
4093 .expect("an inserted relation should see its batch-final target");
4094 assert_eq!(
4095 batch_rows(&inserted_target),
4096 vec![expected_row(10, Some(9)), expected_row(9, None)],
4097 );
4098
4099 session
4100 .execute_trusted_dynamic_mutation(&insert(11, None))
4101 .expect("the updated-reference fixture source should commit");
4102 let updated_target = session
4103 .execute_trusted_dynamic_mutation_batch(vec![
4104 update_parent(11, Some(12)),
4105 insert(12, None),
4106 ])
4107 .expect("an updated relation should see its batch-final target");
4108 assert_eq!(
4109 batch_rows(&updated_target),
4110 vec![expected_row(11, Some(12)), expected_row(12, None)],
4111 );
4112 }
4113
4114 #[test]
4115 fn mixed_batch_commits_cross_entity_then_rejects_late_failures_atomically() {
4116 let session = initialize();
4117 session
4118 .execute_trusted_dynamic_mutation(&insert(1, None))
4119 .expect("the primary mixed fixture row should commit");
4120 session
4121 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
4122 entity: OTHER_ENTITY_NAME.to_string(),
4123 patch: other_patch(Some(1), 10),
4124 })
4125 .expect("the secondary mixed fixture row should commit");
4126
4127 let mixed_entity = session
4128 .execute_trusted_dynamic_mutation_batch(vec![
4129 update_code(1, 11),
4130 DynamicMutation::Update {
4131 entity: OTHER_ENTITY_NAME.to_string(),
4132 key: InputValue::nat64(1),
4133 patch: other_patch(None, 11),
4134 },
4135 ])
4136 .expect("one atomic batch may span accepted entities in the same store");
4137 assert_eq!(
4138 batch_rows(&mixed_entity),
4139 vec![
4140 expected_row_with_code(1, None, 11),
4141 vec![
4142 OutputValue::nat64(1),
4143 OutputValue::nat64(11),
4144 OutputValue::null(),
4145 ],
4146 ],
4147 );
4148
4149 let missing = session
4150 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 12), delete(99)])
4151 .expect_err("a late missing delete must reject the earlier staged update");
4152 assert_eq!(missing.class(), ErrorClass::NotFound);
4153
4154 session
4155 .execute_trusted_dynamic_mutation(&insert(2, None))
4156 .expect("the collision fixture should commit");
4157 let collision = session
4158 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 13), insert(2, None)])
4159 .expect_err("an insert collision must reject the earlier staged update");
4160 assert_eq!(collision.class(), ErrorClass::Conflict);
4161 let failures_unchanged = session
4162 .execute_trusted_dynamic_mutation(&update_code(1, 11))
4163 .expect("failed batches must preserve the original unique value");
4164 assert_eq!(failures_unchanged.affected_rows, 0);
4165
4166 let replaced = session
4167 .execute_trusted_dynamic_mutation_batch(vec![
4168 update_code(1, 14),
4169 DynamicMutation::Replace {
4170 entity: ENTITY_NAME.to_string(),
4171 key: InputValue::nat64(99),
4172 patch: patch(None, None, Some(99)),
4173 },
4174 ])
4175 .expect("ordinary caller-key replace should insert its absent final row");
4176 assert_eq!(
4177 batch_rows(&replaced),
4178 vec![
4179 expected_row_with_code(1, None, 14),
4180 expected_row_with_code(99, None, 99),
4181 ],
4182 );
4183
4184 let unchanged = session
4185 .execute_trusted_dynamic_mutation(&update_code(1, 14))
4186 .expect("the successful mixed replace must publish its preceding update");
4187 assert_eq!(unchanged.affected_rows, 0);
4188 let other_unchanged = session
4189 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4190 entity: OTHER_ENTITY_NAME.to_string(),
4191 key: InputValue::nat64(1),
4192 patch: other_patch(None, 11),
4193 })
4194 .expect("the cross-entity commit must publish the secondary row");
4195 assert_eq!(other_unchanged.affected_rows, 0);
4196 }
4197
4198 #[test]
4199 fn structural_unknown_root_and_dotted_subpath_reject_before_commit() {
4200 let session = initialize();
4201 session
4202 .execute_trusted_dynamic_mutation_batch(vec![insert(1, None), insert(2, None)])
4203 .expect("structural rejection fixtures should commit");
4204
4205 let unknown_root = session
4206 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
4207 entity: ENTITY_NAME.to_string(),
4208 key: InputValue::nat64(1),
4209 patch: DynamicStructuralPatch::new(vec![(
4210 "missing".to_string(),
4211 DynamicWriteCell::Value(InputValue::nat64(10)),
4212 )]),
4213 })
4214 .expect_err("an unknown structural root field must reject");
4215 assert_eq!(unknown_root.class(), ErrorClass::Unsupported);
4216 assert_eq!(unknown_root.origin(), ErrorOrigin::Executor);
4217 assert_eq!(
4218 unknown_root.diagnostic_code(),
4219 icydb_diagnostic_code::DiagnosticCode::RuntimeUnsupported,
4220 );
4221 assert!(unknown_root.diagnostic_facts().is_empty());
4222
4223 let dotted_subpath = session
4224 .execute_trusted_dynamic_mutation_batch(vec![
4225 update_code(1, 11),
4226 DynamicMutation::Update {
4227 entity: ENTITY_NAME.to_string(),
4228 key: InputValue::nat64(2),
4229 patch: DynamicStructuralPatch::new(vec![(
4230 "code.value".to_string(),
4231 DynamicWriteCell::Value(InputValue::nat64(12)),
4232 )]),
4233 },
4234 ])
4235 .expect_err("a dotted structural subpath must reject the complete batch");
4236 assert_eq!(dotted_subpath.class(), ErrorClass::Unsupported);
4237 assert_eq!(dotted_subpath.origin(), ErrorOrigin::Executor);
4238 assert_eq!(
4239 dotted_subpath.diagnostic_code(),
4240 icydb_diagnostic_code::DiagnosticCode::RuntimeUnsupported,
4241 );
4242 assert!(dotted_subpath.diagnostic_facts().is_empty());
4243
4244 let unchanged = session
4245 .execute_trusted_dynamic_mutation(&update_code(1, 1))
4246 .expect("the rejected batch must preserve the earlier row");
4247 assert_eq!(unchanged.affected_rows, 0);
4248
4249 let whole_field = session
4250 .execute_trusted_dynamic_mutation(&update_code(2, 12))
4251 .expect("a complete root-field update must remain supported");
4252 assert_eq!(whole_field.affected_rows, 1);
4253 assert_eq!(whole_field.rows, vec![expected_row_with_code(2, None, 12)]);
4254 }
4255
4256 #[test]
4257 fn cross_entity_relations_observe_one_complete_final_overlay() {
4258 let session = initialize();
4259 let inserted = session
4260 .execute_trusted_dynamic_mutation_batch(vec![
4261 DynamicMutation::Insert {
4262 entity: OTHER_ENTITY_NAME.to_string(),
4263 patch: other_patch_with_node(Some(20), 200, Some(42)),
4264 },
4265 insert(42, None),
4266 ])
4267 .expect("a source may precede its same-batch target in another entity");
4268 assert_eq!(inserted.len(), 2);
4269
4270 session
4271 .execute_trusted_dynamic_mutation_batch(vec![
4272 delete(42),
4273 DynamicMutation::Delete {
4274 entity: OTHER_ENTITY_NAME.to_string(),
4275 key: InputValue::nat64(20),
4276 },
4277 ])
4278 .expect("a target and cross-entity source may delete in either request order");
4279
4280 session
4281 .execute_trusted_dynamic_mutation_batch(vec![
4282 insert(43, None),
4283 DynamicMutation::Insert {
4284 entity: OTHER_ENTITY_NAME.to_string(),
4285 patch: other_patch_with_node(Some(21), 210, Some(43)),
4286 },
4287 ])
4288 .expect("the retained cross-entity relation fixture should commit");
4289 let blocked = session
4290 .execute_trusted_dynamic_mutation_batch(vec![delete(43)])
4291 .expect_err("a retained source in another entity must protect its target");
4292 assert_relation_violation(&blocked);
4293 }
4294
4295 #[test]
4296 fn mixed_batch_admits_64_entities_with_one_timestamp_and_rejects_the_65th() {
4297 let session = initialize();
4298 let requests = (0..64)
4299 .map(|index| DynamicMutation::Insert {
4300 entity: format!("MixedBounded{index}"),
4301 patch: DynamicStructuralPatch::new(vec![(
4302 "id".to_string(),
4303 DynamicWriteCell::Value(InputValue::nat64(1)),
4304 )]),
4305 })
4306 .collect();
4307 let admitted = session
4308 .execute_trusted_dynamic_mutation_batch(requests)
4309 .expect("exactly 64 same-store entities should admit");
4310 assert_eq!(admitted.len(), 64);
4311 let timestamps = admitted
4312 .iter()
4313 .map(|result| {
4314 result
4315 .rows
4316 .first()
4317 .and_then(|row| row.get(1))
4318 .expect("every bounded entity should return its managed timestamp")
4319 })
4320 .collect::<Vec<_>>();
4321 assert!(timestamps.windows(2).all(|pair| pair[0] == pair[1]));
4322
4323 let over_limit = (0..65)
4324 .map(|index| DynamicMutation::Insert {
4325 entity: format!("MixedBounded{index}"),
4326 patch: DynamicStructuralPatch::new(vec![(
4327 "id".to_string(),
4328 DynamicWriteCell::Value(InputValue::nat64(2)),
4329 )]),
4330 })
4331 .collect();
4332 let error = session
4333 .execute_trusted_dynamic_mutation_batch(over_limit)
4334 .expect_err("the 65th distinct entity must reject before staging");
4335 assert_eq!(error.class(), ErrorClass::Unsupported);
4336 assert_eq!(
4337 error.diagnostic_facts(),
4338 vec![
4339 (icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 65),
4340 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 64),
4341 ],
4342 );
4343 }
4344
4345 #[test]
4346 fn mixed_batch_rejects_a_cross_store_item_with_bounded_tags() {
4347 let session = initialize();
4348 let error = session
4349 .execute_trusted_dynamic_mutation_batch(vec![
4350 insert(70, None),
4351 DynamicMutation::Insert {
4352 entity: CROSS_ENTITY_NAME.to_string(),
4353 patch: DynamicStructuralPatch::new(vec![(
4354 "id".to_string(),
4355 DynamicWriteCell::Value(InputValue::nat64(70)),
4356 )]),
4357 },
4358 ])
4359 .expect_err("a structural batch must remain inside one accepted store");
4360 assert_eq!(error.class(), ErrorClass::Conflict);
4361 assert_eq!(
4362 error.diagnostic_facts(),
4363 vec![
4364 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1),
4365 (
4366 icydb_diagnostic_code::DiagnosticFactTag::ExpectedEntityTag,
4367 ENTITY_TAG.value(),
4368 ),
4369 (
4370 icydb_diagnostic_code::DiagnosticFactTag::ActualEntityTag,
4371 CROSS_ENTITY_TAG.value(),
4372 ),
4373 ],
4374 );
4375 session
4376 .execute_trusted_dynamic_mutation(&insert(70, None))
4377 .expect("cross-store rejection must publish no first-item effect");
4378 }
4379
4380 #[test]
4381 fn mixed_batch_unique_swap_and_delete_release_use_the_final_overlay() {
4382 let session = initialize();
4383 session
4384 .execute_trusted_dynamic_mutation_batch(vec![
4385 insert_with_code(1, None, 10),
4386 insert_with_code(2, None, 20),
4387 ])
4388 .expect("the unique-overlay fixture should commit");
4389
4390 let conflict = session
4391 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 30), update_code(2, 30)])
4392 .expect_err("the final row must still reject a duplicate unique membership");
4393 assert_eq!(
4394 conflict.diagnostic().error_code(),
4395 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONSTRAINT_VIOLATION,
4396 );
4397 for (id, code) in [(1, 10), (2, 20)] {
4398 let unchanged = session
4399 .execute_trusted_dynamic_mutation(&update_code(id, code))
4400 .expect("rejected preflight must preserve both original unique values");
4401 assert_eq!(unchanged.affected_rows, 0);
4402 }
4403
4404 let swapped = session
4405 .execute_trusted_dynamic_mutation_batch(vec![update_code(1, 20), update_code(2, 10)])
4406 .expect("two final rows should atomically swap unique memberships");
4407 assert_eq!(
4408 batch_rows(&swapped),
4409 vec![
4410 expected_row_with_code(1, None, 20),
4411 expected_row_with_code(2, None, 10),
4412 ],
4413 );
4414
4415 let released = session
4416 .execute_trusted_dynamic_mutation_batch(vec![delete(1), insert_with_code(3, None, 20)])
4417 .expect("a delete should release unique membership to a final inserted row");
4418 assert_eq!(
4419 batch_rows(&released),
4420 vec![
4421 expected_row_with_code(1, None, 20),
4422 expected_row_with_code(3, None, 20),
4423 ],
4424 );
4425 }
4426}
4427
4428#[cfg(test)]
4429mod identity_pre_key_tests {
4430 #[cfg(feature = "sql")]
4431 mod grouped_count_tests;
4432 mod nested_relation_tests;
4433 mod replay_construction_tests;
4434 mod result_boundary_tests;
4435 #[cfg(feature = "sql")]
4436 mod schema_publication_tests;
4437
4438 use super::DynamicTypedEntityBinding;
4439 use super::{
4440 AcceptedMutationIntentPatch, AcceptedRowLayoutRuntimeContract, AcceptedStructuralMutation,
4441 AcceptedStructuralMutationPacking, AcceptedStructuralMutationStagedAdmission,
4442 AcceptedStructuralMutationTarget, DbSession, DynamicMutation, DynamicStructuralPatch,
4443 DynamicTypedMutation, DynamicWriteCell, FieldSlot,
4444 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS, MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES,
4445 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES, MutationProgressRecordOp,
4446 TypedEntityDescriptor, TypedFieldType, add_structural_mutation_staged_bytes,
4447 admit_structural_mutation_staged_charge, checked_pre_key_candidate_count,
4448 insert_key_exists_after_generation, structural_mutation_staged_charge,
4449 validate_structural_mutation_result_bytes,
4450 };
4451 #[cfg(feature = "sql")]
4452 use crate::db::data::DecodedDataStoreKey;
4453 #[cfg(feature = "sql")]
4454 use crate::db::executor::budget::{
4455 HardExecutionBudget, HardExecutionContext, HardExecutionFailureHeadroom,
4456 with_execution_budget_for_tests, with_query_execution_budget_for_tests,
4457 };
4458 use crate::db::mutation_job::{MutationJobRecord, MutationJobTransition};
4459 #[cfg(feature = "sql")]
4460 use crate::db::{
4461 CompareProofAndAdvanceError, ExhaustiveReadError, MutationJobError,
4462 MutationJobRestartReason, PrimaryKeyComponent, PrimaryKeyValue, RawDataStoreKey,
4463 ReadSetRevisionError, ResumableJobAdvance, ResumableJobAdvanceRequest,
4464 ResumableJobAdvanceStatus, ResumableJobError, ResumableJobId, ResumableJobIdempotencyKey,
4465 ResumableJobStatus, asc,
4466 };
4467 use crate::db::{DynamicQuery, QueryExecutionError};
4468 use crate::{
4469 db::{
4470 GeneratedStartupDriverStep, MutationJobAdvanceRequest, MutationJobId,
4471 MutationJobIdempotencyKey, MutationJobPhase, MutationJobStatus, TypedFieldDescriptor,
4472 commit::{
4473 database_incarnation_id, forget_recovered_domain_for_tests,
4474 install_startup_recovery_wakeup,
4475 },
4476 data::DataStore,
4477 drive_generated_startup_recovery_page,
4478 executor::{MutationCommitInterruption, interrupt_next_mutation_commit_for_tests},
4479 index::{IndexId, IndexKey, IndexKeyKind, IndexStore, IndexStoreVisit},
4480 integrity::{
4481 InsertMutationJobResult, PhysicalUnitCheckpoint, QuickIntegrityStatus,
4482 RowInspectionLimits, execute_quick_integrity, execute_row_integrity_page,
4483 with_mutation_progress_store,
4484 },
4485 journal::{
4486 JournalBatch, JournalRecord, JournalSequence, JournalTailControl, JournalTailStore,
4487 encode_journal_batch,
4488 },
4489 registry::{
4490 StoreAllocationIdentities, StoreAllocationIdentity, StoreHandle, StoreRegistry,
4491 StoreRuntimeStorageCapabilities,
4492 },
4493 schema::{
4494 AcceptedConstraintCatalog, AcceptedFieldKind, AcceptedSchemaRevision, FieldId,
4495 FieldInsertGeneration, FieldStorageDecode, LeafCodec, PersistedFieldSnapshot,
4496 PersistedIndexFieldPathSnapshot, PersistedIndexKeySnapshot, PersistedIndexSnapshot,
4497 PersistedRelationEdgeSnapshot, PersistedSchemaSnapshot, RelationId, ScalarCodec,
4498 SchemaFieldSlot, SchemaFieldWritePolicy, SchemaIndexId, SchemaInsertDefault,
4499 SchemaRowLayout, SchemaStore, SchemaVersion,
4500 accepted_schema_candidate_with_field_bindings_for_tests,
4501 cardinality_build::{
4502 CardinalityBuildAuthority, CardinalityGenerationPageOutcome,
4503 drive_cardinality_generation_page,
4504 },
4505 cardinality_generation::{CardinalityGenerationHeader, CardinalityGenerationState},
4506 },
4507 write_context::MutationMode,
4508 },
4509 error::{ErrorClass, ErrorOrigin, InternalError},
4510 testing::test_memory,
4511 traits::{CanisterKind, Path},
4512 types::{EntityTag, Timestamp},
4513 value::{InputValue, OutputValue, Value},
4514 };
4515 use icydb_schema::{FieldSourceKey, ScalarType};
4516 use std::{
4517 cell::{Cell, RefCell},
4518 collections::BTreeMap,
4519 };
4520
4521 const STORE_PATH: &str = "session::write::identity_pre_key_tests::Store";
4522 const ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::Entity";
4523 const ID_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::id";
4524 const PAYLOAD_SOURCE: &str = "session::write::identity_pre_key_tests::Entity::payload";
4525 const ENTITY_NAME: &str = "IdentityRow";
4526 const ENTITY_TAG: EntityTag = EntityTag::new(93);
4527 const TYPED_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
4528 ENTITY_SOURCE,
4529 &[ID_SOURCE],
4530 &[
4531 TypedFieldDescriptor::new(ID_SOURCE, TypedFieldType::Scalar(ScalarType::Nat64), false),
4532 TypedFieldDescriptor::new(
4533 PAYLOAD_SOURCE,
4534 TypedFieldType::Scalar(ScalarType::Nat64),
4535 false,
4536 ),
4537 ],
4538 );
4539 const SECOND_ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::SecondEntity";
4540 const SECOND_ID_SOURCE: &str = "session::write::identity_pre_key_tests::SecondEntity::id";
4541 const SECOND_PAYLOAD_SOURCE: &str =
4542 "session::write::identity_pre_key_tests::SecondEntity::payload";
4543 const SECOND_TARGET_SOURCE: &str =
4544 "session::write::identity_pre_key_tests::SecondEntity::target_id";
4545 const SECOND_ENTITY_NAME: &str = "SecondIdentityRow";
4546 const SECOND_ENTITY_TAG: EntityTag = EntityTag::new(96);
4547 const THIRD_ENTITY_SOURCE: &str = "session::write::identity_pre_key_tests::ThirdEntity";
4548 const THIRD_ID_SOURCE: &str = "session::write::identity_pre_key_tests::ThirdEntity::id";
4549 const THIRD_PAYLOAD_SOURCE: &str =
4550 "session::write::identity_pre_key_tests::ThirdEntity::payload";
4551 const THIRD_ENTITY_NAME: &str = "ThirdIdentityRow";
4552 const THIRD_ENTITY_TAG: EntityTag = EntityTag::new(97);
4553 const JOURNALED_STORE_PATH: &str = "session::write::identity_pre_key_tests::JournaledStore";
4554 const UNRELATED_STORE_PATH: &str = "session::write::identity_pre_key_tests::UnrelatedStore";
4555
4556 fn batch_rows(results: &[crate::db::DynamicMutationResult]) -> Vec<Vec<OutputValue>> {
4557 results
4558 .iter()
4559 .flat_map(|result| result.rows.iter().cloned())
4560 .collect()
4561 }
4562
4563 struct TestCanister;
4564
4565 impl Path for TestCanister {
4566 const PATH: &'static str = "session::write::identity_pre_key_tests::Canister";
4567 }
4568
4569 impl CanisterKind for TestCanister {
4570 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
4571 Ok(45)
4572 }
4573 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.commit.v1";
4574 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
4575 Ok(49)
4576 }
4577 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_pre_key_tests.startup.control.v1";
4578 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
4579 Ok(46)
4580 }
4581 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
4582 "icydb.identity_pre_key_tests.integrity.progress.v1";
4583 }
4584
4585 thread_local! {
4586 static STARTUP_WAKEUPS: Cell<u32> = const { Cell::new(0) };
4587 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
4588 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
4589 static SCHEMA_STORE: RefCell<SchemaStore> =
4590 const { RefCell::new(SchemaStore::init_heap()) };
4591 static UNRELATED_DATA_STORE: RefCell<DataStore> =
4592 const { RefCell::new(DataStore::init_heap()) };
4593 static UNRELATED_INDEX_STORE: RefCell<IndexStore> =
4594 const { RefCell::new(IndexStore::init_heap()) };
4595 static UNRELATED_SCHEMA_STORE: RefCell<SchemaStore> =
4596 const { RefCell::new(SchemaStore::init_heap()) };
4597 static STORE_REGISTRY: StoreRegistry = {
4598 let mut registry = StoreRegistry::new();
4599 registry.register_store(
4600 STORE_PATH,
4601 &DATA_STORE,
4602 &INDEX_STORE,
4603 &SCHEMA_STORE,
4604 StoreAllocationIdentities::absent(),
4605 StoreRuntimeStorageCapabilities::heap(),
4606 ).expect("identity pre-key test store should register");
4607 registry.register_store(
4608 UNRELATED_STORE_PATH,
4609 &UNRELATED_DATA_STORE,
4610 &UNRELATED_INDEX_STORE,
4611 &UNRELATED_SCHEMA_STORE,
4612 StoreAllocationIdentities::absent(),
4613 StoreRuntimeStorageCapabilities::heap(),
4614 ).expect("unrelated identity test store should register");
4615 registry
4616 };
4617 static JOURNALED_DATA_STORE: RefCell<DataStore> =
4618 RefCell::new(DataStore::init_journaled(test_memory(186)));
4619 static JOURNALED_INDEX_STORE: RefCell<IndexStore> =
4620 RefCell::new(IndexStore::init_journaled(test_memory(187)));
4621 static JOURNALED_SCHEMA_STORE: RefCell<SchemaStore> =
4622 RefCell::new(SchemaStore::init_journaled(test_memory(188)));
4623 static JOURNALED_TAIL_STORE: RefCell<JournalTailStore> =
4624 RefCell::new(JournalTailStore::init(test_memory(189)));
4625 static JOURNALED_STORE_REGISTRY: StoreRegistry = {
4626 let mut registry = StoreRegistry::new();
4627 registry.register_journaled_store(
4628 JOURNALED_STORE_PATH,
4629 &JOURNALED_DATA_STORE,
4630 &JOURNALED_INDEX_STORE,
4631 &JOURNALED_SCHEMA_STORE,
4632 &JOURNALED_TAIL_STORE,
4633 StoreAllocationIdentities::new_journaled(
4634 StoreAllocationIdentity::new(186, "icydb.test.identity_range.data.v1"),
4635 StoreAllocationIdentity::new(187, "icydb.test.identity_range.index.v1"),
4636 StoreAllocationIdentity::new(188, "icydb.test.identity_range.schema.v1"),
4637 StoreAllocationIdentity::new(189, "icydb.test.identity_range.journal.v1"),
4638 ),
4639 StoreRuntimeStorageCapabilities::journaled(),
4640 ).expect("identity range journaled store should register");
4641 registry
4642 };
4643 }
4644
4645 fn record_startup_wakeup() {
4646 STARTUP_WAKEUPS.with(|wakeups| wakeups.set(wakeups.get().saturating_add(1)));
4647 }
4648
4649 struct JournaledTestCanister;
4650
4651 impl Path for JournaledTestCanister {
4652 const PATH: &'static str = "session::write::identity_pre_key_tests::JournaledCanister";
4653 }
4654
4655 impl CanisterKind for JournaledTestCanister {
4656 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
4657 Ok(190)
4658 }
4659 const COMMIT_STABLE_KEY: &'static str = "icydb.identity_range_tests.commit.v1";
4660 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
4661 Ok(192)
4662 }
4663 const STARTUP_STABLE_KEY: &'static str = "icydb.identity_range_tests.startup.control.v1";
4664 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
4665 Ok(191)
4666 }
4667 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
4668 "icydb.identity_range_tests.integrity.progress.v1";
4669 }
4670
4671 fn source_key(source: &str) -> FieldSourceKey {
4672 FieldSourceKey::try_new(source).expect("identity test field source should admit")
4673 }
4674
4675 fn identity_snapshot(store_path: &str, payload_unique: bool) -> PersistedSchemaSnapshot {
4676 identity_snapshot_for_entity(
4677 store_path,
4678 payload_unique,
4679 false,
4680 false,
4681 ENTITY_SOURCE,
4682 ENTITY_NAME,
4683 None,
4684 )
4685 }
4686
4687 fn identity_snapshot_with_nullable_payload(store_path: &str) -> PersistedSchemaSnapshot {
4688 identity_snapshot_for_entity(
4689 store_path,
4690 false,
4691 false,
4692 true,
4693 ENTITY_SOURCE,
4694 ENTITY_NAME,
4695 None,
4696 )
4697 }
4698
4699 fn identity_snapshot_with_payload_index(
4700 store_path: &str,
4701 payload_unique: bool,
4702 composite: bool,
4703 ) -> PersistedSchemaSnapshot {
4704 identity_snapshot_for_entity(
4705 store_path,
4706 payload_unique,
4707 composite,
4708 false,
4709 ENTITY_SOURCE,
4710 ENTITY_NAME,
4711 None,
4712 )
4713 }
4714
4715 fn identity_snapshot_for_entity(
4716 store_path: &str,
4717 payload_unique: bool,
4718 composite: bool,
4719 payload_nullable: bool,
4720 entity_source: &str,
4721 entity_name: &str,
4722 relation_target: Option<&str>,
4723 ) -> PersistedSchemaSnapshot {
4724 let mut fields = vec![
4725 PersistedFieldSnapshot::new_initial_with_write_policy(
4726 FieldId::new(1),
4727 "id".to_string(),
4728 SchemaFieldSlot::new(0),
4729 AcceptedFieldKind::Nat64,
4730 Vec::new(),
4731 false,
4732 SchemaInsertDefault::None,
4733 SchemaFieldWritePolicy::from_model_policies(
4734 Some(FieldInsertGeneration::Identity),
4735 None,
4736 ),
4737 FieldStorageDecode::ByKind,
4738 LeafCodec::Scalar(ScalarCodec::Nat64),
4739 ),
4740 PersistedFieldSnapshot::new_initial(
4741 FieldId::new(2),
4742 "payload".to_string(),
4743 SchemaFieldSlot::new(1),
4744 AcceptedFieldKind::Nat64,
4745 Vec::new(),
4746 payload_nullable,
4747 SchemaInsertDefault::None,
4748 FieldStorageDecode::ByKind,
4749 LeafCodec::Scalar(ScalarCodec::Nat64),
4750 ),
4751 ];
4752 if relation_target.is_some() {
4753 fields.push(PersistedFieldSnapshot::new_initial(
4754 FieldId::new(3),
4755 "target_id".to_string(),
4756 SchemaFieldSlot::new(2),
4757 AcceptedFieldKind::Nat64,
4758 Vec::new(),
4759 true,
4760 SchemaInsertDefault::None,
4761 FieldStorageDecode::ByKind,
4762 LeafCodec::Scalar(ScalarCodec::Nat64),
4763 ));
4764 }
4765 let mut index_fields = vec![PersistedIndexFieldPathSnapshot::new(
4766 FieldId::new(2),
4767 SchemaFieldSlot::new(1),
4768 vec!["payload".to_string()],
4769 AcceptedFieldKind::Nat64,
4770 payload_nullable,
4771 )];
4772 if composite {
4773 index_fields.push(PersistedIndexFieldPathSnapshot::new(
4774 FieldId::new(1),
4775 SchemaFieldSlot::new(0),
4776 vec!["id".to_string()],
4777 AcceptedFieldKind::Nat64,
4778 false,
4779 ));
4780 }
4781 let snapshot = PersistedSchemaSnapshot::new_with_indexes(
4782 SchemaVersion::initial(),
4783 entity_source.to_string(),
4784 entity_name.to_string(),
4785 FieldId::new(1),
4786 SchemaRowLayout::initial(
4787 fields
4788 .iter()
4789 .map(|field| (field.id(), field.slot()))
4790 .collect(),
4791 ),
4792 fields,
4793 vec![PersistedIndexSnapshot::new(
4794 SchemaIndexId::new(1).expect("identity test index ID should admit"),
4795 1,
4796 if composite {
4797 "by_payload_id".to_string()
4798 } else {
4799 "by_payload".to_string()
4800 },
4801 store_path.to_string(),
4802 payload_unique,
4803 PersistedIndexKeySnapshot::FieldPath(index_fields),
4804 None,
4805 )],
4806 );
4807 let Some(relation_target) = relation_target else {
4808 return snapshot;
4809 };
4810 let snapshot = snapshot.with_relations(vec![PersistedRelationEdgeSnapshot::new_direct(
4811 RelationId::new(1).expect("mixed recovery relation identity should be non-zero"),
4812 "target".to_string(),
4813 relation_target.to_string(),
4814 vec![FieldId::new(3)],
4815 )]);
4816 let constraints = AcceptedConstraintCatalog::initial(
4817 snapshot.fields(),
4818 snapshot.indexes(),
4819 snapshot.relations(),
4820 )
4821 .expect("mixed recovery relation constraints should close");
4822 snapshot.with_constraint_catalog(constraints)
4823 }
4824
4825 fn initialize() -> DbSession<TestCanister> {
4826 initialize_with_snapshot(identity_snapshot(STORE_PATH, false))
4827 }
4828
4829 fn initialize_with_composite_payload_index() -> DbSession<TestCanister> {
4830 initialize_with_snapshot(identity_snapshot_with_payload_index(
4831 STORE_PATH, false, true,
4832 ))
4833 }
4834
4835 fn initialize_with_snapshot(snapshot: PersistedSchemaSnapshot) -> DbSession<TestCanister> {
4836 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
4837 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
4838 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
4839 UNRELATED_DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
4840 UNRELATED_INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
4841 UNRELATED_SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
4842 let session = DbSession::<TestCanister>::new(
4843 &STORE_REGISTRY,
4844 &crate::db::RequestExecutionRoot::__new_runtime_root(),
4845 );
4846 session
4847 .db
4848 .drive_startup_recovery_page()
4849 .expect("identity pre-key test database should initialize");
4850 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
4851 STORE_PATH,
4852 AcceptedSchemaRevision::INITIAL,
4853 BTreeMap::from([(ENTITY_TAG, snapshot)]),
4854 BTreeMap::from([
4855 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
4856 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
4857 ]),
4858 );
4859 let store = session
4860 .db
4861 .store_handle(STORE_PATH)
4862 .expect("identity pre-key test store should resolve");
4863 crate::db::commit::publish_accepted_schema_candidate(
4864 STORE_PATH,
4865 store,
4866 AcceptedSchemaRevision::NONE,
4867 &candidate,
4868 )
4869 .expect("identity candidate should publish with explicit zero state");
4870 session
4871 }
4872
4873 fn initialize_journaled_with_root_and_payload_uniqueness(
4874 payload_unique: bool,
4875 ) -> (
4876 DbSession<JournaledTestCanister>,
4877 crate::db::RequestExecutionRoot,
4878 ) {
4879 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4880 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
4881 session
4882 .db
4883 .drive_startup_recovery_page()
4884 .expect("journaled identity database should initialize");
4885 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
4886 JOURNALED_STORE_PATH,
4887 AcceptedSchemaRevision::INITIAL,
4888 BTreeMap::from([(
4889 ENTITY_TAG,
4890 identity_snapshot(JOURNALED_STORE_PATH, payload_unique),
4891 )]),
4892 BTreeMap::from([
4893 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
4894 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
4895 ]),
4896 );
4897 let store = session
4898 .db
4899 .store_handle(JOURNALED_STORE_PATH)
4900 .expect("journaled identity store should resolve");
4901 crate::db::commit::publish_accepted_schema_candidate(
4902 JOURNALED_STORE_PATH,
4903 store,
4904 AcceptedSchemaRevision::NONE,
4905 &candidate,
4906 )
4907 .expect("journaled identity candidate should publish");
4908 (session, root)
4909 }
4910
4911 fn initialize_journaled_with_root() -> (
4912 DbSession<JournaledTestCanister>,
4913 crate::db::RequestExecutionRoot,
4914 ) {
4915 initialize_journaled_with_root_and_payload_uniqueness(false)
4916 }
4917
4918 fn initialize_journaled_multi_entity() -> DbSession<JournaledTestCanister> {
4919 let root = crate::db::RequestExecutionRoot::__new_runtime_root();
4920 let session = DbSession::<JournaledTestCanister>::new(&JOURNALED_STORE_REGISTRY, &root);
4921 session
4922 .db
4923 .drive_startup_recovery_page()
4924 .expect("multi-entity journaled database should initialize");
4925 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
4926 JOURNALED_STORE_PATH,
4927 AcceptedSchemaRevision::INITIAL,
4928 BTreeMap::from([
4929 (ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH, false)),
4930 (
4931 SECOND_ENTITY_TAG,
4932 identity_snapshot_for_entity(
4933 JOURNALED_STORE_PATH,
4934 false,
4935 false,
4936 false,
4937 SECOND_ENTITY_SOURCE,
4938 SECOND_ENTITY_NAME,
4939 Some(ENTITY_SOURCE),
4940 ),
4941 ),
4942 (
4943 THIRD_ENTITY_TAG,
4944 identity_snapshot_for_entity(
4945 JOURNALED_STORE_PATH,
4946 false,
4947 false,
4948 false,
4949 THIRD_ENTITY_SOURCE,
4950 THIRD_ENTITY_NAME,
4951 None,
4952 ),
4953 ),
4954 ]),
4955 BTreeMap::from([
4956 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
4957 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
4958 (
4959 (SECOND_ENTITY_TAG, source_key(SECOND_ID_SOURCE)),
4960 FieldId::new(1),
4961 ),
4962 (
4963 (SECOND_ENTITY_TAG, source_key(SECOND_PAYLOAD_SOURCE)),
4964 FieldId::new(2),
4965 ),
4966 (
4967 (SECOND_ENTITY_TAG, source_key(SECOND_TARGET_SOURCE)),
4968 FieldId::new(3),
4969 ),
4970 (
4971 (THIRD_ENTITY_TAG, source_key(THIRD_ID_SOURCE)),
4972 FieldId::new(1),
4973 ),
4974 (
4975 (THIRD_ENTITY_TAG, source_key(THIRD_PAYLOAD_SOURCE)),
4976 FieldId::new(2),
4977 ),
4978 ]),
4979 );
4980 let store = session
4981 .db
4982 .store_handle(JOURNALED_STORE_PATH)
4983 .expect("multi-entity journaled store should resolve");
4984 crate::db::commit::publish_accepted_schema_candidate(
4985 JOURNALED_STORE_PATH,
4986 store,
4987 AcceptedSchemaRevision::NONE,
4988 &candidate,
4989 )
4990 .expect("multi-entity journaled candidate should publish");
4991 session
4992 }
4993
4994 fn initialize_journaled() -> DbSession<JournaledTestCanister> {
4995 initialize_journaled_with_root().0
4996 }
4997
4998 fn initialize_journaled_with_unique_payload() -> DbSession<JournaledTestCanister> {
4999 initialize_journaled_with_root_and_payload_uniqueness(true).0
5000 }
5001
5002 fn drive_journaled_recovery_to_completion(session: &DbSession<JournaledTestCanister>) {
5003 for _ in 0..8 {
5004 if session
5005 .db
5006 .drive_startup_recovery_page()
5007 .expect("dedicated driver recovery should remain valid")
5008 {
5009 return;
5010 }
5011 }
5012 panic!("dedicated driver recovery should quiesce within eight complete batches");
5013 }
5014
5015 fn drive_journaled_cardinality_to_ready(session: &DbSession<JournaledTestCanister>) {
5016 let handle = session
5017 .db
5018 .store_handle(JOURNALED_STORE_PATH)
5019 .expect("journaled cardinality store should resolve");
5020 for _ in 0..8 {
5021 let outcome = handle
5022 .with_data(|data| {
5023 handle.with_index(|index| {
5024 handle.with_schema_mut(|schema| {
5025 drive_cardinality_generation_page(data, index, schema, |schema| {
5026 let watermark = JOURNALED_TAIL_STORE
5027 .with(|tail| tail.borrow().fold_watermark())?;
5028 CardinalityBuildAuthority::derive(
5029 schema,
5030 database_incarnation_id()?,
5031 handle.allocation_identities(),
5032 watermark,
5033 )
5034 })
5035 })
5036 })
5037 })
5038 .expect("bounded cardinality generation should advance");
5039 if outcome == CardinalityGenerationPageOutcome::Quiescent {
5040 return;
5041 }
5042 }
5043 panic!("cardinality generation should become Ready within eight bounded pages");
5044 }
5045
5046 fn journaled_user_index_prefix() -> (IndexId, Vec<Vec<u8>>) {
5047 JOURNALED_INDEX_STORE.with(|store| {
5048 let mut selected = None;
5049 store
5050 .borrow()
5051 .visit_entries(|raw_key, _value| {
5052 let key = IndexKey::try_from_raw(raw_key)
5053 .expect("accepted user index key should decode");
5054 if key.key_kind() != IndexKeyKind::User {
5055 return Ok::<_, InternalError>(IndexStoreVisit::Continue);
5056 }
5057 let components = (0..key.component_count())
5058 .map(|index| {
5059 key.component(index)
5060 .expect("accepted index component should exist")
5061 .to_vec()
5062 })
5063 .collect::<Vec<_>>();
5064 selected = Some((*key.index_id(), components));
5065 Ok(IndexStoreVisit::Stop)
5066 })
5067 .expect("accepted user index should be inspectable");
5068 selected.expect("the cardinality fixture should contain one user index entry")
5069 })
5070 }
5071
5072 fn reset_journaled_cardinality_projections() -> u64 {
5073 JOURNALED_DATA_STORE.with(|store| {
5074 store
5075 .borrow_mut()
5076 .reset_journaled_live_projection()
5077 .expect("row projection should reset without a count scan");
5078 });
5079 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
5080 let fold_watermark = JOURNALED_TAIL_STORE
5081 .with(|store| store.borrow().fold_watermark())
5082 .expect("journal watermark should remain current-form");
5083 JOURNALED_INDEX_STORE.with(|store| {
5084 store
5085 .borrow_mut()
5086 .reset_journaled_live_projection(data_generation, fold_watermark)
5087 .expect("index projection should reset without a count scan");
5088 });
5089 data_generation
5090 }
5091
5092 fn assert_journaled_cardinality(
5093 handle: StoreHandle,
5094 index_id: IndexId,
5095 prefix_components: &[Vec<u8>],
5096 expected: u64,
5097 ) {
5098 assert_eq!(handle.exact_entity_count(ENTITY_TAG), Some(expected));
5099 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
5100 assert_eq!(
5101 handle.exact_user_index_prefix_count(
5102 data_generation,
5103 IndexKeyKind::User,
5104 index_id,
5105 prefix_components,
5106 ),
5107 Some(expected),
5108 );
5109 }
5110
5111 fn mark_journaled_cardinality_building() {
5112 let current = JOURNALED_SCHEMA_STORE.with(|store| {
5113 store
5114 .borrow()
5115 .cardinality_generation_header()
5116 .expect("Ready header should decode")
5117 .expect("Ready header should exist")
5118 });
5119 JOURNALED_SCHEMA_STORE.with(|store| {
5120 store
5121 .borrow_mut()
5122 .write_cardinality_generation_header(CardinalityGenerationHeader::new(
5123 current.generation(),
5124 CardinalityGenerationState::Building,
5125 current.slot(),
5126 current.source(),
5127 ))
5128 .expect("Building fallback fixture should persist");
5129 });
5130 }
5131
5132 fn payload_patch(value: u64) -> AcceptedMutationIntentPatch {
5133 AcceptedMutationIntentPatch::new()
5134 .set_authored(FieldSlot::from_validated_index(1), InputValue::nat64(value))
5135 }
5136
5137 fn dynamic_payload_patch(value: u64) -> DynamicStructuralPatch {
5138 DynamicStructuralPatch::new(vec![(
5139 "payload".to_string(),
5140 DynamicWriteCell::Value(InputValue::nat64(value)),
5141 )])
5142 }
5143
5144 fn related_dynamic_payload_patch(value: u64, target_id: u64) -> DynamicStructuralPatch {
5145 DynamicStructuralPatch::new(vec![
5146 (
5147 "payload".to_string(),
5148 DynamicWriteCell::Value(InputValue::nat64(value)),
5149 ),
5150 (
5151 "target_id".to_string(),
5152 DynamicWriteCell::Value(InputValue::nat64(target_id)),
5153 ),
5154 ])
5155 }
5156
5157 fn expected_dynamic_row(id: u64, payload: u64) -> Vec<OutputValue> {
5158 vec![OutputValue::nat64(id), OutputValue::nat64(payload)]
5159 }
5160
5161 fn exact_key_binding<C: CanisterKind>(session: &DbSession<C>) -> DynamicTypedEntityBinding {
5162 session
5163 .issue_typed_entity_binding(&TYPED_DESCRIPTOR)
5164 .expect("exact-key test binding should issue")
5165 }
5166
5167 fn typed_payload_insert(
5168 binding: &DynamicTypedEntityBinding,
5169 payload: u64,
5170 ) -> DynamicTypedMutation {
5171 let patch = binding
5172 .bind_write_ordinals(vec![(
5173 1,
5174 DynamicWriteCell::Value(InputValue::nat64(payload)),
5175 )])
5176 .expect("typed payload patch should bind");
5177 DynamicTypedMutation::Insert { patch }
5178 }
5179
5180 fn typed_payload_delete(id: u64) -> DynamicTypedMutation {
5181 DynamicTypedMutation::Delete {
5182 key: InputValue::nat64(id),
5183 }
5184 }
5185
5186 fn insert_exact_key_fixture<C: CanisterKind>(session: &DbSession<C>, payload: u64) -> u64 {
5187 let output = session
5188 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
5189 entity: ENTITY_NAME.to_string(),
5190 patch: dynamic_payload_patch(payload),
5191 })
5192 .expect("exact-key fixture insert should commit");
5193 match output.rows.as_slice() {
5194 [row] => match row.as_slice() {
5195 [id, actual_payload] if matches!(actual_payload.as_public(), crate::value::PublicValue::Nat64(value) if *value == payload) =>
5196 {
5197 let crate::value::PublicValue::Nat64(id) = id.as_public() else {
5198 panic!("exact-key fixture should return a natural identity");
5199 };
5200 *id
5201 }
5202 _ => panic!("exact-key fixture should return its identity and payload"),
5203 },
5204 _ => panic!("exact-key fixture insert should return one row"),
5205 }
5206 }
5207
5208 #[cfg(feature = "sql")]
5209 fn sql_projection_rows(session: &DbSession<TestCanister>, sql: &str) -> Vec<Vec<OutputValue>> {
5210 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5211 .execute_trusted_sql_query(sql)
5212 .expect("focused SQL projection should execute")
5213 else {
5214 panic!("focused SQL projection should return rows")
5215 };
5216
5217 rows
5218 }
5219
5220 #[cfg(feature = "sql")]
5221 #[test]
5222 fn secondary_ordered_covering_limit_stops_at_the_present_row_window() {
5223 let session = initialize_with_composite_payload_index();
5224 for payload in [30, 10, 20, 20, 40] {
5225 insert_exact_key_fixture(&session, payload);
5226 }
5227
5228 assert_eq!(
5229 sql_projection_rows(
5230 &session,
5231 "SELECT payload FROM IdentityRow ORDER BY payload ASC, id ASC LIMIT 1",
5232 ),
5233 vec![vec![OutputValue::nat64(10)]],
5234 );
5235 #[cfg(feature = "sql")]
5236 assert_sql_query_fits_resource_limit(
5237 &session,
5238 "SELECT payload FROM IdentityRow ORDER BY payload ASC, id ASC LIMIT 1",
5239 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
5240 1,
5241 );
5242
5243 assert_eq!(
5244 sql_projection_rows(
5245 &session,
5246 "SELECT payload FROM IdentityRow ORDER BY payload DESC, id DESC LIMIT 1",
5247 ),
5248 vec![vec![OutputValue::nat64(40)]],
5249 );
5250 #[cfg(feature = "sql")]
5251 assert_sql_query_fits_resource_limit(
5252 &session,
5253 "SELECT payload FROM IdentityRow ORDER BY payload DESC, id DESC LIMIT 1",
5254 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
5255 1,
5256 );
5257
5258 assert_eq!(
5259 sql_projection_rows(
5260 &session,
5261 "SELECT id, payload FROM IdentityRow \
5262 ORDER BY payload ASC, id ASC LIMIT 2 OFFSET 1",
5263 ),
5264 vec![
5265 vec![OutputValue::nat64(3), OutputValue::nat64(20)],
5266 vec![OutputValue::nat64(4), OutputValue::nat64(20)],
5267 ],
5268 );
5269 #[cfg(feature = "sql")]
5270 assert_sql_query_fits_resource_limit(
5271 &session,
5272 "SELECT id, payload FROM IdentityRow \
5273 ORDER BY payload ASC, id ASC LIMIT 2 OFFSET 1",
5274 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
5275 3,
5276 );
5277
5278 assert_eq!(
5279 sql_projection_rows(
5280 &session,
5281 "SELECT payload FROM IdentityRow ORDER BY payload ASC, id ASC",
5282 ),
5283 [10, 20, 20, 30, 40]
5284 .into_iter()
5285 .map(|payload| vec![OutputValue::nat64(payload)])
5286 .collect::<Vec<_>>(),
5287 );
5288 }
5289
5290 #[cfg(feature = "sql")]
5291 #[test]
5292 fn secondary_ordered_covering_limit_fails_on_an_accessed_missing_row() {
5293 let session = initialize_with_composite_payload_index();
5294 let first = insert_exact_key_fixture(&session, 10);
5295 insert_exact_key_fixture(&session, 20);
5296 let raw_key =
5297 DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(first))
5298 .expect("missing-row fixture key should decode")
5299 .to_raw()
5300 .expect("missing-row fixture key should encode");
5301 let store = session
5302 .db
5303 .store_handle(STORE_PATH)
5304 .expect("missing-row fixture store should resolve");
5305 assert!(
5306 store.with_data_mut(|data| data.remove(&raw_key)).is_some(),
5307 "fixture must remove only the authoritative row",
5308 );
5309
5310 let error = session
5311 .execute_trusted_sql_query(
5312 "SELECT payload FROM IdentityRow ORDER BY payload ASC, id ASC LIMIT 1",
5313 )
5314 .expect_err("an accessed accepted-index row must remain fail-closed");
5315 assert!(matches!(
5316 error,
5317 crate::db::QueryError::Execute(QueryExecutionError::Corruption(_))
5318 ));
5319 }
5320
5321 #[cfg(feature = "sql")]
5322 #[test]
5323 fn secondary_indexed_max_uses_one_descending_edge_across_ties() {
5324 let session = initialize_with_composite_payload_index();
5325 let mut inserted = Vec::new();
5326 for payload in [30, 10, 20, 20, 40, 40] {
5327 inserted.push(insert_exact_key_fixture(&session, payload));
5328 }
5329
5330 let sql = "SELECT MAX(payload) FROM IdentityRow";
5331 let data_reads_before = DataStore::current_get_call_count();
5332 let index_reads_before = IndexStore::current_entry_read_count();
5333 assert_eq!(
5334 sql_projection_rows(&session, sql),
5335 vec![vec![OutputValue::nat64(40)]],
5336 );
5337 assert_eq!(DataStore::current_get_call_count() - data_reads_before, 1);
5338 assert!(IndexStore::current_entry_read_count() - index_reads_before <= 1);
5339
5340 let range_sql = "SELECT MAX(payload) FROM IdentityRow WHERE payload < 40";
5341 let data_reads_before = DataStore::current_get_call_count();
5342 let index_reads_before = IndexStore::current_entry_read_count();
5343 assert_eq!(
5344 sql_projection_rows(&session, range_sql),
5345 vec![vec![OutputValue::nat64(30)]],
5346 );
5347 assert_eq!(DataStore::current_get_call_count() - data_reads_before, 1);
5348 assert!(IndexStore::current_entry_read_count() - index_reads_before <= 1);
5349
5350 let last = inserted
5351 .last()
5352 .copied()
5353 .expect("secondary MAX fixture should retain its last identity");
5354 let raw_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(last))
5355 .expect("missing-row fixture key should decode")
5356 .to_raw()
5357 .expect("missing-row fixture key should encode");
5358 let store = session
5359 .db
5360 .store_handle(STORE_PATH)
5361 .expect("missing-row fixture store should resolve");
5362 assert!(
5363 store.with_data_mut(|data| data.remove(&raw_key)).is_some(),
5364 "fixture must remove only the descending edge row",
5365 );
5366
5367 let error = session
5368 .execute_trusted_sql_query("SELECT MAX(payload) FROM IdentityRow")
5369 .expect_err("an accessed accepted-index row must remain fail-closed");
5370 assert!(matches!(
5371 error,
5372 crate::db::QueryError::Execute(QueryExecutionError::Corruption(_))
5373 ));
5374 }
5375
5376 #[cfg(feature = "sql")]
5377 #[test]
5378 fn secondary_indexed_max_upper_range_fails_on_an_accessed_missing_row() {
5379 let session = initialize_with_composite_payload_index();
5380 let upper_range_edge = insert_exact_key_fixture(&session, 30);
5381 for payload in [10, 20, 40] {
5382 insert_exact_key_fixture(&session, payload);
5383 }
5384 let raw_key = DecodedDataStoreKey::try_from_structural_key(
5385 ENTITY_TAG,
5386 &Value::Nat64(upper_range_edge),
5387 )
5388 .expect("missing-row fixture key should decode")
5389 .to_raw()
5390 .expect("missing-row fixture key should encode");
5391 let store = session
5392 .db
5393 .store_handle(STORE_PATH)
5394 .expect("missing-row fixture store should resolve");
5395 assert!(
5396 store.with_data_mut(|data| data.remove(&raw_key)).is_some(),
5397 "fixture must remove only the upper-range edge row",
5398 );
5399
5400 let error = session
5401 .execute_trusted_sql_query("SELECT MAX(payload) FROM IdentityRow WHERE payload < 40")
5402 .expect_err("an accessed upper-range edge row must remain fail-closed");
5403 assert!(matches!(
5404 error,
5405 crate::db::QueryError::Execute(QueryExecutionError::Corruption(_))
5406 ));
5407 }
5408
5409 #[test]
5410 fn exact_counts_use_entity_and_bounded_index_metadata_without_physical_reads() {
5411 let session = initialize();
5412 for payload in [10, 10, 20] {
5413 insert_exact_key_fixture(&session, payload);
5414 }
5415 let binding = exact_key_binding(&session);
5416 let entity = DynamicQuery::new(ENTITY_NAME);
5417 let tens =
5418 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("payload").eq(10_u64));
5419 let selected = DynamicQuery::new(ENTITY_NAME)
5420 .filter(crate::db::FieldRef::new("payload").in_list([10_u64, 10, 20, 99]));
5421 let missing =
5422 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("payload").eq(99_u64));
5423 let data_reads_before = DataStore::current_get_call_count();
5424 let index_reads_before = IndexStore::current_entry_read_count();
5425
5426 assert_eq!(session.execute_public_exact_count(&entity).unwrap(), 3);
5427 assert_eq!(session.execute_public_exact_count(&tens).unwrap(), 2);
5428 assert_eq!(session.execute_public_exact_count(&selected).unwrap(), 3);
5429 assert_eq!(session.execute_public_exact_count(&missing).unwrap(), 0);
5430 assert_eq!(
5431 session
5432 .execute_public_exact_count_for_typed_binding(&binding, &tens)
5433 .unwrap(),
5434 Some(2),
5435 );
5436 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5437 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5438
5439 session
5440 .execute_trusted_dynamic_insert_batch(
5441 ENTITY_NAME,
5442 (0..64).map(|_| dynamic_payload_patch(10)).collect(),
5443 )
5444 .expect("a larger matching population should commit");
5445 let data_reads_before = DataStore::current_get_call_count();
5446 let index_reads_before = IndexStore::current_entry_read_count();
5447 assert_eq!(session.execute_public_exact_count(&tens).unwrap(), 66);
5448 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5449 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5450 }
5451
5452 #[test]
5453 fn exact_count_accepts_the_leading_field_of_a_composite_user_index() {
5454 let session = initialize_with_composite_payload_index();
5455 for payload in [10, 10, 20] {
5456 insert_exact_key_fixture(&session, payload);
5457 }
5458 let tens =
5459 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("payload").eq(10_u64));
5460 let selected = DynamicQuery::new(ENTITY_NAME)
5461 .filter(crate::db::FieldRef::new("payload").in_list([10_u64, 20, 99]));
5462 let data_reads_before = DataStore::current_get_call_count();
5463 let index_reads_before = IndexStore::current_entry_read_count();
5464
5465 assert_eq!(session.execute_public_exact_count(&tens).unwrap(), 2);
5466 assert_eq!(session.execute_public_exact_count(&selected).unwrap(), 3);
5467 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5468 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5469 }
5470
5471 #[cfg(feature = "sql")]
5472 #[test]
5473 fn exact_count_shared_executor_preserves_sql_direct_count_results() {
5474 let session = initialize();
5475 let data_reads_before = DataStore::current_get_call_count();
5476 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5477 .execute_trusted_sql_query("SELECT COUNT(*) FROM IdentityRow")
5478 .expect("empty SQL direct count should succeed")
5479 else {
5480 panic!("empty SQL direct count should return one projection row")
5481 };
5482 assert_eq!(rows, vec![vec![OutputValue::nat64(0)]]);
5483 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5484
5485 for payload in [10, 10, 20] {
5486 insert_exact_key_fixture(&session, payload);
5487 }
5488
5489 let data_reads_before = DataStore::current_get_call_count();
5490 let index_reads_before = IndexStore::current_entry_read_count();
5491 for sql in [
5492 "SELECT COUNT(*) FROM IdentityRow",
5493 "SELECT COUNT(payload) FROM IdentityRow",
5494 "SELECT COUNT(1) FROM IdentityRow",
5495 "SELECT COUNT(*) FROM IdentityRow WHERE true",
5496 "SELECT COUNT(*) FROM IdentityRow WHERE payload IN (10, 10, 20, 99)",
5497 ] {
5498 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5499 .execute_trusted_sql_query(sql)
5500 .expect("SQL direct count should use the shared exact executor")
5501 else {
5502 panic!("SQL direct count should return one projection row")
5503 };
5504 assert_eq!(rows, vec![vec![OutputValue::nat64(3)]], "{sql}");
5505 }
5506 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5507 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5508
5509 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5510 .execute_trusted_sql_query("SELECT COUNT(*) FROM IdentityRow WHERE payload = 10")
5511 .expect("nontrivial exact-prefix count should preserve its predicate")
5512 else {
5513 panic!("nontrivial exact-prefix count should return one projection row")
5514 };
5515 assert_eq!(rows, vec![vec![OutputValue::nat64(2)]]);
5516 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5517 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5518
5519 let data_reads_before = DataStore::current_get_call_count();
5520 for (sql, expected) in [
5521 ("SELECT COUNT(*) FROM IdentityRow WHERE false", 0_u64),
5522 ("SELECT COUNT(*) FROM IdentityRow WHERE id = 1", 1),
5523 ] {
5524 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5525 .execute_trusted_sql_query(sql)
5526 .expect("non-entity count control should succeed")
5527 else {
5528 panic!("non-entity count control should return one projection row")
5529 };
5530 assert_eq!(rows, vec![vec![OutputValue::nat64(expected)]], "{sql}");
5531 }
5532 assert!(DataStore::current_get_call_count() > data_reads_before);
5533
5534 session
5535 .execute_trusted_sql_query("SELECT COUNT(*) FROM IdentityRow LIMIT 1")
5536 .expect_err("unordered aggregate input pagination must remain rejected");
5537
5538 let data_reads_before = DataStore::current_get_call_count();
5539 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5540 .execute_trusted_sql_query("SELECT COUNT(DISTINCT payload) FROM IdentityRow")
5541 .expect("distinct count should retain prepared execution")
5542 else {
5543 panic!("distinct count should return one projection row")
5544 };
5545 assert_eq!(rows, vec![vec![OutputValue::nat64(2)]]);
5546 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5547 }
5548
5549 #[cfg(feature = "sql")]
5550 #[test]
5551 fn exact_count_composite_prefix_admits_seventeen_canonical_keys_only() {
5552 let session = initialize_with_composite_payload_index();
5553 for payload in [10, 10, 20] {
5554 insert_exact_key_fixture(&session, payload);
5555 }
5556 let ids_at_count_cap = (1_u64..=17)
5557 .map(|id| id.to_string())
5558 .collect::<Vec<_>>()
5559 .join(", ");
5560 let at_count_cap_sql = format!(
5561 "SELECT COUNT(*) FROM IdentityRow WHERE payload = 10 AND id IN ({ids_at_count_cap})",
5562 );
5563 let data_reads_before = DataStore::current_get_call_count();
5564 let index_reads_before = IndexStore::current_entry_read_count();
5565 assert_eq!(
5566 sql_projection_rows(&session, at_count_cap_sql.as_str()),
5567 vec![vec![OutputValue::nat64(2)]],
5568 );
5569 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5570 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5571
5572 let authored_duplicate_sql = format!(
5573 "SELECT COUNT(*) FROM IdentityRow WHERE payload = 10 AND id IN (1, {ids_at_count_cap})",
5574 );
5575 assert_eq!(
5576 sql_projection_rows(&session, authored_duplicate_sql.as_str()),
5577 vec![vec![OutputValue::nat64(2)]],
5578 );
5579 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5580 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5581
5582 let ids_over_count_cap = format!("{ids_at_count_cap}, 18");
5583 let over_count_cap_sql = format!(
5584 "SELECT COUNT(*) FROM IdentityRow WHERE payload = 10 AND id IN ({ids_over_count_cap})",
5585 );
5586 assert_eq!(
5587 sql_projection_rows(&session, over_count_cap_sql.as_str()),
5588 vec![vec![OutputValue::nat64(2)]],
5589 );
5590 assert!(DataStore::current_get_call_count() > data_reads_before);
5591 }
5592
5593 #[cfg(feature = "sql")]
5594 #[test]
5595 fn exact_count_nullable_field_uses_prepared_borrowed_primary_scan() {
5596 let session = initialize_with_snapshot(identity_snapshot_with_nullable_payload(STORE_PATH));
5597 session
5598 .execute_trusted_dynamic_insert_batch(
5599 ENTITY_NAME,
5600 vec![
5601 dynamic_payload_patch(10),
5602 DynamicStructuralPatch::new(Vec::new()),
5603 ],
5604 )
5605 .expect("nullable count fixture should insert");
5606
5607 let data_reads_before = DataStore::current_get_call_count();
5608 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5609 .execute_trusted_sql_query("SELECT COUNT(payload) FROM IdentityRow")
5610 .expect("nullable count should retain prepared execution")
5611 else {
5612 panic!("nullable count should return one projection row")
5613 };
5614 assert_eq!(rows, vec![vec![OutputValue::nat64(1)]]);
5615 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5616 }
5617
5618 #[cfg(feature = "sql")]
5619 #[test]
5620 fn indexed_extrema_nullable_field_uses_prepared_borrowed_primary_scan() {
5621 let session = initialize_with_snapshot(identity_snapshot_with_nullable_payload(STORE_PATH));
5622 session
5623 .execute_trusted_dynamic_insert_batch(
5624 ENTITY_NAME,
5625 vec![DynamicStructuralPatch::new(Vec::new())],
5626 )
5627 .expect("all-null extrema fixture should insert");
5628
5629 for sql in [
5630 "SELECT MIN(payload) FROM IdentityRow",
5631 "SELECT MAX(payload) FROM IdentityRow",
5632 ] {
5633 let data_reads_before = DataStore::current_get_call_count();
5634 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5635 .execute_trusted_sql_query(sql)
5636 .expect("all-null extrema should retain complete reduction")
5637 else {
5638 panic!("all-null extrema should return one projection row")
5639 };
5640 assert_eq!(rows, vec![vec![OutputValue::null()]], "{sql}");
5641 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5642 }
5643
5644 session
5645 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(10)])
5646 .expect("mixed nullable extrema fixture should insert");
5647
5648 for sql in [
5649 "SELECT MIN(payload) FROM IdentityRow",
5650 "SELECT MAX(payload) FROM IdentityRow",
5651 ] {
5652 let data_reads_before = DataStore::current_get_call_count();
5653 let crate::db::SqlStatementResult::Projection { rows, .. } = session
5654 .execute_trusted_sql_query(sql)
5655 .expect("mixed nullable extrema should retain complete reduction")
5656 else {
5657 panic!("mixed nullable extrema should return one projection row")
5658 };
5659 assert_eq!(rows, vec![vec![OutputValue::nat64(10)]], "{sql}");
5660 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5661 }
5662 }
5663
5664 #[test]
5665 fn exact_count_rejects_non_metadata_shapes_without_physical_reads() {
5666 let session = initialize();
5667 insert_exact_key_fixture(&session, 10);
5668 let rejected = [
5669 DynamicQuery::new(ENTITY_NAME).limit(1),
5670 DynamicQuery::new(ENTITY_NAME).select(["payload"]),
5671 DynamicQuery::new(ENTITY_NAME).order_by(crate::db::asc("payload")),
5672 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("id").eq(1_u64)),
5673 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FilterExpr::and(vec![
5674 crate::db::FieldRef::new("payload").eq(10_u64),
5675 crate::db::FieldRef::new("id").eq(1_u64),
5676 ])),
5677 DynamicQuery::new(ENTITY_NAME)
5678 .filter(crate::db::FieldRef::new("payload").in_list(0_u64..=16)),
5679 ];
5680 let data_reads_before = DataStore::current_get_call_count();
5681 let index_reads_before = IndexStore::current_entry_read_count();
5682 for request in rejected {
5683 let error = session
5684 .execute_public_exact_count(&request)
5685 .expect_err("unsupported count shape must reject");
5686 assert_eq!(
5687 error.diagnostic().error_code(),
5688 icydb_diagnostic_code::ErrorCode::RUNTIME_UNSUPPORTED,
5689 );
5690 assert!(matches!(
5691 error,
5692 crate::db::QueryError::Execute(QueryExecutionError::Unsupported(_)),
5693 ));
5694 }
5695 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5696 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5697 }
5698
5699 #[test]
5700 fn exact_count_unavailable_metadata_has_a_typed_diagnostic_without_physical_reads() {
5701 let journaled = initialize_journaled();
5702 insert_exact_key_fixture(&journaled, 10);
5703 let binding = exact_key_binding(&journaled);
5704 let requests = [
5705 DynamicQuery::new(ENTITY_NAME),
5706 DynamicQuery::new(ENTITY_NAME).filter(crate::db::FieldRef::new("payload").eq(10_u64)),
5707 ];
5708 let data_reads_before = DataStore::current_get_call_count();
5709 let index_reads_before = IndexStore::current_entry_read_count();
5710 for request in requests {
5711 let dynamic = journaled
5712 .execute_public_exact_count(&request)
5713 .expect_err("journal overlay has no exact metadata");
5714 let typed = journaled
5715 .execute_public_exact_count_for_typed_binding(&binding, &request)
5716 .expect_err("typed binding must retain unavailable-metadata diagnostic");
5717 for error in [dynamic, typed] {
5718 let diagnostic = error.diagnostic();
5719 assert_eq!(
5720 diagnostic.error_code(),
5721 icydb_diagnostic_code::ErrorCode::QUERY_EXACT_COUNT_METADATA_UNAVAILABLE,
5722 );
5723 assert_eq!(
5724 diagnostic.class(),
5725 icydb_diagnostic_code::ErrorClass::Unsupported
5726 );
5727 assert_eq!(
5728 diagnostic.origin(),
5729 icydb_diagnostic_code::ErrorOrigin::Query
5730 );
5731 assert!(matches!(
5732 error,
5733 crate::db::QueryError::Execute(QueryExecutionError::Unsupported(_)),
5734 ));
5735 }
5736 }
5737 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
5738 assert_eq!(IndexStore::current_entry_read_count(), index_reads_before);
5739 }
5740
5741 #[test]
5742 fn exact_count_typed_binding_fails_closed_after_accepted_revision_changes() {
5743 let session = initialize();
5744 let binding = exact_key_binding(&session);
5745 let request = DynamicQuery::new(ENTITY_NAME);
5746 assert_eq!(
5747 session
5748 .execute_public_exact_count_for_typed_binding(&binding, &request)
5749 .unwrap(),
5750 Some(0),
5751 );
5752
5753 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
5754 STORE_PATH,
5755 AcceptedSchemaRevision::new(2),
5756 BTreeMap::from([(ENTITY_TAG, identity_snapshot(STORE_PATH, false))]),
5757 BTreeMap::from([
5758 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
5759 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
5760 ]),
5761 );
5762 let store = session
5763 .db
5764 .store_handle(STORE_PATH)
5765 .expect("exact-count store should resolve");
5766 crate::db::commit::publish_accepted_schema_candidate(
5767 STORE_PATH,
5768 store,
5769 AcceptedSchemaRevision::INITIAL,
5770 &candidate,
5771 )
5772 .expect("successor accepted schema should publish");
5773
5774 assert_eq!(
5775 session
5776 .execute_public_exact_count_for_typed_binding(&binding, &request)
5777 .unwrap(),
5778 None,
5779 );
5780 }
5781
5782 #[cfg(feature = "sql")]
5783 fn identity_row_stored_bytes<C: CanisterKind>(
5784 session: &DbSession<C>,
5785 store_path: &'static str,
5786 key: u64,
5787 ) -> u64 {
5788 let data_key = DecodedDataStoreKey::try_from_structural_key(ENTITY_TAG, &Value::Nat64(key))
5789 .expect("identity row key should encode");
5790 let raw_key = data_key.to_raw().expect("identity raw key should encode");
5791 let store = session
5792 .db
5793 .recovered_store(store_path)
5794 .expect("identity store should resolve");
5795 store.with_data(|data_store| {
5796 u64::try_from(
5797 data_store
5798 .get(&raw_key)
5799 .expect("inserted identity row should exist")
5800 .len(),
5801 )
5802 .expect("bounded row length should fit u64")
5803 })
5804 }
5805
5806 #[cfg(feature = "sql")]
5807 fn with_stored_bytes_limit<T>(
5808 limit: u64,
5809 shape_fingerprint_prefix: u64,
5810 operation: impl FnOnce() -> Result<T, crate::db::query::intent::QueryError>,
5811 ) -> Result<T, crate::db::query::intent::QueryError> {
5812 let budget = HardExecutionBudget::uniform_for_tests(
5813 u64::MAX,
5814 HardExecutionFailureHeadroom::new(500, 256),
5815 )
5816 .with_limit_for_tests(
5817 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::StoredBytesRead,
5818 limit,
5819 );
5820 let context = HardExecutionContext::new(
5821 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
5822 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
5823 shape_fingerprint_prefix,
5824 );
5825
5826 with_query_execution_budget_for_tests(budget, context, operation)
5827 }
5828
5829 #[cfg(feature = "sql")]
5830 fn advance_with_exhausted_mutation_predicate_budget(
5831 session: &DbSession<JournaledTestCanister>,
5832 request: &MutationJobAdvanceRequest,
5833 ) -> Result<crate::db::MutationJobAdvanceReceipt, MutationJobError> {
5834 let budget = HardExecutionBudget::uniform_for_tests(
5835 u64::MAX,
5836 HardExecutionFailureHeadroom::new(1_000_000_000, 64 * 1_024),
5837 )
5838 .with_limit_for_tests(
5839 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
5840 0,
5841 );
5842 let context = HardExecutionContext::new(
5843 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
5844 icydb_diagnostic_code::DiagnosticExecutionLane::Mutation,
5845 0x6d75_7461_7465_7465,
5846 );
5847 with_execution_budget_for_tests(
5848 budget,
5849 context,
5850 || session.advance_trusted_mutation_job(request),
5851 |_| MutationJobError::Internal,
5852 )
5853 }
5854
5855 #[cfg(feature = "sql")]
5856 const fn exact_key(value: u64) -> PrimaryKeyValue {
5857 PrimaryKeyValue::Scalar(PrimaryKeyComponent::Nat64(value))
5858 }
5859
5860 #[cfg(feature = "sql")]
5861 fn assert_exact_key_batch<C: CanisterKind>(session: &DbSession<C>) {
5862 let first = insert_exact_key_fixture(session, 41);
5863 let second = insert_exact_key_fixture(session, 42);
5864 let missing = u64::MAX;
5865 let binding = exact_key_binding(session);
5866 let gets_before = DataStore::current_get_call_count();
5867 let result = session
5868 .execute_public_exact_key_batch_for_typed_binding(
5869 &binding,
5870 &[
5871 exact_key(second),
5872 exact_key(missing),
5873 exact_key(first),
5874 exact_key(second),
5875 ],
5876 )
5877 .expect("exact-key batch should execute")
5878 .expect("exact-key binding should remain current");
5879
5880 assert_eq!(result.positions, vec![0, 1, 2, 0]);
5881 assert_eq!(
5882 result.distinct_rows,
5883 vec![
5884 Some(expected_dynamic_row(second, 42)),
5885 None,
5886 Some(expected_dynamic_row(first, 41)),
5887 ],
5888 );
5889 assert_eq!(
5890 DataStore::current_get_call_count().saturating_sub(gets_before),
5891 3,
5892 "four input positions with one duplicate must perform three physical reads",
5893 );
5894 }
5895
5896 #[cfg(feature = "sql")]
5897 #[test]
5898 fn exact_key_batches_preserve_semantics_across_heap_and_journaled_stores() {
5899 assert_exact_key_batch(&initialize());
5900 assert_exact_key_batch(&initialize_journaled());
5901 }
5902
5903 #[cfg(feature = "sql")]
5904 fn assert_primary_range_materialization_fetches_once<C: CanisterKind>(
5905 session: &DbSession<C>,
5906 store_path: &'static str,
5907 ) {
5908 let key = insert_exact_key_fixture(session, 41);
5909 let stored_bytes = identity_row_stored_bytes(session, store_path, key);
5910
5911 let scalar = DynamicQuery::new(ENTITY_NAME)
5912 .select(["id", "payload"])
5913 .order_by(asc("id"))
5914 .limit(1);
5915 let gets_before = DataStore::current_get_call_count();
5916 let scalar_page = with_stored_bytes_limit(stored_bytes, 0x7072_696d_6172_792d, || {
5917 session.execute_trusted_live_page(&scalar, None)
5918 })
5919 .expect("one scalar primary-range row should fit one payload-read allowance");
5920 assert_eq!(scalar_page.row_count, 1);
5921 assert_eq!(
5922 DataStore::current_get_call_count().saturating_sub(gets_before),
5923 1,
5924 "scalar primary traversal should fetch its emitted row exactly once",
5925 );
5926
5927 let grouped = DynamicQuery::new(ENTITY_NAME)
5928 .group_by("payload")
5929 .aggregate(crate::db::count())
5930 .grouped_limits(10, 16 * 1_024)
5931 .limit(1);
5932 let gets_before = DataStore::current_get_call_count();
5933 let grouped_page = with_stored_bytes_limit(stored_bytes, 0x6772_6f75_7065_642d, || {
5934 session.execute_trusted_dynamic_grouped_query(&grouped)
5935 })
5936 .expect("one grouped primary-range row should fit one payload-read allowance");
5937 assert_eq!(grouped_page.row_count, 1);
5938 assert_eq!(
5939 DataStore::current_get_call_count().saturating_sub(gets_before),
5940 1,
5941 "grouped primary traversal should fetch its source row exactly once",
5942 );
5943 }
5944
5945 #[cfg(feature = "sql")]
5946 #[test]
5947 fn row_materialization_fetches_each_required_payload_at_most_once() {
5948 assert_primary_range_materialization_fetches_once(&initialize(), STORE_PATH);
5949 assert_primary_range_materialization_fetches_once(
5950 &initialize_journaled(),
5951 JOURNALED_STORE_PATH,
5952 );
5953 }
5954
5955 #[cfg(feature = "sql")]
5956 #[test]
5957 fn ordered_grouped_pages_close_a_group_spanning_physical_refills_before_resume() {
5958 let session = initialize();
5959 let mut patches = Vec::new();
5960 for _ in 0..70 {
5961 patches.push(dynamic_payload_patch(10));
5962 }
5963 for _ in 0..3 {
5964 patches.push(dynamic_payload_patch(20));
5965 }
5966 patches.push(dynamic_payload_patch(30));
5967 let inserted = session
5968 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, patches)
5969 .expect("ordered grouped continuation rows should insert");
5970 assert_eq!(inserted.rows.len(), 74);
5971
5972 let query = DynamicQuery::new(ENTITY_NAME)
5973 .group_by("payload")
5974 .aggregate(crate::db::count())
5975 .aggregate(crate::db::sum("id"))
5976 .order_by(asc("payload"))
5977 .grouped_limits(4, 16 * 1_024)
5978 .limit(1);
5979 let expected = [
5980 (10_u64, 70_u64, crate::types::Decimal::new(2_485, 0)),
5981 (20, 3, crate::types::Decimal::new(216, 0)),
5982 (30, 1, crate::types::Decimal::new(74, 0)),
5983 ];
5984 let mut continuation: Option<String> = None;
5985 let mut seen_cursors = std::collections::BTreeSet::new();
5986
5987 for (page_index, (group_key, row_count, id_sum)) in expected.into_iter().enumerate() {
5988 let request = continuation.as_ref().map_or_else(
5989 || query.clone(),
5990 |cursor| query.clone().cursor(cursor.clone()),
5991 );
5992 let entries_before = IndexStore::current_entry_read_count();
5993 let rows_before = DataStore::current_get_call_count();
5994 let page = session
5995 .execute_trusted_dynamic_grouped_query(&request)
5996 .unwrap_or_else(|error| {
5997 panic!("ordered grouped page {page_index} should execute: {error:?}")
5998 });
5999 let entries_read =
6000 IndexStore::current_entry_read_count().saturating_sub(entries_before);
6001 let rows_read = DataStore::current_get_call_count().saturating_sub(rows_before);
6002
6003 assert_eq!(page.row_count, 1);
6004 let [row] = page.rows.as_slice() else {
6005 panic!("ordered grouped page must contain exactly one closed group")
6006 };
6007 assert_eq!(row.group_key(), &[OutputValue::nat64(group_key)]);
6008 assert_eq!(
6009 row.aggregate_values(),
6010 &[OutputValue::nat64(row_count), OutputValue::decimal(id_sum),],
6011 );
6012 if page_index == 0 {
6013 assert!(
6014 entries_read.saturating_add(rows_read) >= 70,
6015 "the first closed group must span the maintained 64-entry physical refill",
6016 );
6017 }
6018
6019 continuation = page.next_cursor;
6020 if page_index + 1 < expected.len() {
6021 let cursor = continuation
6022 .as_ref()
6023 .expect("another closed group should retain continuation");
6024 assert!(
6025 seen_cursors.insert(cursor.clone()),
6026 "ordered grouped continuation must advance monotonically",
6027 );
6028 } else {
6029 assert_eq!(continuation, None);
6030 }
6031 }
6032 }
6033
6034 #[cfg(feature = "sql")]
6035 #[test]
6036 fn exhaustive_pages_require_and_recompare_the_complete_source_proof() {
6037 let session = initialize();
6038 let first = insert_exact_key_fixture(&session, 41);
6039 let second = insert_exact_key_fixture(&session, 42);
6040 let third = insert_exact_key_fixture(&session, 43);
6041 let query = DynamicQuery::new(ENTITY_NAME)
6042 .select(["id", "payload"])
6043 .order_by(asc("id"));
6044
6045 let page = session
6046 .execute_trusted_exhaustive_page(&query, None, None)
6047 .expect("initial exhaustive page should capture its source proof");
6048 assert_eq!(
6049 page.rows,
6050 vec![
6051 expected_dynamic_row(first, 41),
6052 expected_dynamic_row(second, 42),
6053 ],
6054 );
6055 let continuation = page
6056 .continuation
6057 .as_deref()
6058 .expect("unreturned row should retain exhaustive continuation");
6059 assert!(matches!(
6060 session.execute_trusted_exhaustive_page(&query, Some(continuation), None),
6061 Err(ExhaustiveReadError::Revision(
6062 ReadSetRevisionError::ResumeProofRequired
6063 )),
6064 ));
6065 let resumed = session
6066 .execute_trusted_exhaustive_page(&query, Some(continuation), Some(&page.proof))
6067 .expect("unchanged proof should resume exhaustive traversal");
6068 assert_eq!(resumed.rows, vec![expected_dynamic_row(third, 43)]);
6069 assert_eq!(resumed.continuation, None);
6070
6071 let stale_page = session
6072 .execute_trusted_exhaustive_page(&query, None, None)
6073 .expect("fresh exhaustive page should capture current revision");
6074 let stale_continuation = stale_page
6075 .continuation
6076 .as_deref()
6077 .expect("fresh three-row traversal should retain continuation");
6078 let _ = insert_exact_key_fixture(&session, 44);
6079 assert!(matches!(
6080 session.execute_trusted_exhaustive_page(
6081 &query,
6082 Some(stale_continuation),
6083 Some(&stale_page.proof),
6084 ),
6085 Err(ExhaustiveReadError::Revision(
6086 ReadSetRevisionError::StoreDataChanged { .. }
6087 )),
6088 ));
6089 }
6090
6091 #[cfg(feature = "sql")]
6092 #[test]
6093 fn heap_sources_cannot_back_durable_resumable_jobs() {
6094 let session = initialize();
6095 let proof = session
6096 .capture_read_set_revision_proof(&[ENTITY_NAME])
6097 .expect("heap source proof should capture for one-call exhaustive reads");
6098 let job_id = ResumableJobId::try_from_bytes([70; 32])
6099 .expect("nonzero heap test job identity should admit");
6100
6101 assert!(matches!(
6102 session.start_resumable_job(job_id, proof, Vec::new()),
6103 Err(ResumableJobError::SourceProof(
6104 ReadSetRevisionError::DurableStoreRequired { .. }
6105 )),
6106 ));
6107 }
6108
6109 #[cfg(feature = "sql")]
6110 #[test]
6111 fn proof_and_progress_controls_charge_one_shared_request_scope() {
6112 let (session, root) = initialize_journaled_with_root();
6113 let resource = icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions;
6114 let before = root.observed(resource);
6115 let proof = session
6116 .capture_read_set_revision_proof(&[ENTITY_NAME])
6117 .expect("proof capture should use the retained request scope");
6118 let job_id = ResumableJobId::try_from_bytes([75; 32])
6119 .expect("nonzero accounting job identity should admit");
6120 session
6121 .start_resumable_job(job_id, proof, Vec::new())
6122 .expect("job start should use the same retained request scope");
6123 let _ = session
6124 .resumable_job_state(job_id)
6125 .expect("job load should use the same retained request scope");
6126
6127 assert_eq!(root.observed(resource).saturating_sub(before), 3);
6128 }
6129
6130 #[cfg(feature = "sql")]
6131 #[test]
6132 fn source_proofs_ignore_unrelated_stores_but_bind_access_state_changes() {
6133 let session = initialize();
6134 let proof = session
6135 .capture_read_set_revision_proof(&[ENTITY_NAME])
6136 .expect("source proof should cover only the entity's physical store");
6137 let shared_store_proof = session
6138 .capture_read_set_revision_proof(&[ENTITY_NAME, ENTITY_NAME])
6139 .expect("entities sharing one physical source should deduplicate");
6140 assert_eq!(shared_store_proof, proof);
6141 assert_eq!(shared_store_proof.stores().len(), 1);
6142 let unrelated = session
6143 .db
6144 .store_handle(UNRELATED_STORE_PATH)
6145 .expect("unrelated registered store should resolve");
6146 unrelated.with_data_mut(|store| {
6147 let _ = store.remove(&RawDataStoreKey::from_persisted_bytes(vec![1]));
6148 });
6149 session
6150 .verify_read_set_revision_proof(&proof)
6151 .expect("a nonparticipating store mutation must not invalidate the proof");
6152
6153 let source = session
6154 .db
6155 .store_handle(STORE_PATH)
6156 .expect("participating source store should resolve");
6157 source
6158 .mark_index_building()
6159 .expect("source access-state transition should advance its revision");
6160 assert!(matches!(
6161 session.verify_read_set_revision_proof(&proof),
6162 Err(ExhaustiveReadError::Revision(
6163 ReadSetRevisionError::StoreAccessChanged { .. }
6164 )),
6165 ));
6166 }
6167
6168 #[cfg(feature = "sql")]
6169 #[expect(
6170 clippy::too_many_lines,
6171 reason = "one lifecycle test proves successful replay plus pre-page and post-page source invalidation without sharing progress state across tests"
6172 )]
6173 #[test]
6174 fn journaled_job_advance_is_idempotent_and_revision_checked_on_both_sides() {
6175 let session = initialize_journaled();
6176 let proof = session
6177 .capture_read_set_revision_proof(&[ENTITY_NAME])
6178 .expect("journaled source proof should capture");
6179 let job_id =
6180 ResumableJobId::try_from_bytes([71; 32]).expect("nonzero job identity should admit");
6181 session
6182 .start_resumable_job(job_id, proof, vec![0])
6183 .expect("journaled job should start outside its protected source revision");
6184 let request = ResumableJobAdvanceRequest::new(
6185 job_id,
6186 0,
6187 ResumableJobIdempotencyKey::new("page-0")
6188 .expect("bounded idempotency key should admit"),
6189 );
6190 let calls = Cell::new(0_u8);
6191 let receipt = session
6192 .compare_proof_and_advance(&request, |state| {
6193 calls.set(calls.get() + 1);
6194 assert_eq!(state.application_state, vec![0]);
6195 Ok::<_, ()>(
6196 ResumableJobAdvance::new(Some("cursor-1".to_string()), vec![1], vec![9])
6197 .expect("bounded application advance should admit"),
6198 )
6199 })
6200 .expect("unchanged source should advance exactly once");
6201 assert_eq!(calls.get(), 1);
6202 assert_eq!(receipt.status, ResumableJobAdvanceStatus::Advanced);
6203 assert_eq!(receipt.committed_sequence, 1);
6204
6205 let replay = session
6206 .compare_proof_and_advance::<()>(&request, |_| {
6207 panic!("lost-response replay must not execute application work")
6208 })
6209 .expect("same request identity should return its persisted receipt");
6210 assert_eq!(replay, receipt);
6211 let retained = session
6212 .resumable_job_state(job_id)
6213 .expect("advanced state should remain durable");
6214 assert_eq!(retained.sequence, 1);
6215 assert_eq!(retained.application_state, vec![1]);
6216
6217 let _ = insert_exact_key_fixture(&session, 51);
6218 let pre_change_request = ResumableJobAdvanceRequest::new(
6219 job_id,
6220 1,
6221 ResumableJobIdempotencyKey::new("page-1")
6222 .expect("bounded idempotency key should admit"),
6223 );
6224 let pre_change_calls = Cell::new(0_u8);
6225 let invalidated = session
6226 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
6227 pre_change_calls.set(pre_change_calls.get() + 1);
6228 unreachable!("pre-page proof failure must reject before application work")
6229 })
6230 .expect("source drift should persist one replayable invalidation receipt");
6231 assert_eq!(pre_change_calls.get(), 0);
6232 assert_eq!(invalidated.status, ResumableJobAdvanceStatus::Invalidated);
6233 let invalidated_state = session
6234 .resumable_job_state(job_id)
6235 .expect("invalidated job should remain inspectable");
6236 assert_eq!(invalidated_state.status, ResumableJobStatus::Invalidated);
6237 assert_eq!(invalidated_state.continuation, None);
6238 assert_eq!(invalidated_state.application_state, vec![1]);
6239 assert_eq!(
6240 session
6241 .compare_proof_and_advance::<()>(&pre_change_request, |_| {
6242 panic!("invalidation replay must not execute application work")
6243 })
6244 .expect("lost invalidation reply should replay exactly"),
6245 invalidated,
6246 );
6247
6248 let post_proof = session
6249 .capture_read_set_revision_proof(&[ENTITY_NAME])
6250 .expect("post-change journaled proof should capture");
6251 let post_job_id = ResumableJobId::try_from_bytes([72; 32])
6252 .expect("nonzero post-change job identity should admit");
6253 session
6254 .start_resumable_job(post_job_id, post_proof, vec![7])
6255 .expect("post-change journaled job should start");
6256 let post_request = ResumableJobAdvanceRequest::new(
6257 post_job_id,
6258 0,
6259 ResumableJobIdempotencyKey::new("post-page-0")
6260 .expect("bounded idempotency key should admit"),
6261 );
6262 let post_receipt = session
6263 .compare_proof_and_advance::<()>(&post_request, |_| {
6264 let _ = insert_exact_key_fixture(&session, 52);
6265 Ok(ResumableJobAdvance::new(None, vec![8], vec![10])
6266 .expect("bounded post-change candidate should admit"))
6267 })
6268 .expect("post-page drift should discard the candidate and persist invalidation");
6269 assert_eq!(post_receipt.status, ResumableJobAdvanceStatus::Invalidated);
6270 let post_state = session
6271 .resumable_job_state(post_job_id)
6272 .expect("post-page invalidation should remain inspectable");
6273 assert_eq!(post_state.status, ResumableJobStatus::Invalidated);
6274 assert_eq!(post_state.application_state, vec![7]);
6275 session
6276 .acknowledge_resumable_job(post_job_id, post_state.sequence)
6277 .expect("terminal job acknowledgement should remove retained progress");
6278 session
6279 .acknowledge_resumable_job(post_job_id, post_state.sequence)
6280 .expect("lost acknowledgement reply should be safely replayable");
6281 assert_eq!(
6282 session.resumable_job_state(post_job_id),
6283 Err(ResumableJobError::NotFound),
6284 );
6285
6286 let completed_job_id = ResumableJobId::try_from_bytes([74; 32])
6287 .expect("nonzero completed job identity should admit");
6288 let completed_proof = session
6289 .capture_read_set_revision_proof(&[ENTITY_NAME])
6290 .expect("completed-job source proof should capture");
6291 session
6292 .start_resumable_job(completed_job_id, completed_proof, Vec::new())
6293 .expect("completed-job fixture should start");
6294 let completed_request = ResumableJobAdvanceRequest::new(
6295 completed_job_id,
6296 0,
6297 ResumableJobIdempotencyKey::new("complete")
6298 .expect("bounded completion key should admit"),
6299 );
6300 let completed_receipt = session
6301 .compare_proof_and_advance::<()>(&completed_request, |_| {
6302 Ok(ResumableJobAdvance::new(None, vec![99], vec![100])
6303 .expect("bounded terminal advance should admit"))
6304 })
6305 .expect("null continuation should commit terminal completion");
6306 let completed_state = session
6307 .resumable_job_state(completed_job_id)
6308 .expect("completed state should remain replayable before acknowledgement");
6309 assert_eq!(completed_state.status, ResumableJobStatus::Completed);
6310 assert_eq!(
6311 session
6312 .compare_proof_and_advance::<()>(&completed_request, |_| {
6313 panic!("completed request replay must not execute application work")
6314 })
6315 .expect("completed request should replay until acknowledgement"),
6316 completed_receipt,
6317 );
6318 let after_completion = ResumableJobAdvanceRequest::new(
6319 completed_job_id,
6320 1,
6321 ResumableJobIdempotencyKey::new("after-complete")
6322 .expect("bounded post-completion key should admit"),
6323 );
6324 assert!(matches!(
6325 session.compare_proof_and_advance::<()>(&after_completion, |_| {
6326 panic!("completed jobs cannot execute another page")
6327 }),
6328 Err(CompareProofAndAdvanceError::Protocol(
6329 ResumableJobError::Completed
6330 )),
6331 ));
6332 session
6333 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
6334 .expect("completed job should acknowledge and free capacity");
6335 session
6336 .acknowledge_resumable_job(completed_job_id, completed_state.sequence)
6337 .expect("completion acknowledgement should be idempotent");
6338
6339 let stale_job_id = ResumableJobId::try_from_bytes([73; 32])
6340 .expect("nonzero stale-sequence job identity should admit");
6341 let stale_proof = session
6342 .capture_read_set_revision_proof(&[ENTITY_NAME])
6343 .expect("stale-sequence source proof should capture");
6344 session
6345 .start_resumable_job(stale_job_id, stale_proof, Vec::new())
6346 .expect("stale-sequence job should start");
6347 let stale_request = ResumableJobAdvanceRequest::new(
6348 stale_job_id,
6349 4,
6350 ResumableJobIdempotencyKey::new("stale").expect("bounded idempotency key should admit"),
6351 );
6352 assert!(matches!(
6353 session.compare_proof_and_advance::<()>(&stale_request, |_| {
6354 panic!("stale sequence must reject before application work")
6355 }),
6356 Err(CompareProofAndAdvanceError::Protocol(
6357 ResumableJobError::StaleSequence {
6358 expected: 4,
6359 actual: 0,
6360 }
6361 )),
6362 ));
6363 assert_eq!(
6364 session.acknowledge_resumable_job(stale_job_id, 0),
6365 Err(ResumableJobError::NotTerminal),
6366 );
6367 }
6368
6369 #[cfg(feature = "sql")]
6370 #[test]
6371 fn exact_key_batch_uses_typed_hard_execution_budget() {
6372 let session = initialize();
6373 let binding = exact_key_binding(&session);
6374 let budget =
6375 HardExecutionBudget::uniform_for_tests(0, HardExecutionFailureHeadroom::new(500, 256));
6376 let error = session
6377 .execute_exact_key_batch_with_hard_budget_for_tests(
6378 &binding,
6379 &[exact_key(u64::MAX)],
6380 &budget,
6381 )
6382 .expect_err("zero query budget should reject the exact-key route");
6383
6384 assert!(matches!(
6385 error.diagnostic().detail(),
6386 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6387 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
6388 })
6389 ));
6390 let facts = error.diagnostic_facts();
6391 assert_eq!(
6392 &facts[..5],
6393 &[
6394 (
6395 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
6396 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::QueryExecutions.raw(),
6397 ),
6398 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 0),
6399 (icydb_diagnostic_code::DiagnosticFactTag::Actual, 1),
6400 (
6401 icydb_diagnostic_code::DiagnosticFactTag::ExecutionBudgetScope,
6402 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution.raw(),
6403 ),
6404 (
6405 icydb_diagnostic_code::DiagnosticFactTag::ExecutionLane,
6406 icydb_diagnostic_code::DiagnosticExecutionLane::PublicRead.raw(),
6407 ),
6408 ],
6409 );
6410 assert_eq!(
6411 facts[5].0,
6412 icydb_diagnostic_code::DiagnosticFactTag::QueryShapeFingerprintPrefix,
6413 );
6414 assert_ne!(facts[5].1, 0);
6415 }
6416
6417 #[cfg(feature = "sql")]
6418 fn assert_planned_query_exhausts(
6419 session: &DbSession<TestCanister>,
6420 query: &crate::db::DynamicQuery,
6421 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
6422 ) {
6423 let budget = HardExecutionBudget::uniform_for_tests(
6424 u64::MAX,
6425 HardExecutionFailureHeadroom::new(500, 256),
6426 )
6427 .with_limit_for_tests(resource, 0);
6428 let context = HardExecutionContext::new(
6429 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
6430 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
6431 0x7068_7973_6963_616c,
6432 );
6433 let error = with_query_execution_budget_for_tests(budget, context, || {
6434 session.execute_trusted_live_page(query, None)
6435 })
6436 .expect_err("the injected zero resource allowance should reject planned execution");
6437
6438 assert!(matches!(
6439 error.diagnostic().detail(),
6440 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6441 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
6442 })
6443 ));
6444 assert_eq!(
6445 error.diagnostic_facts()[0],
6446 (
6447 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
6448 resource.raw(),
6449 ),
6450 );
6451 }
6452
6453 #[cfg(feature = "sql")]
6454 fn assert_grouped_query_exhausts(
6455 session: &DbSession<TestCanister>,
6456 query: &crate::db::DynamicQuery,
6457 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
6458 ) {
6459 let budget = HardExecutionBudget::uniform_for_tests(
6460 u64::MAX,
6461 HardExecutionFailureHeadroom::new(500, 256),
6462 )
6463 .with_limit_for_tests(resource, 0);
6464 let context = HardExecutionContext::new(
6465 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
6466 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
6467 0x6772_6f75_7065_642d,
6468 );
6469 let error = with_query_execution_budget_for_tests(budget, context, || {
6470 session.execute_trusted_dynamic_grouped_query(query)
6471 })
6472 .expect_err("the injected zero resource allowance should reject grouped execution");
6473
6474 assert!(matches!(
6475 error.diagnostic().detail(),
6476 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6477 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
6478 })
6479 ));
6480 assert_eq!(
6481 error.diagnostic_facts()[0],
6482 (
6483 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
6484 resource.raw(),
6485 ),
6486 );
6487 }
6488
6489 #[cfg(feature = "sql")]
6490 fn assert_sql_query_exhausts(
6491 session: &DbSession<TestCanister>,
6492 sql: &str,
6493 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
6494 ) {
6495 let budget = HardExecutionBudget::uniform_for_tests(
6496 u64::MAX,
6497 HardExecutionFailureHeadroom::new(500, 256),
6498 )
6499 .with_limit_for_tests(resource, 0);
6500 let context = HardExecutionContext::new(
6501 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
6502 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
6503 0x7371_6c2d_736f_7274,
6504 );
6505 let error = with_query_execution_budget_for_tests(budget, context, || {
6506 session.execute_trusted_sql_query(sql)
6507 })
6508 .expect_err("the injected zero resource allowance should reject SQL execution");
6509
6510 assert!(matches!(
6511 error.diagnostic().detail(),
6512 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
6513 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::ExecutionBudgetExceeded,
6514 })
6515 ));
6516 assert_eq!(
6517 error.diagnostic_facts()[0],
6518 (
6519 icydb_diagnostic_code::DiagnosticFactTag::BudgetResource,
6520 resource.raw(),
6521 ),
6522 );
6523 }
6524
6525 #[cfg(feature = "sql")]
6526 fn assert_sql_query_fits_resource_limit(
6527 session: &DbSession<TestCanister>,
6528 sql: &str,
6529 resource: icydb_diagnostic_code::DiagnosticExecutionBudgetResource,
6530 limit: u64,
6531 ) {
6532 let budget = HardExecutionBudget::uniform_for_tests(
6533 u64::MAX,
6534 HardExecutionFailureHeadroom::new(500, 256),
6535 )
6536 .with_limit_for_tests(resource, limit);
6537 let context = HardExecutionContext::new(
6538 icydb_diagnostic_code::DiagnosticExecutionBudgetScope::Execution,
6539 icydb_diagnostic_code::DiagnosticExecutionLane::TrustedRead,
6540 0x7371_6c2d_626f_756e,
6541 );
6542 with_query_execution_budget_for_tests(budget, context, || {
6543 session.execute_trusted_sql_query(sql)
6544 })
6545 .expect("bounded SQL execution should fit its physical-work limit");
6546 }
6547
6548 #[cfg(feature = "sql")]
6549 #[test]
6550 fn planned_read_routes_share_physical_resource_accounting() {
6551 let session = initialize();
6552 let first = insert_exact_key_fixture(&session, 41);
6553 insert_exact_key_fixture(&session, 42);
6554
6555 let fallback = crate::db::DynamicQuery::new(ENTITY_NAME)
6556 .filter(crate::db::FieldRef::new("id").eq(first))
6557 .select(["id", "payload"])
6558 .order_by(crate::db::asc("id"))
6559 .limit(1);
6560 assert_eq!(
6561 session
6562 .execute_trusted_live_page(&fallback, None)
6563 .expect("bounded fallback execution should preserve its result")
6564 .row_count,
6565 1,
6566 );
6567 assert_planned_query_exhausts(
6568 &session,
6569 &fallback,
6570 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::RowsVisited,
6571 );
6572
6573 let covering = crate::db::DynamicQuery::new(ENTITY_NAME)
6574 .filter(crate::db::FieldRef::new("payload").eq(41_u64))
6575 .select(["payload"])
6576 .order_by(crate::db::asc("payload"))
6577 .limit(1);
6578 assert_eq!(
6579 session
6580 .execute_trusted_live_page(&covering, None)
6581 .expect("bounded covering execution should preserve its result")
6582 .row_count,
6583 1,
6584 );
6585 assert_planned_query_exhausts(
6586 &session,
6587 &covering,
6588 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::KeyIndexEntriesVisited,
6589 );
6590
6591 let residual = crate::db::DynamicQuery::new(ENTITY_NAME)
6592 .filter(crate::db::FieldRef::new("payload").eq_field("id"))
6593 .select(["id"])
6594 .order_by(crate::db::asc("id"))
6595 .limit(1);
6596 assert_eq!(
6597 session
6598 .execute_trusted_live_page(&residual, None)
6599 .expect("bounded residual execution should preserve its result")
6600 .row_count,
6601 0,
6602 );
6603 assert_planned_query_exhausts(
6604 &session,
6605 &residual,
6606 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::PredicateExpressionSteps,
6607 );
6608
6609 assert_planned_query_exhausts(
6610 &session,
6611 &fallback,
6612 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::ResultBytes,
6613 );
6614
6615 let grouped = crate::db::DynamicQuery::new(ENTITY_NAME)
6616 .group_by("payload")
6617 .aggregate(crate::db::count())
6618 .order_by(crate::db::asc("payload"))
6619 .grouped_limits(10, 16 * 1_024)
6620 .limit(1);
6621 let grouped_result = session
6622 .execute_trusted_dynamic_grouped_query(&grouped)
6623 .expect("bounded grouped execution should preserve its result");
6624 assert_eq!(grouped_result.row_count, 1);
6625 assert!(grouped_result.next_cursor.is_some());
6626 assert_grouped_query_exhausts(
6627 &session,
6628 &grouped,
6629 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::GroupDistinctEntries,
6630 );
6631 assert_grouped_query_exhausts(
6632 &session,
6633 &grouped,
6634 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::CursorSteps,
6635 );
6636
6637 assert_sql_query_exhausts(
6638 &session,
6639 "SELECT payload, COUNT(*) AS row_count FROM IdentityRow \
6640 GROUP BY payload ORDER BY row_count DESC, payload ASC LIMIT 1",
6641 icydb_diagnostic_code::DiagnosticExecutionBudgetResource::SortEntries,
6642 );
6643 }
6644
6645 #[cfg(feature = "sql")]
6646 #[test]
6647 fn mutation_execution_budget_exhaustion_terminalizes_forward_and_verify() {
6648 let (session, _root) = initialize_journaled_with_root();
6649 assert_eq!(insert_exact_key_fixture(&session, 41), 1);
6650
6651 for (identity, sql, expected_phase) in [
6652 (
6653 91_u8,
6654 "UPDATE IdentityRow SET payload = 42 WHERE id = 1",
6655 MutationJobPhase::Forward,
6656 ),
6657 (
6658 92_u8,
6659 "UPDATE IdentityRow SET payload = 42 WHERE id = 999",
6660 MutationJobPhase::Verify,
6661 ),
6662 ] {
6663 let job_id = MutationJobId::try_from_bytes([identity; 32])
6664 .expect("budget fixture identity should admit");
6665 let mut state = session
6666 .start_trusted_sql_mutation_job(job_id, sql)
6667 .expect("budget fixture job should start");
6668 if expected_phase == MutationJobPhase::Verify {
6669 let forward = MutationJobAdvanceRequest::new(
6670 job_id,
6671 state.sequence,
6672 MutationJobIdempotencyKey::new(format!("budget-forward-{identity}"))
6673 .expect("bounded Forward replay identity should admit"),
6674 );
6675 let receipt = session
6676 .advance_trusted_mutation_job(&forward)
6677 .expect("nonmatching Forward page should enter Verify");
6678 assert_eq!(receipt.phase, MutationJobPhase::Verify);
6679 state = session
6680 .mutation_job_state(job_id)
6681 .expect("Verify predecessor should remain readable");
6682 }
6683 assert_eq!(state.phase, expected_phase);
6684
6685 let request = MutationJobAdvanceRequest::new(
6686 job_id,
6687 state.sequence,
6688 MutationJobIdempotencyKey::new(format!("budget-exhaust-{identity}"))
6689 .expect("bounded exhaustion replay identity should admit"),
6690 );
6691 let terminal = advance_with_exhausted_mutation_predicate_budget(&session, &request)
6692 .expect("admitted execution-budget failure should commit terminal progress");
6693 assert_eq!(
6694 terminal.status,
6695 MutationJobStatus::RestartRequired(
6696 MutationJobRestartReason::ExecutionBudgetPolicyExceeded,
6697 ),
6698 );
6699 assert_eq!(terminal.rows_updated, 0);
6700 assert_eq!(
6701 session.advance_trusted_mutation_job(&request),
6702 Ok(terminal.clone()),
6703 "exact terminal replay must not execute the exhausted page again",
6704 );
6705 assert_dynamic_payload(&session, 1, 41);
6706 session
6707 .acknowledge_mutation_job(job_id, terminal.committed_sequence)
6708 .expect("terminal budget fixture should acknowledge");
6709 }
6710 }
6711
6712 fn assert_dynamic_payload<C: CanisterKind>(
6713 session: &DbSession<C>,
6714 key: u64,
6715 expected_payload: u64,
6716 ) {
6717 let unchanged = session
6718 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
6719 entity: ENTITY_NAME.to_string(),
6720 key: InputValue::nat64(key),
6721 patch: dynamic_payload_patch(expected_payload),
6722 })
6723 .expect("the expected row should remain readable through a no-op update");
6724 assert_eq!(unchanged.affected_rows, 0);
6725 assert_eq!(
6726 unchanged.rows,
6727 vec![expected_dynamic_row(key, expected_payload)],
6728 );
6729 }
6730
6731 fn assert_exact_batch_backlog_pressure(
6732 pressure: &InternalError,
6733 before: JournalTailControl,
6734 next_sequence: u64,
6735 ) {
6736 assert_eq!(
6737 pressure.diagnostic().error_code(),
6738 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_CONVERGENCE_BACKLOG_PRESSURE,
6739 );
6740 assert_eq!(
6741 pressure.diagnostic_facts(),
6742 vec![
6743 (
6744 icydb_diagnostic_code::DiagnosticFactTag::BacklogResource,
6745 icydb_diagnostic_code::DiagnosticBacklogResource::Batches.raw(),
6746 ),
6747 (icydb_diagnostic_code::DiagnosticFactTag::CurrentCount, 64),
6748 (icydb_diagnostic_code::DiagnosticFactTag::ProposedCount, 1),
6749 (icydb_diagnostic_code::DiagnosticFactTag::Limit, 64),
6750 ],
6751 );
6752 assert_eq!(
6753 crate::db::commit::next_database_commit_sequence()
6754 .expect("pressure must leave the database sequence readable"),
6755 next_sequence,
6756 );
6757 assert!(matches!(
6758 crate::db::commit::observe_commit_control()
6759 .expect("pressure must leave commit control observable"),
6760 crate::db::commit::CommitControlObservation::Present {
6761 marker_present: false,
6762 ..
6763 },
6764 ));
6765 assert_eq!(
6766 JOURNALED_TAIL_STORE.with(|tail| {
6767 tail.borrow()
6768 .current_tail_control()
6769 .expect("pressure must preserve the exact tail control")
6770 }),
6771 before,
6772 );
6773 }
6774
6775 fn batch(values: &[u64]) -> Vec<AcceptedStructuralMutation> {
6776 values
6777 .iter()
6778 .map(|value| {
6779 AcceptedStructuralMutation::save(
6780 MutationMode::Insert,
6781 AcceptedStructuralMutationTarget::ResolveFromAfterImage,
6782 payload_patch(*value),
6783 )
6784 })
6785 .collect()
6786 }
6787
6788 fn atomic_progress_fixture(
6789 identity_byte: u8,
6790 ) -> (
6791 MutationJobRecord,
6792 MutationJobRecord,
6793 MutationProgressRecordOp,
6794 ) {
6795 let job_id = MutationJobId::try_from_bytes([identity_byte; 32])
6796 .expect("nonzero atomic progress job id should admit");
6797 let before = MutationJobRecord::new(job_id, vec![1, identity_byte], vec![2])
6798 .expect("atomic progress predecessor should admit");
6799 let request = MutationJobAdvanceRequest::new(
6800 job_id,
6801 0,
6802 MutationJobIdempotencyKey::new(format!("atomic-{identity_byte}"))
6803 .expect("atomic progress replay key should admit"),
6804 );
6805 let (after, _) = before
6806 .apply_transition(
6807 &request,
6808 MutationJobTransition::new(
6809 MutationJobStatus::Active,
6810 MutationJobPhase::Forward,
6811 vec![3],
6812 1,
6813 1,
6814 0,
6815 ),
6816 )
6817 .expect("atomic progress successor should admit");
6818 let operation = MutationProgressRecordOp::replace(&before, &after)
6819 .expect("atomic progress replacement should admit");
6820 (before, after, operation)
6821 }
6822
6823 fn assert_mutation_facts(
6824 error: &InternalError,
6825 session: &DbSession<TestCanister>,
6826 tail: Vec<(icydb_diagnostic_code::DiagnosticFactTag, u64)>,
6827 ) {
6828 use icydb_diagnostic_code::DiagnosticFactTag as Tag;
6829 let catalog = session
6830 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
6831 .unwrap();
6832 let fingerprint = catalog.fingerprint();
6833 let mut expected = vec![
6834 (
6835 Tag::AcceptedSchemaFingerprintMethod,
6836 u64::from(catalog.fingerprint_method_version()),
6837 ),
6838 (
6839 Tag::AcceptedSchemaFingerprintHigh,
6840 u64::from_be_bytes(fingerprint[..8].try_into().unwrap()),
6841 ),
6842 (
6843 Tag::AcceptedSchemaFingerprintLow,
6844 u64::from_be_bytes(fingerprint[8..].try_into().unwrap()),
6845 ),
6846 ];
6847 expected.extend(tail);
6848 assert_eq!(error.diagnostic_facts(), expected);
6849 assert_eq!(
6850 icydb_diagnostic_code::validate_known_diagnostic_fact_schema(
6851 error.diagnostic().error_code(),
6852 &expected,
6853 ),
6854 Ok(()),
6855 );
6856 }
6857
6858 fn assert_identity_boundary(error: &InternalError) {
6859 assert_eq!(error.class(), ErrorClass::Unsupported);
6860 assert_eq!(error.origin(), ErrorOrigin::Identity);
6861 }
6862
6863 #[test]
6864 fn generated_candidate_collision_is_identity_corruption_before_generic_uniqueness() {
6865 let generated = insert_key_exists_after_generation(true);
6866 assert_eq!(generated.class(), ErrorClass::Corruption);
6867 assert_eq!(generated.origin(), ErrorOrigin::Identity);
6868
6869 let ordinary = insert_key_exists_after_generation(false);
6870 assert_ne!(ordinary.origin(), ErrorOrigin::Identity);
6871 }
6872
6873 #[cfg(target_pointer_width = "64")]
6874 #[test]
6875 fn pre_key_candidate_count_rejects_values_beyond_the_persisted_u32_bound() {
6876 let error = checked_pre_key_candidate_count(
6877 usize::try_from(u64::from(u32::MAX) + 1).expect("64-bit usize should hold u32 + 1"),
6878 )
6879 .expect_err("candidate counts beyond u32 must reject");
6880 assert_identity_boundary(&error);
6881 }
6882
6883 #[test]
6884 #[expect(
6885 clippy::too_many_lines,
6886 reason = "one holding lifecycle proves split, merge, transfer, late-failure neutrality, result order, and Identity state"
6887 )]
6888 fn mixed_structural_batch_preserves_holding_conservation_and_failure_atomicity() {
6889 let session = initialize();
6890 let seeded = session
6891 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
6892 .expect("seed rows should commit");
6893 assert_eq!(seeded.affected_rows, 1);
6894
6895 let split = session
6896 .execute_trusted_dynamic_mutation_batch(vec![
6897 DynamicMutation::Update {
6898 entity: ENTITY_NAME.to_string(),
6899 key: InputValue::nat64(1),
6900 patch: dynamic_payload_patch(60),
6901 },
6902 DynamicMutation::Insert {
6903 entity: ENTITY_NAME.to_string(),
6904 patch: dynamic_payload_patch(40),
6905 },
6906 ])
6907 .expect("one holding should split atomically");
6908 assert_eq!(
6909 split.iter().map(|result| result.affected_rows).sum::<u32>(),
6910 2,
6911 );
6912 assert_eq!(
6913 batch_rows(&split),
6914 vec![expected_dynamic_row(1, 60), expected_dynamic_row(2, 40),],
6915 "split after-images must retain input order and exact quantity",
6916 );
6917
6918 let rejected_split = session
6919 .execute_trusted_dynamic_mutation_batch(vec![
6920 DynamicMutation::Update {
6921 entity: ENTITY_NAME.to_string(),
6922 key: InputValue::nat64(1),
6923 patch: dynamic_payload_patch(50),
6924 },
6925 DynamicMutation::Insert {
6926 entity: ENTITY_NAME.to_string(),
6927 patch: DynamicStructuralPatch::new(Vec::new()),
6928 },
6929 ])
6930 .expect_err("an invalid split output must reject the staged source update");
6931 assert_eq!(rejected_split.class(), ErrorClass::Unsupported);
6932 assert_eq!(rejected_split.origin(), ErrorOrigin::Executor);
6933 assert_mutation_facts(
6934 &rejected_split,
6935 &session,
6936 vec![
6937 (
6938 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
6939 ENTITY_TAG.value(),
6940 ),
6941 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 2),
6942 (
6943 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
6944 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
6945 ),
6946 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 1),
6947 ],
6948 );
6949 assert_dynamic_payload(&session, 1, 60);
6950 assert_dynamic_payload(&session, 2, 40);
6951
6952 let transfer = session
6953 .execute_trusted_dynamic_mutation_batch(vec![
6954 DynamicMutation::Update {
6955 entity: ENTITY_NAME.to_string(),
6956 key: InputValue::nat64(1),
6957 patch: dynamic_payload_patch(70),
6958 },
6959 DynamicMutation::Update {
6960 entity: ENTITY_NAME.to_string(),
6961 key: InputValue::nat64(2),
6962 patch: dynamic_payload_patch(30),
6963 },
6964 ])
6965 .expect("distinct transfer patches should share one atomic batch");
6966 assert_eq!(
6967 batch_rows(&transfer),
6968 vec![expected_dynamic_row(1, 70), expected_dynamic_row(2, 30),],
6969 "the transfer must preserve the exact total quantity",
6970 );
6971
6972 let merge = session
6973 .execute_trusted_dynamic_mutation_batch(vec![
6974 DynamicMutation::Delete {
6975 entity: ENTITY_NAME.to_string(),
6976 key: InputValue::nat64(2),
6977 },
6978 DynamicMutation::Update {
6979 entity: ENTITY_NAME.to_string(),
6980 key: InputValue::nat64(1),
6981 patch: dynamic_payload_patch(100),
6982 },
6983 ])
6984 .expect("two holdings should merge atomically");
6985 assert_eq!(
6986 batch_rows(&merge),
6987 vec![expected_dynamic_row(2, 30), expected_dynamic_row(1, 100),],
6988 "delete before-images and update after-images must retain input order",
6989 );
6990
6991 let resplit = session
6992 .execute_trusted_dynamic_mutation_batch(vec![
6993 DynamicMutation::Update {
6994 entity: ENTITY_NAME.to_string(),
6995 key: InputValue::nat64(1),
6996 patch: dynamic_payload_patch(60),
6997 },
6998 DynamicMutation::Insert {
6999 entity: ENTITY_NAME.to_string(),
7000 patch: dynamic_payload_patch(40),
7001 },
7002 ])
7003 .expect("the merged holding should split again");
7004 assert_eq!(
7005 batch_rows(&resplit),
7006 vec![expected_dynamic_row(1, 60), expected_dynamic_row(3, 40),],
7007 );
7008
7009 let rejected_merge = session
7010 .execute_trusted_dynamic_mutation_batch(vec![
7011 DynamicMutation::Delete {
7012 entity: ENTITY_NAME.to_string(),
7013 key: InputValue::nat64(3),
7014 },
7015 DynamicMutation::Update {
7016 entity: ENTITY_NAME.to_string(),
7017 key: InputValue::nat64(99),
7018 patch: dynamic_payload_patch(100),
7019 },
7020 ])
7021 .expect_err("a late missing merge target must preserve the earlier staged delete");
7022 assert_eq!(rejected_merge.class(), ErrorClass::NotFound);
7023 assert_dynamic_payload(&session, 1, 60);
7024 assert_dynamic_payload(&session, 3, 40);
7025
7026 SCHEMA_STORE.with(|store| {
7027 let cursor = store
7028 .borrow()
7029 .identity_statement_cursor(
7030 database_incarnation_id().expect("database incarnation should remain readable"),
7031 ENTITY_TAG,
7032 FieldId::new(1),
7033 &AcceptedFieldKind::Nat64,
7034 )
7035 .expect("mixed Identity state should remain readable");
7036 assert_eq!(cursor.expected_high_water(), 3);
7037 assert!(!cursor.has_allocations());
7038 });
7039 }
7040
7041 #[test]
7042 fn mixed_structural_batch_rejects_duplicate_holding_targets_without_mutation() {
7043 let session = initialize();
7044 session
7045 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(100)])
7046 .expect("the holding fixture should initialize");
7047
7048 let duplicate = session
7049 .execute_trusted_dynamic_mutation_batch(vec![
7050 DynamicMutation::Update {
7051 entity: ENTITY_NAME.to_string(),
7052 key: InputValue::nat64(1),
7053 patch: dynamic_payload_patch(60),
7054 },
7055 DynamicMutation::Delete {
7056 entity: ENTITY_NAME.to_string(),
7057 key: InputValue::nat64(1),
7058 },
7059 ])
7060 .expect_err("duplicate targets across operation kinds must reject");
7061 assert!(matches!(
7062 duplicate.diagnostic().detail(),
7063 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7064 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchDuplicateKey,
7065 }),
7066 ));
7067 assert_eq!(
7068 duplicate.diagnostic_facts(),
7069 vec![
7070 (
7071 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
7072 ENTITY_TAG.value(),
7073 ),
7074 (
7075 icydb_diagnostic_code::DiagnosticFactTag::FirstBatchPosition,
7076 0,
7077 ),
7078 (
7079 icydb_diagnostic_code::DiagnosticFactTag::DuplicateBatchPosition,
7080 1,
7081 ),
7082 ],
7083 );
7084 assert_dynamic_payload(&session, 1, 100);
7085 }
7086
7087 #[test]
7088 fn dynamic_insert_batch_checks_count_before_entity_resolution() {
7089 use icydb_diagnostic_code::{DiagnosticDetail, RuntimeBoundaryCode};
7090
7091 let session = initialize();
7092 for (count, boundary) in [
7093 (0, RuntimeBoundaryCode::MutationBatchEmpty),
7094 (
7095 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1,
7096 RuntimeBoundaryCode::MutationBatchTooManyItems,
7097 ),
7098 ] {
7099 let error = session
7100 .execute_trusted_dynamic_insert_batch(
7101 "",
7102 (0..count).map(|_| dynamic_payload_patch(10)).collect(),
7103 )
7104 .expect_err("batch count must reject before the empty entity name");
7105 assert_eq!(
7106 error.diagnostic().detail(),
7107 Some(&DiagnosticDetail::RuntimeBoundary { boundary }),
7108 );
7109 }
7110 let error = session
7111 .execute_trusted_dynamic_insert_batch("", vec![dynamic_payload_patch(10)])
7112 .expect_err("an admitted count must still validate the entity name");
7113 assert_eq!(error.class(), ErrorClass::Unsupported);
7114 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
7115 }
7116
7117 #[test]
7118 fn dynamic_insert_batch_preserves_positions_atomicity_and_identity_order() {
7119 use icydb_diagnostic_code::DiagnosticFactTag;
7120
7121 let session = initialize();
7122 let authored_identity = DynamicStructuralPatch::new(vec![(
7123 "id".to_string(),
7124 DynamicWriteCell::Value(InputValue::nat64(99)),
7125 )]);
7126 let error = session
7127 .execute_trusted_dynamic_insert_batch(
7128 ENTITY_NAME,
7129 vec![dynamic_payload_patch(10), authored_identity],
7130 )
7131 .expect_err("a late generated-field write must reject during lowering");
7132 assert!(
7133 error
7134 .diagnostic_facts()
7135 .contains(&(DiagnosticFactTag::BatchPosition, 1))
7136 );
7137
7138 let wrong_type = DynamicStructuralPatch::new(vec![(
7139 "payload".to_string(),
7140 DynamicWriteCell::Value(InputValue::text("invalid".to_string())),
7141 )]);
7142 session
7143 .execute_trusted_dynamic_insert_batch(
7144 ENTITY_NAME,
7145 vec![dynamic_payload_patch(10), wrong_type],
7146 )
7147 .expect_err("late value validation must reject the complete staged batch");
7148 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
7149
7150 let inserted = session
7151 .execute_trusted_dynamic_insert_batch(
7152 ENTITY_NAME,
7153 vec![dynamic_payload_patch(10), dynamic_payload_patch(20)],
7154 )
7155 .expect("rejected batches must not consume generated identities");
7156 assert_eq!(inserted.affected_rows, 2);
7157 assert_eq!(
7158 inserted.rows,
7159 vec![
7160 vec![OutputValue::nat64(1), OutputValue::nat64(10)],
7161 vec![OutputValue::nat64(2), OutputValue::nat64(20)],
7162 ],
7163 );
7164 }
7165
7166 #[test]
7167 fn mixed_structural_batch_rejects_empty_and_over_bound_before_resolution() {
7168 let session = initialize();
7169 let empty = session
7170 .execute_trusted_dynamic_mutation_batch(Vec::new())
7171 .expect_err("an empty public batch must reject");
7172 assert!(matches!(
7173 empty.diagnostic().detail(),
7174 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7175 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchEmpty,
7176 }),
7177 ));
7178 assert_eq!(
7179 empty.diagnostic_facts(),
7180 vec![(icydb_diagnostic_code::DiagnosticFactTag::ActualCount, 0,)],
7181 );
7182
7183 let requests = (0..=MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS)
7184 .map(|_| DynamicMutation::Delete {
7185 entity: ENTITY_NAME.to_string(),
7186 key: InputValue::nat64(1),
7187 })
7188 .collect();
7189 let over_bound = session
7190 .execute_trusted_dynamic_mutation_batch(requests)
7191 .expect_err("operation cap plus one must reject before row resolution");
7192 assert!(matches!(
7193 over_bound.diagnostic().detail(),
7194 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7195 boundary: icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchTooManyItems,
7196 }),
7197 ));
7198 assert_eq!(
7199 over_bound.diagnostic_facts(),
7200 vec![
7201 (
7202 icydb_diagnostic_code::DiagnosticFactTag::ActualCount,
7203 (MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS + 1) as u64,
7204 ),
7205 (
7206 icydb_diagnostic_code::DiagnosticFactTag::Limit,
7207 MAX_STRUCTURAL_MUTATION_BATCH_OPERATIONS as u64,
7208 ),
7209 ],
7210 );
7211 }
7212
7213 #[test]
7214 fn mixed_structural_batch_staged_byte_bound_uses_checked_exact_boundary() {
7215 assert_eq!(
7216 structural_mutation_staged_charge([11, 13, 17])
7217 .expect("the writer-owned formula should sum all three row-image components"),
7218 41,
7219 );
7220 let mut exact = 0;
7221 add_structural_mutation_staged_bytes(
7222 &mut exact,
7223 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
7224 )
7225 .expect("the exact staged-byte boundary should admit");
7226 assert_eq!(exact, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
7227
7228 let error = add_structural_mutation_staged_bytes(&mut exact, [1])
7229 .expect_err("one byte above the staged-byte boundary must reject");
7230 assert!(matches!(
7231 error.diagnostic().detail(),
7232 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7233 boundary:
7234 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchStagedBytesExceeded,
7235 }),
7236 ));
7237 assert_eq!(
7238 error.diagnostic_facts(),
7239 vec![
7240 (
7241 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
7242 (MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1) as u64,
7243 ),
7244 (
7245 icydb_diagnostic_code::DiagnosticFactTag::Limit,
7246 MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES as u64,
7247 ),
7248 ],
7249 );
7250
7251 let mut prefix = 0;
7252 assert_eq!(
7253 admit_structural_mutation_staged_charge(
7254 &mut prefix,
7255 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES],
7256 AcceptedStructuralMutationPacking::BoundedPrefix,
7257 )
7258 .expect("the exact prefix boundary should calculate"),
7259 AcceptedStructuralMutationStagedAdmission::Admitted,
7260 );
7261 assert_eq!(prefix, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
7262 assert_eq!(
7263 admit_structural_mutation_staged_charge(
7264 &mut prefix,
7265 [1],
7266 AcceptedStructuralMutationPacking::BoundedPrefix,
7267 )
7268 .expect("the next prefix candidate should calculate"),
7269 AcceptedStructuralMutationStagedAdmission::PageFull,
7270 );
7271 assert_eq!(prefix, MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES);
7272
7273 let mut empty_prefix = 0;
7274 assert_eq!(
7275 admit_structural_mutation_staged_charge(
7276 &mut empty_prefix,
7277 [MAX_STRUCTURAL_MUTATION_BATCH_STAGED_BYTES + 1],
7278 AcceptedStructuralMutationPacking::BoundedPrefix,
7279 )
7280 .expect("one oversized candidate should classify without mutating the prefix"),
7281 AcceptedStructuralMutationStagedAdmission::CandidateExceedsPolicy,
7282 );
7283 assert_eq!(empty_prefix, 0);
7284
7285 validate_structural_mutation_result_bytes(MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES)
7286 .expect("the exact result-byte boundary should admit");
7287 let error = validate_structural_mutation_result_bytes(
7288 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1,
7289 )
7290 .expect_err("one byte above the result-byte boundary must reject");
7291 assert!(matches!(
7292 error.diagnostic().detail(),
7293 Some(icydb_diagnostic_code::DiagnosticDetail::RuntimeBoundary {
7294 boundary:
7295 icydb_diagnostic_code::RuntimeBoundaryCode::MutationBatchResultBytesExceeded,
7296 }),
7297 ));
7298 assert_eq!(
7299 error.diagnostic_facts(),
7300 vec![
7301 (
7302 icydb_diagnostic_code::DiagnosticFactTag::ActualLength,
7303 (MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES + 1) as u64,
7304 ),
7305 (
7306 icydb_diagnostic_code::DiagnosticFactTag::Limit,
7307 MAX_STRUCTURAL_MUTATION_BATCH_RESULT_BYTES as u64,
7308 ),
7309 ],
7310 );
7311 }
7312
7313 #[expect(
7314 clippy::too_many_lines,
7315 reason = "one lifecycle proves shared materialization and every maintained frontend against the same zero-state owner"
7316 )]
7317 #[test]
7318 fn identity_insert_frontends_share_one_committed_range_without_rejected_consumption() {
7319 let session = initialize();
7320 let catalog = session
7321 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7322 .expect("identity catalog should resolve");
7323 let initial_description = session
7324 .try_describe_entity_by_name(ENTITY_NAME)
7325 .expect("accepted Identity description should resolve");
7326 assert_eq!(
7327 initial_description.entity_tag(),
7328 catalog.identity().entity_tag().value()
7329 );
7330 assert_eq!(
7331 initial_description.accepted_schema_fingerprint_method(),
7332 catalog.fingerprint_method_version()
7333 );
7334 assert_eq!(
7335 initial_description.accepted_schema_fingerprint(),
7336 catalog.fingerprint()
7337 );
7338 let initial_identity = initial_description
7339 .identity()
7340 .expect("accepted Identity policy should be described");
7341 assert_eq!(initial_identity.field(), "id");
7342 assert_eq!(initial_identity.generator(), "Identity::next");
7343 assert_eq!(initial_identity.accepted_kind(), "nat64");
7344 assert_eq!(initial_identity.minimum(), 1);
7345 assert_eq!(initial_identity.maximum(), u128::from(u64::MAX));
7346 assert_eq!(initial_identity.high_water(), 0);
7347 assert_eq!(initial_identity.remaining(), u128::from(u64::MAX));
7348 assert!(!initial_identity.exhausted());
7349
7350 let rejected = session
7351 .execute_accepted_structural_save_batch(
7352 &catalog,
7353 true,
7354 batch(&[1_000, 2_000]),
7355 Timestamp::from_millis(6),
7356 |_| Err::<(), _>(InternalError::executor_unsupported()),
7357 )
7358 .expect_err("a rejected precommit result must not publish its tentative range");
7359 assert_eq!(rejected.class(), ErrorClass::Unsupported);
7360 assert_eq!(DATA_STORE.with(|store| store.borrow().len()), 0);
7361
7362 let rows = session
7363 .execute_accepted_structural_save_batch(
7364 &catalog,
7365 true,
7366 batch(&[10, 20, 30]),
7367 Timestamp::from_millis(7),
7368 Ok,
7369 )
7370 .expect("one accepted batch should commit rows and one identity range");
7371 assert_eq!(
7372 rows.into_iter().map(|row| row.values).collect::<Vec<_>>(),
7373 vec![
7374 vec![Value::Nat64(1), Value::Nat64(10)],
7375 vec![Value::Nat64(2), Value::Nat64(20)],
7376 vec![Value::Nat64(3), Value::Nat64(30)],
7377 ],
7378 );
7379
7380 let dynamic = session
7381 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
7382 entity: ENTITY_NAME.to_string(),
7383 patch: DynamicStructuralPatch::new(vec![(
7384 "payload".to_string(),
7385 DynamicWriteCell::Value(InputValue::nat64(40)),
7386 )]),
7387 })
7388 .expect("dynamic omission should commit through shared Identity generation");
7389 assert_eq!(dynamic.affected_rows, 1);
7390
7391 for (request, operation) in [
7392 (
7393 DynamicMutation::Insert {
7394 entity: ENTITY_NAME.to_string(),
7395 patch: DynamicStructuralPatch::new(vec![
7396 (
7397 "id".to_string(),
7398 DynamicWriteCell::Value(InputValue::nat64(41)),
7399 ),
7400 (
7401 "payload".to_string(),
7402 DynamicWriteCell::Value(InputValue::nat64(42)),
7403 ),
7404 ]),
7405 },
7406 icydb_diagnostic_code::DiagnosticMutationOperation::Insert,
7407 ),
7408 (
7409 DynamicMutation::Update {
7410 entity: ENTITY_NAME.to_string(),
7411 key: InputValue::nat64(1),
7412 patch: DynamicStructuralPatch::new(vec![(
7413 "id".to_string(),
7414 DynamicWriteCell::Default,
7415 )]),
7416 },
7417 icydb_diagnostic_code::DiagnosticMutationOperation::Update,
7418 ),
7419 ] {
7420 let error = session
7421 .execute_trusted_dynamic_mutation(&request)
7422 .expect_err("structural Identity authorship and regeneration must reject");
7423 assert_eq!(error.class(), ErrorClass::Unsupported);
7424 assert_eq!(error.origin(), ErrorOrigin::Executor);
7425 assert_mutation_facts(
7426 &error,
7427 &session,
7428 vec![
7429 (
7430 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
7431 ENTITY_TAG.value(),
7432 ),
7433 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
7434 (
7435 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
7436 operation.raw(),
7437 ),
7438 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0),
7439 ],
7440 );
7441 }
7442
7443 let binding = session
7444 .issue_typed_entity_binding(&TYPED_DESCRIPTOR)
7445 .expect("typed output should bind the Identity field");
7446 let typed_patch = binding
7447 .bind_write_ordinals(vec![(1, DynamicWriteCell::Value(InputValue::nat64(50)))])
7448 .expect("typed payload should lower");
7449 let typed = session
7450 .execute_trusted_typed_mutation(
7451 &binding,
7452 DynamicTypedMutation::Insert { patch: typed_patch },
7453 )
7454 .expect("typed omission should commit through shared Identity generation");
7455 assert_eq!(
7456 typed
7457 .expect("typed insert should return one mutation result")
7458 .affected_rows,
7459 1,
7460 );
7461 let explicit_typed_patch = binding
7462 .bind_write_ordinals(vec![
7463 (0, DynamicWriteCell::Value(InputValue::nat64(51))),
7464 (1, DynamicWriteCell::Value(InputValue::nat64(52))),
7465 ])
7466 .expect("the low-level binding should retain exact authored intent");
7467 let explicit_typed_error = session
7468 .execute_trusted_typed_mutation(
7469 &binding,
7470 DynamicTypedMutation::Insert {
7471 patch: explicit_typed_patch,
7472 },
7473 )
7474 .expect_err("typed Identity authorship must reject before allocation");
7475 assert_eq!(explicit_typed_error.class(), ErrorClass::Unsupported);
7476 assert_eq!(explicit_typed_error.origin(), ErrorOrigin::Executor);
7477 assert_mutation_facts(
7478 &explicit_typed_error,
7479 &session,
7480 vec![
7481 (
7482 icydb_diagnostic_code::DiagnosticFactTag::EntityTag,
7483 ENTITY_TAG.value(),
7484 ),
7485 (icydb_diagnostic_code::DiagnosticFactTag::FieldId, 1),
7486 (
7487 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
7488 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
7489 ),
7490 (icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0),
7491 ],
7492 );
7493
7494 let replace_error = session
7495 .execute_trusted_dynamic_mutation(&DynamicMutation::Replace {
7496 entity: ENTITY_NAME.to_string(),
7497 key: InputValue::nat64(99),
7498 patch: DynamicStructuralPatch::new(vec![(
7499 "payload".to_string(),
7500 DynamicWriteCell::Value(InputValue::nat64(60)),
7501 )]),
7502 })
7503 .expect_err("save-as-insert with a chosen Identity must reject");
7504 assert_eq!(replace_error.class(), ErrorClass::Unsupported);
7505 assert_eq!(replace_error.origin(), ErrorOrigin::Executor);
7506
7507 #[cfg(feature = "sql")]
7508 {
7509 for sql in [
7510 "INSERT INTO IdentityRow (payload) VALUES (70) RETURNING id, payload",
7511 "INSERT INTO IdentityRow (id, payload) VALUES (DEFAULT, 80) RETURNING id",
7512 ] {
7513 let _result = session
7514 .execute_trusted_sql_mutation(sql)
7515 .expect("SQL omission and DEFAULT should commit Identity generation");
7516 }
7517
7518 let error = session
7519 .execute_trusted_sql_mutation(
7520 "INSERT INTO IdentityRow (id, payload) VALUES (42, 90)",
7521 )
7522 .expect_err("an explicit SQL Identity value must reject before allocation");
7523 let diagnostic = error.diagnostic();
7524 assert_eq!(
7525 diagnostic.code(),
7526 icydb_diagnostic_code::DiagnosticCode::QuerySqlWriteBoundary,
7527 );
7528 assert!(matches!(
7529 diagnostic.detail(),
7530 Some(icydb_diagnostic_code::DiagnosticDetail::SqlWriteBoundary {
7531 boundary: icydb_diagnostic_code::SqlWriteBoundaryCode::ExplicitGeneratedField,
7532 }),
7533 ));
7534 }
7535
7536 let expected_committed = if cfg!(feature = "sql") { 7 } else { 5 };
7537 assert_eq!(
7538 DATA_STORE.with(|store| store.borrow().len()),
7539 expected_committed
7540 );
7541 SCHEMA_STORE.with(|store| {
7542 let cursor = store
7543 .borrow()
7544 .identity_statement_cursor(
7545 database_incarnation_id().expect("database incarnation should remain readable"),
7546 ENTITY_TAG,
7547 FieldId::new(1),
7548 &AcceptedFieldKind::Nat64,
7549 )
7550 .expect("committed writes must leave active state readable");
7551 assert_eq!(cursor.expected_high_water(), u128::from(expected_committed),);
7552 assert!(!cursor.has_allocations());
7553 });
7554 let committed_description = session
7555 .try_describe_entity_by_name(ENTITY_NAME)
7556 .expect("committed Identity description should resolve");
7557 let committed_identity = committed_description
7558 .identity()
7559 .expect("accepted Identity policy should remain described");
7560 assert_eq!(
7561 committed_identity.high_water(),
7562 u128::from(expected_committed),
7563 );
7564 assert_eq!(
7565 committed_identity.remaining(),
7566 u128::from(u64::MAX - expected_committed),
7567 );
7568 assert!(!committed_identity.exhausted());
7569 }
7570
7571 #[test]
7572 #[expect(
7573 clippy::too_many_lines,
7574 reason = "one ordered scenario proves target/progress atomicity, every interruption wake-up, state-only admission, and successful no-op wake-up behavior"
7575 )]
7576 fn mutation_progress_and_target_rows_recover_as_one_marker_transition() {
7577 let session = initialize_journaled();
7578 let initial_entity_revision = JOURNALED_TAIL_STORE
7579 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
7580 .expect("direct initial schema publication must install entity revision authority");
7581 assert_eq!(initial_entity_revision, 1);
7582 install_startup_recovery_wakeup(record_startup_wakeup);
7583 let catalog = session
7584 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7585 .expect("journaled atomic-progress catalog should resolve");
7586 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
7587 .expect("journaled atomic-progress row layout should build");
7588
7589 for (ordinal, interruption) in [
7590 MutationCommitInterruption::MarkerPersisted,
7591 MutationCommitInterruption::JournalPublished,
7592 MutationCommitInterruption::RowsPublished,
7593 MutationCommitInterruption::ProgressReplaced,
7594 ]
7595 .into_iter()
7596 .enumerate()
7597 {
7598 let identity_byte = 31 + u8::try_from(ordinal).expect("small ordinal should fit");
7599 let (before, after, operation) = atomic_progress_fixture(identity_byte);
7600 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7601 match store.insert_mutation(&before)? {
7602 InsertMutationJobResult::Inserted => Ok(()),
7603 InsertMutationJobResult::Occupied(_) => {
7604 Err(crate::db::MutationJobError::IdentityConflict)
7605 }
7606 }
7607 })
7608 .expect("atomic predecessor should insert once");
7609
7610 let wakeups_before = STARTUP_WAKEUPS.with(Cell::get);
7611 interrupt_next_mutation_commit_for_tests(interruption);
7612 let interrupted = session.execute_accepted_structural_update_with_mutation_progress(
7613 &catalog,
7614 &descriptor,
7615 batch(&[700 + u64::try_from(ordinal).expect("small ordinal should fit")]),
7616 Timestamp::from_millis(17),
7617 operation,
7618 );
7619 assert!(
7620 interrupted.is_err(),
7621 "selected atomic boundary should interrupt"
7622 );
7623 assert_eq!(
7624 STARTUP_WAKEUPS.with(Cell::get),
7625 wakeups_before.saturating_add(1),
7626 "a normally returned retained-marker error must register its wake-up",
7627 );
7628
7629 forget_recovered_domain_for_tests(&session.db)
7630 .expect("interruption should reset volatile recovery ownership");
7631 let retained_before =
7632 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7633 store.load_mutation(before.state().job_id)
7634 })
7635 .expect("pre-driver progress should load");
7636 let row_count_before = JOURNALED_DATA_STORE.with(|store| store.borrow().len());
7637 let pending = session
7638 .db
7639 .ensure_recovered_state()
7640 .expect_err("ordinary admission must not drive retained-marker recovery");
7641 assert_eq!(
7642 pending.diagnostic().error_code(),
7643 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
7644 );
7645 assert_eq!(
7646 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7647 store.load_mutation(before.state().job_id)
7648 })
7649 .expect("post-admission progress should load"),
7650 retained_before,
7651 );
7652 assert_eq!(
7653 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
7654 row_count_before,
7655 "state-only admission must not mutate target rows",
7656 );
7657 drive_journaled_recovery_to_completion(&session);
7658 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7659 store.load_mutation(before.state().job_id)
7660 })
7661 .expect("recovered successor should load");
7662 assert_eq!(retained, after);
7663 assert_eq!(
7664 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
7665 u64::try_from(ordinal + 1).expect("small row count should fit"),
7666 );
7667 assert_eq!(
7668 JOURNALED_TAIL_STORE
7669 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
7670 .expect("recovery must publish the target entity revision"),
7671 initial_entity_revision
7672 + u64::try_from(ordinal + 1).expect("small revision delta should fit"),
7673 "target rows, entity revision, and progress must recover as one transition",
7674 );
7675 }
7676
7677 let (before, after, operation) = atomic_progress_fixture(39);
7678 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7679 match store.insert_mutation(&before)? {
7680 InsertMutationJobResult::Inserted => Ok(()),
7681 InsertMutationJobResult::Occupied(_) => {
7682 Err(crate::db::MutationJobError::IdentityConflict)
7683 }
7684 }
7685 })
7686 .expect("final predecessor should insert once");
7687 let wakeups_before_success = STARTUP_WAKEUPS.with(Cell::get);
7688 session
7689 .execute_accepted_structural_update_with_mutation_progress(
7690 &catalog,
7691 &descriptor,
7692 batch(&[799]),
7693 Timestamp::from_millis(18),
7694 operation,
7695 )
7696 .expect("uninterrupted atomic transition should clear its marker");
7697 assert_eq!(
7698 STARTUP_WAKEUPS.with(Cell::get),
7699 wakeups_before_success.saturating_add(1),
7700 "a successful retained commit must request online convergence",
7701 );
7702 let retained = with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7703 store.load_mutation(before.state().job_id)
7704 })
7705 .expect("final successor should load");
7706 assert_eq!(retained, after);
7707 forget_recovered_domain_for_tests(&session.db)
7708 .expect("post-clear recovery ownership should reset");
7709 let pending = session
7710 .db
7711 .ensure_recovered_state()
7712 .expect_err("an upgrade epoch must remain gated until its driver runs");
7713 assert_eq!(
7714 pending.diagnostic().error_code(),
7715 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
7716 );
7717 drive_journaled_recovery_to_completion(&session);
7718 assert_eq!(
7719 JOURNALED_TAIL_STORE
7720 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
7721 .expect("uninterrupted transition must retain its entity revision"),
7722 initial_entity_revision + 5,
7723 );
7724 }
7725
7726 fn assert_mixed_entity_recovered_state(session: &DbSession<JournaledTestCanister>) {
7727 for (entity_name, payload) in [
7728 (ENTITY_NAME, 100_u64),
7729 (SECOND_ENTITY_NAME, 1_100),
7730 (THIRD_ENTITY_NAME, 2_100),
7731 ] {
7732 let result = session
7733 .execute_trusted_live_page(
7734 &DynamicQuery::new(entity_name)
7735 .filter(crate::db::FieldRef::new("payload").eq(payload))
7736 .select(["id", "payload"])
7737 .order_by(crate::db::asc("id"))
7738 .limit(64),
7739 None,
7740 )
7741 .expect("every recovered mixed entity should remain queryable");
7742 assert_eq!(result.rows.len(), 1);
7743 }
7744 let retained_relation = session
7745 .execute_trusted_dynamic_mutation_batch(vec![DynamicMutation::Delete {
7746 entity: ENTITY_NAME.to_string(),
7747 key: InputValue::nat64(1),
7748 }])
7749 .expect_err("the recovered reverse relation must protect its target");
7750 assert!(retained_relation.diagnostic_facts().contains(&(
7751 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
7752 icydb_diagnostic_code::DiagnosticConstraintKind::Relation.raw(),
7753 )));
7754 JOURNALED_SCHEMA_STORE.with(|store| {
7755 let store = store.borrow();
7756 for entity_tag in [ENTITY_TAG, SECOND_ENTITY_TAG, THIRD_ENTITY_TAG] {
7757 let cursor = store
7758 .identity_statement_cursor(
7759 database_incarnation_id()
7760 .expect("database incarnation should remain readable"),
7761 entity_tag,
7762 FieldId::new(1),
7763 &AcceptedFieldKind::Nat64,
7764 )
7765 .expect("every mixed Identity owner should remain readable");
7766 assert_eq!(cursor.expected_high_water(), 1);
7767 assert!(!cursor.has_allocations());
7768 }
7769 });
7770 JOURNALED_TAIL_STORE.with(|tail| {
7771 let tail = tail.borrow();
7772 assert_eq!(
7773 tail.entity_mutation_revision(ENTITY_TAG)
7774 .expect("first entity revision should remain readable"),
7775 2,
7776 );
7777 assert_eq!(
7778 tail.entity_mutation_revision(SECOND_ENTITY_TAG)
7779 .expect("second entity revision should remain readable"),
7780 2,
7781 );
7782 assert_eq!(
7783 tail.entity_mutation_revision(THIRD_ENTITY_TAG)
7784 .expect("third entity revision should remain readable"),
7785 2,
7786 );
7787 });
7788 }
7789
7790 fn assert_mixed_entity_recovery(interruption: MutationCommitInterruption) {
7791 let session = initialize_journaled_multi_entity();
7792 interrupt_next_mutation_commit_for_tests(interruption);
7793 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
7794 DynamicMutation::Insert {
7795 entity: ENTITY_NAME.to_string(),
7796 patch: dynamic_payload_patch(100),
7797 },
7798 DynamicMutation::Insert {
7799 entity: SECOND_ENTITY_NAME.to_string(),
7800 patch: related_dynamic_payload_patch(1_100, 1),
7801 },
7802 DynamicMutation::Insert {
7803 entity: THIRD_ENTITY_NAME.to_string(),
7804 patch: dynamic_payload_patch(2_100),
7805 },
7806 ]);
7807 let interruption_error =
7808 interrupted.expect_err("the selected marker boundary should interrupt");
7809 assert_eq!(interruption_error.class(), ErrorClass::InvariantViolation);
7810 if interruption == MutationCommitInterruption::MarkerPersisted {
7811 let (marker_bytes, journal_batch_bytes) =
7812 crate::db::commit::retained_commit_marker_measurement_for_tests()
7813 .expect("the retained marker measurement should remain readable")
7814 .expect("marker persistence should retain one marker");
7815 assert_eq!(marker_bytes, 770);
7816 assert_eq!(journal_batch_bytes, vec![740]);
7817 }
7818 if interruption != MutationCommitInterruption::MarkerPersisted {
7819 let retained_batch = JOURNALED_TAIL_STORE.with(|tail| {
7820 let tail = tail.borrow();
7821 let watermark = tail
7822 .fold_watermark()
7823 .expect("the interrupted fold watermark should decode")
7824 .highest_folded_journal_sequence();
7825 tail.next_batch_after(watermark)
7826 .expect("the interrupted journal tail should decode")
7827 .expect("the interrupted marker should publish one journal batch")
7828 });
7829 let row_paths = retained_batch
7830 .records()
7831 .iter()
7832 .filter_map(|record| match record {
7833 JournalRecord::RowPut { entity_path, .. }
7834 | JournalRecord::RowDelete { entity_path, .. } => Some(entity_path.as_str()),
7835 _ => None,
7836 })
7837 .collect::<Vec<_>>();
7838 assert_eq!(
7839 row_paths,
7840 vec![ENTITY_SOURCE, SECOND_ENTITY_SOURCE, THIRD_ENTITY_SOURCE],
7841 );
7842 }
7843
7844 forget_recovered_domain_for_tests(&session.db)
7845 .expect("the retained mixed marker should reset volatile recovery ownership");
7846 drive_journaled_recovery_to_completion(&session);
7847 assert_mixed_entity_recovered_state(&session);
7848 }
7849
7850 #[test]
7851 fn mixed_entity_recovery_after_marker_persistence() {
7852 assert_mixed_entity_recovery(MutationCommitInterruption::MarkerPersisted);
7853 }
7854
7855 #[test]
7856 fn mixed_entity_recovery_after_journal_publication() {
7857 assert_mixed_entity_recovery(MutationCommitInterruption::JournalPublished);
7858 }
7859
7860 #[test]
7861 fn mixed_entity_recovery_after_row_prefix_publication() {
7862 assert_mixed_entity_recovery(MutationCommitInterruption::RowPrefixPublished);
7863 }
7864
7865 #[test]
7866 fn mixed_entity_recovery_after_all_rows_publish() {
7867 assert_mixed_entity_recovery(MutationCommitInterruption::RowsPublished);
7868 }
7869
7870 #[test]
7871 fn mixed_entity_recovery_after_state_materialization() {
7872 assert_mixed_entity_recovery(MutationCommitInterruption::StateMaterialized);
7873 }
7874
7875 #[test]
7876 fn startup_recovery_initializes_missing_entity_revisions_from_the_store_revision() {
7877 let session = initialize_journaled();
7878 let catalog = session
7879 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7880 .expect("journaled predecessor catalog should resolve");
7881 session
7882 .execute_accepted_structural_save_batch(
7883 &catalog,
7884 true,
7885 batch(&[901]),
7886 Timestamp::from_millis(21),
7887 Ok,
7888 )
7889 .expect("predecessor row should advance the store-wide revision");
7890 let baseline = JOURNALED_TAIL_STORE.with(|tail| {
7891 let mut tail = tail.borrow_mut();
7892 let baseline = tail
7893 .data_mutation_revision()
7894 .expect("predecessor store-wide revision should load");
7895 tail.clear_entity_mutation_revisions_for_tests();
7896 baseline
7897 });
7898
7899 forget_recovered_domain_for_tests(&session.db)
7900 .expect("upgrade should reset volatile recovery ownership");
7901 drive_journaled_recovery_to_completion(&session);
7902
7903 let recovered = JOURNALED_TAIL_STORE
7904 .with(|tail| tail.borrow().entity_mutation_revision(ENTITY_TAG))
7905 .expect("recovery should publish the current entity authority");
7906 assert_eq!(recovered, baseline);
7907 }
7908
7909 #[test]
7910 fn mutation_progress_neither_side_mismatch_blocks_recovery() {
7911 let session = initialize_journaled();
7912 let catalog = session
7913 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7914 .expect("journaled corruption catalog should resolve");
7915 let descriptor = AcceptedRowLayoutRuntimeContract::from_accepted_schema(catalog.snapshot())
7916 .expect("journaled corruption row layout should build");
7917 let (before, _after, operation) = atomic_progress_fixture(41);
7918 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7919 match store.insert_mutation(&before)? {
7920 InsertMutationJobResult::Inserted => Ok(()),
7921 InsertMutationJobResult::Occupied(_) => {
7922 Err(crate::db::MutationJobError::IdentityConflict)
7923 }
7924 }
7925 })
7926 .expect("corruption predecessor should insert once");
7927
7928 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::MarkerPersisted);
7929 assert!(
7930 session
7931 .execute_accepted_structural_update_with_mutation_progress(
7932 &catalog,
7933 &descriptor,
7934 batch(&[811]),
7935 Timestamp::from_millis(19),
7936 operation,
7937 )
7938 .is_err(),
7939 "marker interruption should retain recovery authority",
7940 );
7941 let (unexpected, _) = before
7942 .apply_transition(
7943 &MutationJobAdvanceRequest::new(
7944 before.state().job_id,
7945 0,
7946 MutationJobIdempotencyKey::new("unexpected-third-state")
7947 .expect("unexpected replay key should admit"),
7948 ),
7949 MutationJobTransition::new(
7950 MutationJobStatus::Active,
7951 MutationJobPhase::Forward,
7952 vec![99],
7953 2,
7954 0,
7955 0,
7956 ),
7957 )
7958 .expect("unexpected but valid progress state should admit");
7959 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7960 store.replace_mutation(&unexpected)
7961 })
7962 .expect("test should install the neither-side state");
7963
7964 forget_recovered_domain_for_tests(&session.db)
7965 .expect("corrupt recovery ownership should reset");
7966 let error = session
7967 .db
7968 .drive_startup_recovery_page()
7969 .expect_err("neither-side progress must block recovery");
7970 assert_eq!(error.class(), ErrorClass::Corruption);
7971 assert_eq!(error.origin(), ErrorOrigin::Recovery);
7972 assert_eq!(
7973 with_mutation_progress_store::<JournaledTestCanister, _>(|store| {
7974 store.load_mutation(before.state().job_id)
7975 })
7976 .expect("unexpected state should remain inspectable to the test"),
7977 unexpected,
7978 );
7979 assert!(
7980 session.db.drive_startup_recovery_page().is_err(),
7981 "a retained corrupt marker must continue blocking database access",
7982 );
7983 }
7984
7985 #[test]
7986 #[expect(
7987 clippy::too_many_lines,
7988 reason = "one ordered scenario exercises every durable interruption boundary, guarded recovery, derived rebuild, and both integrity tiers"
7989 )]
7990 fn journaled_identity_recovery_quiesces_every_publication_interruption_before_reallocation() {
7991 let session = initialize_journaled();
7992 let catalog = session
7993 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
7994 .expect("journaled identity catalog should resolve");
7995
7996 for (ordinal, interruption) in [
7997 MutationCommitInterruption::MarkerPersisted,
7998 MutationCommitInterruption::JournalPublished,
7999 MutationCommitInterruption::RowsPublished,
8000 MutationCommitInterruption::StateMaterialized,
8001 ]
8002 .into_iter()
8003 .enumerate()
8004 {
8005 interrupt_next_mutation_commit_for_tests(interruption);
8006 let interrupted = session.execute_accepted_structural_save_batch(
8007 &catalog,
8008 true,
8009 batch(&[u64::try_from(ordinal).expect("ordinal should fit")]),
8010 Timestamp::from_millis(8),
8011 Ok,
8012 );
8013 assert!(
8014 interrupted.is_err(),
8015 "the selected durable boundary should interrupt",
8016 );
8017
8018 let Err(pending) = session.execute_accepted_structural_save_batch(
8019 &catalog,
8020 true,
8021 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
8022 Timestamp::from_millis(9),
8023 Ok,
8024 ) else {
8025 panic!("ordinary mutation must not drive retained-marker recovery");
8026 };
8027 assert_eq!(
8028 pending.diagnostic().error_code(),
8029 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
8030 );
8031 drive_journaled_recovery_to_completion(&session);
8032
8033 let committed = session
8034 .execute_accepted_structural_save_batch(
8035 &catalog,
8036 true,
8037 batch(&[100 + u64::try_from(ordinal).expect("ordinal should fit")]),
8038 Timestamp::from_millis(9),
8039 Ok,
8040 )
8041 .expect("the next mutation must recover before allocating");
8042 let expected_high_water =
8043 u64::try_from((ordinal + 1) * 2).expect("small test high-water should fit");
8044 assert_eq!(
8045 committed
8046 .into_iter()
8047 .map(|row| row.values)
8048 .collect::<Vec<_>>(),
8049 vec![vec![
8050 Value::Nat64(expected_high_water),
8051 Value::Nat64(100 + u64::try_from(ordinal).expect("ordinal should fit")),
8052 ]],
8053 );
8054 assert_eq!(
8055 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
8056 expected_high_water,
8057 );
8058 JOURNALED_SCHEMA_STORE.with(|store| {
8059 let cursor = store
8060 .borrow()
8061 .identity_statement_cursor(
8062 database_incarnation_id()
8063 .expect("database incarnation should remain readable"),
8064 ENTITY_TAG,
8065 FieldId::new(1),
8066 &AcceptedFieldKind::Nat64,
8067 )
8068 .expect("guarded recovery must leave quiescent active state");
8069 assert_eq!(
8070 cursor.expected_high_water(),
8071 u128::from(expected_high_water),
8072 );
8073 assert!(!cursor.has_allocations());
8074 });
8075 }
8076
8077 for (ordinal, (interruption, deleted_key)) in [
8078 (MutationCommitInterruption::MarkerPersisted, 2),
8079 (MutationCommitInterruption::JournalPublished, 4),
8080 (MutationCommitInterruption::RowPrefixPublished, 6),
8081 (MutationCommitInterruption::RowsPublished, 8),
8082 (MutationCommitInterruption::StateMaterialized, 7),
8083 ]
8084 .into_iter()
8085 .enumerate()
8086 {
8087 let expected_payload =
8088 501 + u64::try_from(ordinal).expect("small interruption ordinal should fit");
8089 interrupt_next_mutation_commit_for_tests(interruption);
8090 let interrupted = session.execute_trusted_dynamic_mutation_batch(vec![
8091 DynamicMutation::Update {
8092 entity: ENTITY_NAME.to_string(),
8093 key: InputValue::nat64(1),
8094 patch: dynamic_payload_patch(expected_payload),
8095 },
8096 DynamicMutation::Delete {
8097 entity: ENTITY_NAME.to_string(),
8098 key: InputValue::nat64(deleted_key),
8099 },
8100 ]);
8101 assert!(
8102 interrupted.is_err(),
8103 "the selected caller-key mixed publication boundary should interrupt",
8104 );
8105 let pending = session
8106 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
8107 entity: ENTITY_NAME.to_string(),
8108 key: InputValue::nat64(1),
8109 patch: dynamic_payload_patch(expected_payload),
8110 })
8111 .expect_err("ordinary update must not drive retained-marker recovery");
8112 assert_eq!(
8113 pending.diagnostic().error_code(),
8114 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
8115 );
8116 drive_journaled_recovery_to_completion(&session);
8117 let recovered_update = session
8118 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
8119 entity: ENTITY_NAME.to_string(),
8120 key: InputValue::nat64(1),
8121 patch: dynamic_payload_patch(expected_payload),
8122 })
8123 .expect("guarded reentry should complete the marker-authorized mixed batch");
8124 assert_eq!(
8125 recovered_update.affected_rows, 0,
8126 "the recovered update must already expose its admitted final image",
8127 );
8128 let recovered_delete = session
8129 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
8130 entity: ENTITY_NAME.to_string(),
8131 key: InputValue::nat64(deleted_key),
8132 })
8133 .expect_err("the recovered delete must already be materialized");
8134 assert_eq!(recovered_delete.class(), ErrorClass::NotFound);
8135 JOURNALED_SCHEMA_STORE.with(|store| {
8136 let cursor = store
8137 .borrow()
8138 .identity_statement_cursor(
8139 database_incarnation_id()
8140 .expect("database incarnation should remain readable"),
8141 ENTITY_TAG,
8142 FieldId::new(1),
8143 &AcceptedFieldKind::Nat64,
8144 )
8145 .expect("caller-key recovery must preserve active Identity state");
8146 assert_eq!(cursor.expected_high_water(), 8);
8147 assert!(!cursor.has_allocations());
8148 });
8149 }
8150
8151 forget_recovered_domain_for_tests(&session.db)
8152 .expect("the final journal tail should remain recoverable");
8153 session
8154 .db
8155 .drive_startup_recovery_page()
8156 .expect("derived rebuild must not allocate another identity");
8157
8158 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
8159 let index_generation = JOURNALED_INDEX_STORE.with(|store| store.borrow().generation());
8160 let data_len = JOURNALED_DATA_STORE.with(|store| store.borrow().len());
8161 let index_len = JOURNALED_INDEX_STORE.with(|store| store.borrow().len());
8162 forget_recovered_domain_for_tests(&session.db)
8163 .expect("an empty-tail upgrade should reset recovery ownership");
8164 session
8165 .db
8166 .drive_startup_recovery_page()
8167 .expect("an empty-tail upgrade should admit without rebuilding stored rows or indexes");
8168 assert_eq!(
8169 JOURNALED_DATA_STORE.with(|store| store.borrow().generation()),
8170 data_generation
8171 .checked_add(1)
8172 .expect("test generation should advance once"),
8173 "empty-tail recovery must reset the disposable row projection exactly once",
8174 );
8175 assert_eq!(
8176 JOURNALED_INDEX_STORE.with(|store| store.borrow().generation()),
8177 index_generation
8178 .checked_add(1)
8179 .expect("test generation should advance once"),
8180 "empty-tail recovery must reset the disposable index projection exactly once",
8181 );
8182 assert_eq!(
8183 JOURNALED_DATA_STORE.with(|store| store.borrow().len()),
8184 data_len,
8185 "empty-tail recovery must not rebuild or remove authoritative rows",
8186 );
8187 assert_eq!(
8188 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
8189 index_len,
8190 "empty-tail recovery must not clear or rebuild canonical secondary indexes",
8191 );
8192
8193 let quick = execute_quick_integrity(
8194 &session.db,
8195 catalog.inspection_plan(),
8196 catalog.runtime_root_identity().database_incarnation(),
8197 )
8198 .expect("quiescent Identity control inventory should be inspectable");
8199 assert_eq!(quick.status(), &QuickIntegrityStatus::CompleteClean);
8200 let row_page = execute_row_integrity_page(
8201 &session.db,
8202 catalog.inspection_plan(),
8203 PhysicalUnitCheckpoint::BeforeFirst,
8204 RowInspectionLimits::standard(),
8205 )
8206 .expect("Identity rows should remain within committed high-water");
8207 assert!(row_page.exhausted());
8208 assert!(row_page.findings().is_empty());
8209
8210 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 3);
8211 assert!(
8212 JOURNALED_INDEX_STORE.with(|store| !store.borrow().is_empty()),
8213 "derived index rebuild should restore witnesses without allocating identities",
8214 );
8215 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
8216 JOURNALED_SCHEMA_STORE.with(|store| {
8217 let cursor = store
8218 .borrow()
8219 .identity_statement_cursor(
8220 database_incarnation_id().expect("database incarnation should remain readable"),
8221 ENTITY_TAG,
8222 FieldId::new(1),
8223 &AcceptedFieldKind::Nat64,
8224 )
8225 .expect("folded identity state should reopen without allocating");
8226 assert_eq!(cursor.expected_high_water(), 8);
8227 assert!(!cursor.has_allocations());
8228 });
8229 }
8230
8231 #[test]
8232 fn journaled_online_convergence_drains_the_full_backlog_in_complete_batch_callbacks_without_reallocating_ids()
8233 {
8234 const SUBMISSION: &str = "generated/8899aabbccddeeff";
8235 let session = initialize_journaled();
8236 let catalog = session
8237 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
8238 .expect("journaled identity catalog should resolve");
8239
8240 for payload in 0_u64..64 {
8241 session
8242 .execute_accepted_structural_save_batch(
8243 &catalog,
8244 true,
8245 batch(&[payload]),
8246 Timestamp::from_millis(8),
8247 Ok,
8248 )
8249 .unwrap_or_else(|error| {
8250 panic!("journaled identity fixture row {payload} should commit: {error:?}")
8251 });
8252 }
8253
8254 let before = JOURNALED_TAIL_STORE.with(|tail| {
8255 tail.borrow()
8256 .current_tail_control()
8257 .expect("online backlog control should remain valid")
8258 });
8259 assert_eq!(before.batch_count(), 64);
8260 let next_sequence = crate::db::commit::next_database_commit_sequence()
8261 .expect("database sequence preview should remain readable");
8262 let Err(pressure) = session.execute_accepted_structural_save_batch(
8263 &catalog,
8264 true,
8265 batch(&[64]),
8266 Timestamp::from_millis(8),
8267 Ok,
8268 ) else {
8269 panic!("the exact cumulative batch ceiling should reject one more batch")
8270 };
8271 assert_exact_batch_backlog_pressure(&pressure, before, next_sequence);
8272
8273 for folded_batches in 1..=64 {
8274 let complete = session
8275 .db
8276 .drive_startup_recovery_page()
8277 .expect("online complete-batch callback should commit");
8278 assert_eq!(complete, folded_batches == 64);
8279 }
8280
8281 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
8282 session
8283 .execute_accepted_structural_save_batch(
8284 &catalog,
8285 true,
8286 batch(&[64]),
8287 Timestamp::from_millis(8),
8288 Ok,
8289 )
8290 .expect("drain should make the rejected mutation retryable");
8291 assert!(
8292 session
8293 .db
8294 .drive_startup_recovery_page()
8295 .expect("the retry tail should converge"),
8296 );
8297
8298 assert_eq!(
8299 drive_generated_startup_recovery_page(&session, &JOURNALED_STORE_REGISTRY, SUBMISSION,)
8300 .expect("online convergence should commit"),
8301 GeneratedStartupDriverStep::ApplyGeneratedSchema,
8302 "journal convergence does not complete an unsubmitted generated schema",
8303 );
8304 assert!(
8305 session
8306 .db
8307 .drive_startup_recovery_page()
8308 .expect("the drained journal should remain quiescent"),
8309 );
8310
8311 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 65);
8312 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
8313 assert_dynamic_payload(&session, 1, 0);
8314 assert_dynamic_payload(&session, 65, 64);
8315 JOURNALED_SCHEMA_STORE.with(|store| {
8316 let cursor = store
8317 .borrow()
8318 .identity_statement_cursor(
8319 database_incarnation_id().expect("database incarnation should remain readable"),
8320 ENTITY_TAG,
8321 FieldId::new(1),
8322 &AcceptedFieldKind::Nat64,
8323 )
8324 .expect("online convergence must preserve active Identity state");
8325 assert_eq!(cursor.expected_high_water(), 65);
8326 assert!(!cursor.has_allocations());
8327 });
8328 }
8329
8330 #[test]
8331 fn journaled_online_convergence_reconstructs_same_key_batches_from_canonical_predecessors() {
8332 let session = initialize_journaled();
8333 session
8334 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8335 entity: ENTITY_NAME.to_string(),
8336 patch: dynamic_payload_patch(10),
8337 })
8338 .expect("the initial positioned row should commit");
8339 for payload in [20, 30] {
8340 session
8341 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
8342 entity: ENTITY_NAME.to_string(),
8343 key: InputValue::nat64(1),
8344 patch: dynamic_payload_patch(payload),
8345 })
8346 .unwrap_or_else(|error| {
8347 panic!("the positioned same-key update should commit: {error:?}")
8348 });
8349 }
8350
8351 assert_dynamic_payload(&session, 1, 30);
8352 assert_eq!(
8353 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
8354 1,
8355 "the newest live index effect should hide every predecessor",
8356 );
8357 for folded_batches in 1..=3 {
8358 let complete = session
8359 .db
8360 .drive_startup_recovery_page()
8361 .expect("the positioned same-key batch should converge");
8362 assert_eq!(complete, folded_batches == 3);
8363 }
8364
8365 assert_dynamic_payload(&session, 1, 30);
8366 assert_eq!(
8367 JOURNALED_INDEX_STORE.with(|store| store.borrow().len()),
8368 1,
8369 "canonical derived state must contain only the newest membership",
8370 );
8371 assert!(!JOURNALED_TAIL_STORE.with(|tail| tail.borrow().has_stored_batch()));
8372 }
8373
8374 #[test]
8375 fn ready_cardinality_combines_durable_base_with_exact_live_delta_and_fold_maintenance() {
8376 let session = initialize_journaled();
8377 session
8378 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8379 entity: ENTITY_NAME.to_string(),
8380 patch: dynamic_payload_patch(10),
8381 })
8382 .expect("initial cardinality row should commit");
8383 assert!(
8384 session
8385 .db
8386 .drive_startup_recovery_page()
8387 .expect("initial cardinality row should fold"),
8388 );
8389 drive_journaled_cardinality_to_ready(&session);
8390 let handle = session
8391 .db
8392 .store_handle(JOURNALED_STORE_PATH)
8393 .expect("journaled cardinality store should resolve");
8394 let (index_id, prefix_components) = journaled_user_index_prefix();
8395 reset_journaled_cardinality_projections();
8396 assert_eq!(
8397 JOURNALED_DATA_STORE.with(|store| store.borrow().exact_entity_count(ENTITY_TAG)),
8398 None,
8399 "the reopened-style volatile full count must remain unavailable",
8400 );
8401 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
8402
8403 session
8404 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8405 entity: ENTITY_NAME.to_string(),
8406 patch: dynamic_payload_patch(10),
8407 })
8408 .expect("post-Ready row should commit into the live overlay");
8409 for payload in [20, 10] {
8410 session
8411 .execute_trusted_dynamic_mutation(&DynamicMutation::Update {
8412 entity: ENTITY_NAME.to_string(),
8413 key: InputValue::nat64(2),
8414 patch: dynamic_payload_patch(payload),
8415 })
8416 .expect("same-key post-Ready overlay should commit");
8417 }
8418 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
8419 for folded in 1..=3 {
8420 let complete = session
8421 .db
8422 .drive_startup_recovery_page()
8423 .expect("post-Ready row should fold with exact maintenance");
8424 assert_eq!(complete, folded == 3);
8425 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
8426 }
8427 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 2);
8428 session
8429 .execute_trusted_dynamic_mutation(&DynamicMutation::Delete {
8430 entity: ENTITY_NAME.to_string(),
8431 key: InputValue::nat64(2),
8432 })
8433 .expect("post-Ready delete should commit into the live overlay");
8434 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
8435 assert!(
8436 session
8437 .db
8438 .drive_startup_recovery_page()
8439 .expect("post-Ready delete should fold with exact maintenance"),
8440 );
8441 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
8442 mark_journaled_cardinality_building();
8443 assert_eq!(
8444 handle.exact_entity_count(ENTITY_TAG),
8445 None,
8446 "non-Ready evidence must select the conservative path",
8447 );
8448 #[cfg(feature = "sql")]
8449 {
8450 let data_reads_before = DataStore::current_get_call_count();
8451 let crate::db::SqlStatementResult::Projection { rows, .. } = session
8452 .execute_trusted_sql_query("SELECT COUNT(*) FROM IdentityRow")
8453 .expect("non-Ready entity cardinality should retain SQL fallback")
8454 else {
8455 panic!("fallback count should return one projection row")
8456 };
8457 assert_eq!(rows, vec![vec![OutputValue::nat64(1)]]);
8458 assert_eq!(DataStore::current_get_call_count(), data_reads_before);
8459 }
8460 }
8461
8462 #[test]
8463 fn journaled_cardinality_rejects_volatile_counts_and_unfolded_accepted_root_drift() {
8464 let session = initialize_journaled();
8465 session
8466 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8467 entity: ENTITY_NAME.to_string(),
8468 patch: dynamic_payload_patch(10),
8469 })
8470 .expect("cardinality fixture row should commit");
8471 assert!(
8472 session
8473 .db
8474 .drive_startup_recovery_page()
8475 .expect("cardinality fixture row should fold"),
8476 );
8477 drive_journaled_cardinality_to_ready(&session);
8478 let handle = session
8479 .db
8480 .store_handle(JOURNALED_STORE_PATH)
8481 .expect("journaled cardinality store should resolve");
8482 let (index_id, prefix_components) = journaled_user_index_prefix();
8483 let data_generation = JOURNALED_DATA_STORE.with(|store| store.borrow().generation());
8484
8485 assert_eq!(
8486 JOURNALED_DATA_STORE.with(|store| store.borrow().exact_entity_count(ENTITY_TAG)),
8487 Some(1),
8488 "the live full-count cache should be populated before accepted-root drift",
8489 );
8490 assert_eq!(
8491 JOURNALED_INDEX_STORE.with(|store| {
8492 store.borrow().exact_prefix_cardinality(
8493 data_generation,
8494 IndexKeyKind::User,
8495 index_id,
8496 prefix_components.as_slice(),
8497 )
8498 }),
8499 Some(1),
8500 "the live prefix-count cache should be populated before accepted-root drift",
8501 );
8502 assert_eq!(
8503 JOURNALED_INDEX_STORE.with(|store| {
8504 store.borrow().exact_child_prefixes_for_parent_set(
8505 data_generation,
8506 IndexKeyKind::User,
8507 index_id,
8508 [prefix_components.as_slice()],
8509 8,
8510 )
8511 }),
8512 Some(Vec::new()),
8513 "the volatile child-prefix cache should demonstrate the bypass fixture",
8514 );
8515 assert_eq!(
8516 handle.exact_user_index_child_prefixes_for_parent_set(
8517 data_generation,
8518 index_id,
8519 [prefix_components.as_slice()],
8520 8,
8521 ),
8522 None,
8523 "journaled child enumeration must use its conservative route instead of volatile authority",
8524 );
8525 assert_journaled_cardinality(handle, index_id, prefix_components.as_slice(), 1);
8526
8527 let candidate = accepted_schema_candidate_with_field_bindings_for_tests(
8528 JOURNALED_STORE_PATH,
8529 AcceptedSchemaRevision::new(2),
8530 BTreeMap::from([(ENTITY_TAG, identity_snapshot(JOURNALED_STORE_PATH, false))]),
8531 BTreeMap::from([
8532 ((ENTITY_TAG, source_key(ID_SOURCE)), FieldId::new(1)),
8533 ((ENTITY_TAG, source_key(PAYLOAD_SOURCE)), FieldId::new(2)),
8534 ]),
8535 );
8536 crate::db::commit::publish_accepted_schema_candidate(
8537 JOURNALED_STORE_PATH,
8538 handle,
8539 AcceptedSchemaRevision::INITIAL,
8540 &candidate,
8541 )
8542 .expect("a successor accepted root should publish into the live overlay");
8543
8544 assert_eq!(
8545 handle.exact_entity_count(ENTITY_TAG),
8546 None,
8547 "an unfolded accepted root must invalidate durable evidence immediately",
8548 );
8549 assert_eq!(
8550 handle.exact_user_index_prefix_count(
8551 data_generation,
8552 IndexKeyKind::User,
8553 index_id,
8554 prefix_components.as_slice(),
8555 ),
8556 None,
8557 "journaled consumers must not fall back to a populated volatile prefix cache",
8558 );
8559 }
8560
8561 #[test]
8562 fn journaled_convergence_uses_final_batch_rows_for_unique_release() {
8563 let session = initialize_journaled_with_unique_payload();
8564 let inserted = session
8565 .execute_trusted_dynamic_insert_batch(
8566 ENTITY_NAME,
8567 vec![dynamic_payload_patch(10), dynamic_payload_patch(20)],
8568 )
8569 .expect("the unique journal fixture should commit");
8570 assert_eq!(
8571 inserted.rows,
8572 vec![expected_dynamic_row(1, 10), expected_dynamic_row(2, 20)],
8573 );
8574 assert!(
8575 session
8576 .db
8577 .drive_startup_recovery_page()
8578 .expect("the unique fixture should become canonical"),
8579 );
8580
8581 let swapped = session
8582 .execute_trusted_dynamic_mutation_batch(vec![
8583 DynamicMutation::Update {
8584 entity: ENTITY_NAME.to_string(),
8585 key: InputValue::nat64(1),
8586 patch: dynamic_payload_patch(20),
8587 },
8588 DynamicMutation::Update {
8589 entity: ENTITY_NAME.to_string(),
8590 key: InputValue::nat64(2),
8591 patch: dynamic_payload_patch(10),
8592 },
8593 ])
8594 .expect("one journal batch should admit a final-row unique swap");
8595 assert_eq!(
8596 batch_rows(&swapped),
8597 vec![expected_dynamic_row(1, 20), expected_dynamic_row(2, 10)],
8598 );
8599 assert!(
8600 session
8601 .db
8602 .drive_startup_recovery_page()
8603 .expect("the unique swap should converge in one complete batch"),
8604 );
8605
8606 let released = session
8607 .execute_trusted_dynamic_mutation_batch(vec![
8608 DynamicMutation::Delete {
8609 entity: ENTITY_NAME.to_string(),
8610 key: InputValue::nat64(1),
8611 },
8612 DynamicMutation::Insert {
8613 entity: ENTITY_NAME.to_string(),
8614 patch: dynamic_payload_patch(20),
8615 },
8616 ])
8617 .expect("a journaled delete should release its unique value to the final insert");
8618 assert_eq!(
8619 batch_rows(&released),
8620 vec![expected_dynamic_row(1, 20), expected_dynamic_row(3, 20)],
8621 );
8622 assert!(
8623 session
8624 .db
8625 .drive_startup_recovery_page()
8626 .expect("the delete and unique reuse should converge together"),
8627 );
8628
8629 assert_dynamic_payload(&session, 2, 10);
8630 assert_dynamic_payload(&session, 3, 20);
8631 assert_eq!(JOURNALED_INDEX_STORE.with(|store| store.borrow().len()), 2);
8632 assert!(
8633 session
8634 .execute_trusted_dynamic_insert_batch(ENTITY_NAME, vec![dynamic_payload_patch(20)],)
8635 .is_err(),
8636 "the converged unique index must remain authoritative",
8637 );
8638 }
8639
8640 #[test]
8641 fn journaled_startup_recovery_completes_one_large_batch_atomically() {
8642 let session = initialize_journaled();
8643 let catalog = session
8644 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
8645 .expect("journaled identity catalog should resolve");
8646 let payloads = (0_u64..129).collect::<Vec<_>>();
8647 session
8648 .execute_accepted_structural_save_batch(
8649 &catalog,
8650 true,
8651 batch(&payloads),
8652 Timestamp::from_millis(9),
8653 Ok,
8654 )
8655 .expect("one large journal batch should commit");
8656
8657 forget_recovered_domain_for_tests(&session.db)
8658 .expect("upgrade should reset recovery ownership");
8659 assert!(
8660 !session
8661 .db
8662 .drive_startup_recovery_page()
8663 .expect("replay should precede folding")
8664 );
8665 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
8666 assert!(
8667 !session
8668 .db
8669 .drive_startup_recovery_page()
8670 .expect("the complete batch recovery page should commit"),
8671 );
8672
8673 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 129);
8674 JOURNALED_TAIL_STORE.with(|tail| {
8675 let tail = tail.borrow();
8676 assert!(!tail.has_stored_batch());
8677 });
8678 assert!(session.db.ensure_recovered_state().is_err());
8679 assert!(
8680 session
8681 .db
8682 .drive_startup_recovery_page()
8683 .expect("verification should finish startup")
8684 );
8685 assert_dynamic_payload(&session, 1, 0);
8686 assert_dynamic_payload(&session, 129, 128);
8687 }
8688
8689 #[test]
8690 fn complete_batch_validation_rejects_a_late_record_before_canonical_writes() {
8691 let session = initialize_journaled();
8692 let catalog = session
8693 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
8694 .expect("journaled identity catalog should resolve");
8695 session
8696 .execute_accepted_structural_save_batch(
8697 &catalog,
8698 true,
8699 batch(&[7]),
8700 Timestamp::from_millis(9),
8701 Ok,
8702 )
8703 .expect("journal batch predecessor should commit");
8704
8705 JOURNALED_TAIL_STORE.with(|tail| {
8706 let mut tail = tail.borrow_mut();
8707 let original = tail
8708 .next_batch_after(JournalSequence::new(0))
8709 .expect("journal batch should decode")
8710 .expect("journal batch should exist");
8711 let mut records = original.records().to_vec();
8712 records.push(
8713 JournalRecord::schema_put(JOURNALED_STORE_PATH, vec![0xff; 8])
8714 .expect("bounded semantic corruption should build"),
8715 );
8716 let corrupted = JournalBatch::new_with_database_commit_sequence(
8717 original.batch_id(),
8718 original.commit_marker_id(),
8719 original.journal_sequence(),
8720 original.database_commit_sequence(),
8721 records,
8722 )
8723 .expect("current corrupt batch shape should build");
8724 let encoded = encode_journal_batch(&corrupted)
8725 .expect("current corrupt batch envelope should encode");
8726 tail.clear_batches_through(original.journal_sequence());
8727 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
8728 .expect("corrupt persisted batch should replace the predecessor");
8729 });
8730
8731 forget_recovered_domain_for_tests(&session.db)
8732 .expect("upgrade should reset recovery ownership");
8733 assert!(
8734 !session
8735 .db
8736 .drive_startup_recovery_page()
8737 .expect("replay should precede fold validation")
8738 );
8739 let error = session
8740 .db
8741 .drive_startup_recovery_page()
8742 .expect_err("late semantic corruption must fail before fold apply");
8743 assert_eq!(error.class(), ErrorClass::Corruption);
8744 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
8745 JOURNALED_TAIL_STORE.with(|tail| {
8746 let tail = tail.borrow();
8747 assert_eq!(
8748 tail.fold_watermark()
8749 .expect("watermark should remain readable")
8750 .highest_folded_journal_sequence(),
8751 JournalSequence::new(0),
8752 );
8753 assert!(tail.has_stored_batch());
8754 });
8755 }
8756
8757 #[test]
8758 fn prepared_batch_row_evidence_rejects_a_late_malformed_row_before_canonical_writes() {
8759 let session = initialize_journaled();
8760 let catalog = session
8761 .accepted_schema_catalog_context_for_entity_name(Some(ENTITY_NAME))
8762 .expect("journaled identity catalog should resolve");
8763 session
8764 .execute_accepted_structural_save_batch(
8765 &catalog,
8766 true,
8767 batch(&[7, 8]),
8768 Timestamp::from_millis(9),
8769 Ok,
8770 )
8771 .expect("two-row journal batch should commit");
8772
8773 JOURNALED_TAIL_STORE.with(|tail| {
8774 let mut tail = tail.borrow_mut();
8775 let original = tail
8776 .next_batch_after(JournalSequence::new(0))
8777 .expect("journal batch should decode")
8778 .expect("journal batch should exist");
8779 let mut records = original.records().to_vec();
8780 let mut row_ordinal = 0_u8;
8781 for record in &mut records {
8782 if let JournalRecord::RowPut { row_bytes, .. } = record {
8783 row_ordinal = row_ordinal.saturating_add(1);
8784 if row_ordinal == 2 {
8785 *row_bytes = vec![0xff; 8];
8786 break;
8787 }
8788 }
8789 }
8790 assert_eq!(row_ordinal, 2, "the late row record should be present");
8791 let corrupted = JournalBatch::new_with_database_commit_sequence(
8792 original.batch_id(),
8793 original.commit_marker_id(),
8794 original.journal_sequence(),
8795 original.database_commit_sequence(),
8796 records,
8797 )
8798 .expect("current corrupt batch shape should build");
8799 let encoded = encode_journal_batch(&corrupted)
8800 .expect("current corrupt batch envelope should encode");
8801 tail.clear_batches_through(original.journal_sequence());
8802 tail.insert_raw_batch_for_tests(original.journal_sequence(), encoded)
8803 .expect("corrupt persisted batch should replace the predecessor");
8804 });
8805
8806 forget_recovered_domain_for_tests(&session.db)
8807 .expect("upgrade should reset recovery ownership");
8808 assert!(
8809 !session
8810 .db
8811 .drive_startup_recovery_page()
8812 .expect("replay should precede row preparation")
8813 );
8814 let error = session
8815 .db
8816 .drive_startup_recovery_page()
8817 .expect_err("late malformed row must fail during complete batch preparation");
8818 assert_eq!(error.class(), ErrorClass::Corruption);
8819 assert_eq!(JOURNALED_DATA_STORE.with(|store| store.borrow().len()), 0);
8820 JOURNALED_TAIL_STORE.with(|tail| {
8821 let tail = tail.borrow();
8822 assert_eq!(
8823 tail.fold_watermark()
8824 .expect("watermark should remain readable")
8825 .highest_folded_journal_sequence(),
8826 JournalSequence::new(0),
8827 );
8828 assert!(tail.has_stored_batch());
8829 });
8830 }
8831
8832 #[test]
8833 fn typed_mutation_batch_recovers_as_one_marker_atomic_transition() {
8834 let session = initialize_journaled();
8835 let binding = exact_key_binding(&session);
8836 session
8837 .execute_trusted_same_entity_typed_mutation_batch(
8838 &binding,
8839 vec![
8840 typed_payload_insert(&binding, 10),
8841 typed_payload_insert(&binding, 20),
8842 ],
8843 )
8844 .expect("typed recovery fixture should commit")
8845 .expect("typed recovery fixture binding should remain current");
8846 interrupt_next_mutation_commit_for_tests(MutationCommitInterruption::RowsPublished);
8847
8848 let interrupted = session.execute_trusted_same_entity_typed_mutation_batch(
8849 &binding,
8850 vec![typed_payload_delete(1), typed_payload_insert(&binding, 30)],
8851 );
8852 assert!(
8853 interrupted.is_err(),
8854 "typed batch should expose the selected durable interruption",
8855 );
8856 let pending = session
8857 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
8858 entity: ENTITY_NAME.to_string(),
8859 patch: dynamic_payload_patch(30),
8860 })
8861 .expect_err("ordinary writes must not bypass retained-marker recovery");
8862 assert_eq!(
8863 pending.diagnostic().error_code(),
8864 icydb_diagnostic_code::ErrorCode::RUNTIME_BOUNDARY_DATABASE_STARTUP_RECOVERY_PENDING,
8865 );
8866
8867 drive_journaled_recovery_to_completion(&session);
8868 let recovered = session
8869 .execute_trusted_live_page(&crate::db::DynamicQuery::new(ENTITY_NAME), None)
8870 .expect("the recovered typed batch should be readable");
8871 assert_eq!(
8872 recovered.rows,
8873 vec![expected_dynamic_row(2, 20), expected_dynamic_row(3, 30)],
8874 );
8875 }
8876}
8877
8878#[cfg(test)]
8879mod targeted_rule_mutation_tests {
8880 use super::{
8881 DbSession, DynamicMutation, DynamicStructuralPatch, DynamicTypedMutation, DynamicWriteCell,
8882 TypedEntityDescriptor, TypedFieldType,
8883 };
8884 use crate::{
8885 db::{
8886 TypedFieldDescriptor,
8887 data::{DataStore, encode_input_value_for_candidate_field_contract},
8888 index::IndexStore,
8889 registry::{StoreAllocationIdentities, StoreRegistry, StoreRuntimeStorageCapabilities},
8890 schema::{
8891 AcceptedCheckLiteralV1, AcceptedCompositeCatalog, AcceptedFieldDecodeContract,
8892 AcceptedFieldKind, AcceptedNamedTypeIdentity, AcceptedRuleOperation,
8893 AcceptedRuleTarget, AcceptedSchemaRevision, AcceptedSourceBindingCatalog,
8894 ConstraintOrigin, FieldId, FieldStorageDecode, FieldWriteManagement, LeafCodec,
8895 PersistedFieldSnapshot, PersistedNestedLeafSnapshot, PersistedSchemaSnapshot,
8896 ScalarCodec, SchemaFieldSlot, SchemaFieldWritePolicy, SchemaInsertDefault,
8897 SchemaRowLayout, SchemaStore, SchemaVersion,
8898 accepted_schema_candidate_with_catalogs_for_tests,
8899 build_record_newtype_composite_catalog_for_tests,
8900 empty_accepted_enum_catalog_for_tests, enum_catalog::ValueAdmissionBudget,
8901 },
8902 },
8903 error::InternalError,
8904 traits::{CanisterKind, Path},
8905 types::EntityTag,
8906 value::InputValue,
8907 };
8908 use icydb_schema::{
8909 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, ScalarType, TypeSourceKey,
8910 };
8911 use std::{cell::RefCell, collections::BTreeMap};
8912
8913 const STORE_PATH: &str = "session::write::targeted_rule_mutation_tests::Store";
8914 const ENTITY_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity";
8915 const ID_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::id";
8916 const PROFILE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Entity::profile";
8917 const UPDATED_AT_SOURCE: &str =
8918 "session::write::targeted_rule_mutation_tests::Entity::updated_at";
8919 const PROFILE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Profile";
8920 const DEGREE_TYPE_SOURCE: &str = "session::write::targeted_rule_mutation_tests::Degree";
8921 const DEGREE_MEMBER_SOURCE: &str =
8922 "session::write::targeted_rule_mutation_tests::Profile::degree";
8923 const DEGREE_RULE_SOURCE: &str =
8924 "session::write::targeted_rule_mutation_tests::Profile::degree_multiple";
8925 const TYPED_DESCRIPTOR: TypedEntityDescriptor = TypedEntityDescriptor::new(
8926 ENTITY_SOURCE,
8927 &[ID_SOURCE],
8928 &[
8929 TypedFieldDescriptor::new(ID_SOURCE, TypedFieldType::Scalar(ScalarType::Nat64), false),
8930 TypedFieldDescriptor::new(
8931 PROFILE_SOURCE,
8932 TypedFieldType::Named(PROFILE_TYPE_SOURCE),
8933 false,
8934 ),
8935 TypedFieldDescriptor::new(
8936 UPDATED_AT_SOURCE,
8937 TypedFieldType::Scalar(ScalarType::Timestamp),
8938 false,
8939 ),
8940 ],
8941 );
8942
8943 struct TestCanister;
8944
8945 impl Path for TestCanister {
8946 const PATH: &'static str = "session::write::targeted_rule_mutation_tests::Canister";
8947 }
8948
8949 impl CanisterKind for TestCanister {
8950 fn commit_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
8951 Ok(43)
8952 }
8953 const COMMIT_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.commit.v1";
8954 fn startup_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
8955 Ok(49)
8956 }
8957 const STARTUP_STABLE_KEY: &'static str = "icydb.targeted_mutation_tests.startup.control.v1";
8958 fn integrity_progress_memory_id() -> Result<u8, ic_memory::RuntimeOpenError> {
8959 Ok(44)
8960 }
8961 const INTEGRITY_PROGRESS_STABLE_KEY: &'static str =
8962 "icydb.targeted_mutation_tests.integrity.progress.v1";
8963 }
8964
8965 thread_local! {
8966 static DATA_STORE: RefCell<DataStore> = const { RefCell::new(DataStore::init_heap()) };
8967 static INDEX_STORE: RefCell<IndexStore> = const { RefCell::new(IndexStore::init_heap()) };
8968 static SCHEMA_STORE: RefCell<SchemaStore> =
8969 const { RefCell::new(SchemaStore::init_heap()) };
8970 static STORE_REGISTRY: StoreRegistry = {
8971 let mut registry = StoreRegistry::new();
8972 registry.register_store(
8973 STORE_PATH,
8974 &DATA_STORE,
8975 &INDEX_STORE,
8976 &SCHEMA_STORE,
8977 StoreAllocationIdentities::absent(),
8978 StoreRuntimeStorageCapabilities::heap(),
8979 ).expect("targeted mutation test store should register");
8980 registry
8981 };
8982 }
8983
8984 fn source<T, E: std::fmt::Debug>(raw: &str, parse: impl FnOnce(String) -> Result<T, E>) -> T {
8985 parse(raw.to_string()).expect("test source identity should admit")
8986 }
8987
8988 fn profile_input(degree: u64) -> InputValue {
8989 InputValue::map(vec![(
8990 InputValue::from("degree"),
8991 InputValue::nat64(degree),
8992 )])
8993 }
8994
8995 fn structural_patch(id: u64, degree: u64) -> DynamicStructuralPatch {
8996 DynamicStructuralPatch::new(vec![
8997 (
8998 "id".to_string(),
8999 DynamicWriteCell::Value(InputValue::nat64(id)),
9000 ),
9001 (
9002 "profile".to_string(),
9003 DynamicWriteCell::Value(profile_input(degree)),
9004 ),
9005 ])
9006 }
9007
9008 fn encoded_value(
9009 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
9010 composite_catalog: &AcceptedCompositeCatalog,
9011 name: &str,
9012 kind: &AcceptedFieldKind,
9013 storage_decode: FieldStorageDecode,
9014 leaf_codec: LeafCodec,
9015 value: InputValue,
9016 ) -> Vec<u8> {
9017 let field = AcceptedFieldDecodeContract::new(name, kind, false, storage_decode, leaf_codec);
9018 encode_input_value_for_candidate_field_contract(
9019 enum_catalog,
9020 composite_catalog,
9021 field,
9022 value,
9023 &mut ValueAdmissionBudget::standard(),
9024 )
9025 .expect("test accepted value should encode")
9026 }
9027
9028 fn nat64_literal(
9029 enum_catalog: &crate::db::schema::AcceptedEnumCatalog,
9030 composite_catalog: &AcceptedCompositeCatalog,
9031 value: u64,
9032 ) -> AcceptedCheckLiteralV1 {
9033 let kind = AcceptedFieldKind::Nat64;
9034 AcceptedCheckLiteralV1::from_accepted_parts(
9035 kind.clone(),
9036 FieldStorageDecode::ByKind,
9037 LeafCodec::Scalar(ScalarCodec::Nat64),
9038 encoded_value(
9039 enum_catalog,
9040 composite_catalog,
9041 "degree_bound",
9042 &kind,
9043 FieldStorageDecode::ByKind,
9044 LeafCodec::Scalar(ScalarCodec::Nat64),
9045 InputValue::nat64(value),
9046 ),
9047 )
9048 }
9049
9050 fn targeted_constraint_id(error: &InternalError) -> u32 {
9051 let facts = error.diagnostic_facts();
9052 assert!(facts.contains(&(
9053 icydb_diagnostic_code::DiagnosticFactTag::MutationOperation,
9054 icydb_diagnostic_code::DiagnosticMutationOperation::Insert.raw(),
9055 )));
9056 assert!(facts.contains(&(icydb_diagnostic_code::DiagnosticFactTag::BatchPosition, 0,)));
9057 assert!(facts.contains(&(
9058 icydb_diagnostic_code::DiagnosticFactTag::ConstraintKind,
9059 icydb_diagnostic_code::DiagnosticConstraintKind::TargetedRule.raw(),
9060 )));
9061 assert_eq!(
9062 facts
9063 .iter()
9064 .filter(|(tag, _)| matches!(
9065 tag,
9066 icydb_diagnostic_code::DiagnosticFactTag::RootField
9067 | icydb_diagnostic_code::DiagnosticFactTag::RecordMember
9068 ))
9069 .copied()
9070 .collect::<Vec<_>>(),
9071 vec![
9072 (icydb_diagnostic_code::DiagnosticFactTag::RootField, 2),
9073 (
9074 icydb_diagnostic_code::DiagnosticFactTag::RecordMember,
9075 icydb_diagnostic_code::pack_u32_pair(1, 1),
9076 ),
9077 ]
9078 );
9079 let value = facts
9080 .iter()
9081 .find_map(|(tag, value)| {
9082 (*tag == icydb_diagnostic_code::DiagnosticFactTag::ConstraintId).then_some(*value)
9083 })
9084 .expect("targeted mutation should retain its accepted constraint ID");
9085 u32::try_from(value).expect("accepted constraint ID fits u32")
9086 }
9087
9088 #[expect(
9089 clippy::too_many_lines,
9090 reason = "one end-to-end fixture proves every maintained write frontend converges on the same accepted targeted-rule schedule"
9091 )]
9092 #[test]
9093 fn targeted_rules_converge_across_dynamic_typed_sql_default_timestamp_and_batch_writes() {
9094 DATA_STORE.with(|store| *store.borrow_mut() = DataStore::init_heap());
9095 INDEX_STORE.with(|store| *store.borrow_mut() = IndexStore::init_heap());
9096 SCHEMA_STORE.with(|store| *store.borrow_mut() = SchemaStore::init_heap());
9097
9098 let entity_tag = EntityTag::new(93);
9099 let enum_catalog = empty_accepted_enum_catalog_for_tests();
9100 let (composite_catalog, profile_type, degree_type, degree_member) =
9101 build_record_newtype_composite_catalog_for_tests(
9102 "tests::TargetedProfile".to_string(),
9103 "degree".to_string(),
9104 "tests::TargetedDegree".to_string(),
9105 AcceptedFieldKind::Nat64,
9106 &enum_catalog,
9107 )
9108 .expect("targeted mutation composites should close");
9109 let profile_kind = AcceptedFieldKind::Composite {
9110 type_id: profile_type,
9111 };
9112 let profile_default = encoded_value(
9113 &enum_catalog,
9114 &composite_catalog,
9115 "profile",
9116 &profile_kind,
9117 FieldStorageDecode::CatalogValue,
9118 LeafCodec::Structural,
9119 profile_input(12),
9120 );
9121 let fields = vec![
9122 PersistedFieldSnapshot::new_initial(
9123 FieldId::new(1),
9124 "id".to_string(),
9125 SchemaFieldSlot::new(0),
9126 AcceptedFieldKind::Nat64,
9127 Vec::new(),
9128 false,
9129 SchemaInsertDefault::None,
9130 FieldStorageDecode::ByKind,
9131 LeafCodec::Scalar(ScalarCodec::Nat64),
9132 ),
9133 PersistedFieldSnapshot::new_initial(
9134 FieldId::new(2),
9135 "profile".to_string(),
9136 SchemaFieldSlot::new(1),
9137 profile_kind,
9138 vec![PersistedNestedLeafSnapshot::new(
9139 vec!["degree".to_string()],
9140 AcceptedFieldKind::Composite {
9141 type_id: degree_type,
9142 },
9143 false,
9144 )],
9145 false,
9146 SchemaInsertDefault::SlotPayload(profile_default),
9147 FieldStorageDecode::CatalogValue,
9148 LeafCodec::Structural,
9149 ),
9150 PersistedFieldSnapshot::new_initial_with_write_policy(
9151 FieldId::new(3),
9152 "updated_at".to_string(),
9153 SchemaFieldSlot::new(2),
9154 AcceptedFieldKind::Timestamp,
9155 Vec::new(),
9156 false,
9157 SchemaInsertDefault::None,
9158 SchemaFieldWritePolicy::from_model_policies(
9159 None,
9160 Some(FieldWriteManagement::UpdatedAt),
9161 ),
9162 FieldStorageDecode::ByKind,
9163 LeafCodec::Scalar(ScalarCodec::Timestamp),
9164 ),
9165 ];
9166 let mut snapshot = PersistedSchemaSnapshot::new(
9167 SchemaVersion::initial(),
9168 ENTITY_SOURCE.to_string(),
9169 "TargetedMutation".to_string(),
9170 FieldId::new(1),
9171 SchemaRowLayout::initial(
9172 fields
9173 .iter()
9174 .map(|field| (field.id(), field.slot()))
9175 .collect(),
9176 ),
9177 fields,
9178 );
9179 let constraint_catalog = snapshot
9180 .constraint_catalog()
9181 .clone()
9182 .with_added_targeted_rule(
9183 "profile_degree_multiple".to_string(),
9184 ConstraintOrigin::Generated,
9185 AcceptedRuleTarget::new(
9186 FieldId::new(2),
9187 AcceptedNamedTypeIdentity::Composite(degree_type),
9188 ),
9189 AcceptedRuleOperation::MultipleOf {
9190 divisor: nat64_literal(&enum_catalog, &composite_catalog, 5),
9191 },
9192 )
9193 .expect("targeted mutation rule should allocate");
9194 let targeted_rule_id = constraint_catalog
9195 .constraints()
9196 .last()
9197 .expect("targeted mutation rule should persist")
9198 .id();
9199 snapshot = snapshot.with_constraint_catalog(constraint_catalog);
9200
9201 let entity_source = source(ENTITY_SOURCE, EntitySourceKey::try_new);
9202 let id_source = source(ID_SOURCE, FieldSourceKey::try_new);
9203 let profile_source = source(PROFILE_SOURCE, FieldSourceKey::try_new);
9204 let updated_at_source = source(UPDATED_AT_SOURCE, FieldSourceKey::try_new);
9205 let profile_type_source = source(PROFILE_TYPE_SOURCE, TypeSourceKey::try_new);
9206 let degree_type_source = source(DEGREE_TYPE_SOURCE, TypeSourceKey::try_new);
9207 let degree_member_source = source(DEGREE_MEMBER_SOURCE, FieldSourceKey::try_new);
9208 let degree_rule_source = source(DEGREE_RULE_SOURCE, ConstraintSourceKey::try_new);
9209 let source_bindings = AcceptedSourceBindingCatalog::initial_for_tests(
9210 BTreeMap::from([(entity_source, entity_tag)]),
9211 BTreeMap::from([
9212 ((entity_tag, id_source), FieldId::new(1)),
9213 ((entity_tag, profile_source), FieldId::new(2)),
9214 ((entity_tag, updated_at_source), FieldId::new(3)),
9215 ]),
9216 BTreeMap::from([((entity_tag, degree_rule_source), targeted_rule_id)]),
9217 BTreeMap::new(),
9218 BTreeMap::new(),
9219 )
9220 .with_initial_named_types_for_tests(
9221 BTreeMap::from([
9222 (
9223 profile_type_source,
9224 AcceptedNamedTypeIdentity::Composite(profile_type),
9225 ),
9226 (
9227 degree_type_source,
9228 AcceptedNamedTypeIdentity::Composite(degree_type),
9229 ),
9230 ]),
9231 BTreeMap::new(),
9232 BTreeMap::from([((profile_type, degree_member_source), degree_member)]),
9233 );
9234 let candidate = accepted_schema_candidate_with_catalogs_for_tests(
9235 STORE_PATH,
9236 AcceptedSchemaRevision::INITIAL,
9237 enum_catalog,
9238 composite_catalog,
9239 source_bindings,
9240 BTreeMap::from([(entity_tag, snapshot)]),
9241 );
9242
9243 let session = DbSession::<TestCanister>::new(
9244 &STORE_REGISTRY,
9245 &crate::db::RequestExecutionRoot::__new_runtime_root(),
9246 );
9247 session
9248 .db
9249 .drive_startup_recovery_page()
9250 .expect("targeted mutation test database should initialize");
9251 let store = session
9252 .db
9253 .store_handle(STORE_PATH)
9254 .expect("targeted mutation test store should resolve");
9255 crate::db::commit::publish_accepted_schema_candidate(
9256 STORE_PATH,
9257 store,
9258 AcceptedSchemaRevision::NONE,
9259 &candidate,
9260 )
9261 .expect("targeted mutation candidate should publish");
9262
9263 let dynamic_error = session
9264 .execute_trusted_dynamic_mutation(&DynamicMutation::Insert {
9265 entity: "TargetedMutation".to_string(),
9266 patch: structural_patch(1, 12),
9267 })
9268 .expect_err("dynamic write must enforce the targeted rule");
9269 assert_eq!(
9270 targeted_constraint_id(&dynamic_error),
9271 targeted_rule_id.get()
9272 );
9273
9274 let binding = session
9275 .issue_typed_entity_binding(&TYPED_DESCRIPTOR)
9276 .expect("targeted typed binding should issue");
9277 let typed_patch = binding
9278 .bind_write_ordinals(vec![
9279 (0, DynamicWriteCell::Value(InputValue::nat64(2))),
9280 (1, DynamicWriteCell::Value(profile_input(12))),
9281 ])
9282 .expect("targeted typed patch should bind");
9283 let typed_error = session
9284 .execute_trusted_typed_mutation(
9285 &binding,
9286 DynamicTypedMutation::Insert { patch: typed_patch },
9287 )
9288 .expect_err("typed write must enforce the targeted rule");
9289 assert_eq!(targeted_constraint_id(&typed_error), targeted_rule_id.get());
9290
9291 #[cfg(feature = "sql")]
9292 {
9293 let sql_error = session
9294 .execute_trusted_sql_mutation("INSERT INTO TargetedMutation (id) VALUES (3)")
9295 .expect_err("SQL default resolution must enforce the targeted rule");
9296 let crate::db::QueryError::Execute(execute) = sql_error else {
9297 panic!("targeted SQL write should fail at shared execution admission");
9298 };
9299 assert_eq!(
9300 targeted_constraint_id(execute.as_internal()),
9301 targeted_rule_id.get()
9302 );
9303 }
9304
9305 session
9306 .execute_trusted_dynamic_mutation_batch(vec![
9307 DynamicMutation::Insert {
9308 entity: "TargetedMutation".to_string(),
9309 patch: structural_patch(4, 5),
9310 },
9311 DynamicMutation::Insert {
9312 entity: "TargetedMutation".to_string(),
9313 patch: structural_patch(5, 12),
9314 },
9315 ])
9316 .expect_err("one invalid targeted value must reject the whole batch");
9317 assert_eq!(
9318 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
9319 Some(0),
9320 "no frontend or earlier valid batch row may escape targeted admission",
9321 );
9322
9323 let admitted = session
9324 .execute_trusted_dynamic_mutation_batch(vec![
9325 DynamicMutation::Insert {
9326 entity: "TargetedMutation".to_string(),
9327 patch: structural_patch(6, 5),
9328 },
9329 DynamicMutation::Insert {
9330 entity: "TargetedMutation".to_string(),
9331 patch: structural_patch(7, 10),
9332 },
9333 ])
9334 .expect("compliant targeted values should share one accepted batch");
9335 let admitted_rows = admitted
9336 .iter()
9337 .flat_map(|result| result.rows.iter())
9338 .collect::<Vec<_>>();
9339 let [first, second] = admitted_rows.as_slice() else {
9340 panic!("the mixed targeted batch should return two rows");
9341 };
9342 let first_timestamp = first
9343 .get(2)
9344 .expect("the first mixed row should contain its managed timestamp");
9345 assert!(matches!(
9346 first_timestamp.as_public(),
9347 crate::value::PublicValue::Timestamp(_)
9348 ));
9349 assert_eq!(
9350 second.get(2),
9351 Some(first_timestamp),
9352 "one accepted mixed batch must materialize one managed timestamp",
9353 );
9354 assert_eq!(
9355 DATA_STORE.with(|store| store.borrow().exact_entity_count(entity_tag)),
9356 Some(2),
9357 );
9358 }
9359}