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