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