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