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