1use std::path::Path;
6
7use iota_sdk_types::{TransactionEffects, TransactionEvents, Version};
8use iota_types::{global_state_hash::GlobalStateHash, storage::MarkerValue};
9use serde::{Deserialize, Serialize};
10use tracing::error;
11use typed_store::{
12 DBMapUtils, DbIterator,
13 metrics::SamplingInterval,
14 rocks::{
15 DBBatch, DBMap, DBMapTableConfigMap, DBOptions, MetricConf, default_db_options,
16 read_size_from_env,
17 },
18 rocksdb::compaction_filter::Decision,
19 traits::Map,
20};
21
22use super::*;
23use crate::authority::{
24 authority_store_pruner::ObjectsCompactionFilter,
25 authority_store_types::{
26 StoreObject, StoreObjectValueV2, StoreObjectWrapper, get_store_object, try_construct_object,
27 },
28 epoch_start_configuration::EpochStartConfiguration,
29};
30
31const ENV_VAR_OBJECTS_BLOCK_CACHE_SIZE: &str = "OBJECTS_BLOCK_CACHE_MB";
32pub(crate) const ENV_VAR_LOCKS_BLOCK_CACHE_SIZE: &str = "LOCKS_BLOCK_CACHE_MB";
33const ENV_VAR_TRANSACTIONS_BLOCK_CACHE_SIZE: &str = "TRANSACTIONS_BLOCK_CACHE_MB";
34const ENV_VAR_EFFECTS_BLOCK_CACHE_SIZE: &str = "EFFECTS_BLOCK_CACHE_MB";
35
36#[derive(Default)]
38pub struct AuthorityPerpetualTablesOptions {
39 pub enable_write_stall: bool,
41 pub compaction_filter: Option<ObjectsCompactionFilter>,
42}
43
44impl AuthorityPerpetualTablesOptions {
45 fn apply_to(&self, mut db_options: DBOptions) -> DBOptions {
46 if !self.enable_write_stall {
47 db_options = db_options.disable_write_throttling();
48 }
49 db_options
50 }
51}
52
53#[derive(DBMapUtils)]
56pub struct AuthorityPerpetualTables {
57 pub(crate) objects: DBMap<ObjectKey, StoreObjectWrapper>,
72
73 pub(crate) live_owned_object_markers: DBMap<ObjectReference, ()>,
75
76 pub(crate) transactions: DBMap<TransactionDigest, TrustedTransaction>,
81
82 pub(crate) effects: DBMap<TransactionEffectsDigest, TransactionEffects>,
95
96 pub(crate) executed_effects: DBMap<TransactionDigest, TransactionEffectsDigest>,
102
103 pub(crate) events_2: DBMap<TransactionDigest, TransactionEvents>,
105
106 pub(crate) executed_transactions_to_checkpoint:
111 DBMap<TransactionDigest, (EpochId, CheckpointSequenceNumber)>,
112
113 pub(crate) root_state_hash_by_epoch:
117 DBMap<EpochId, (CheckpointSequenceNumber, GlobalStateHash)>,
118
119 pub(crate) epoch_start_configuration: DBMap<(), EpochStartConfiguration>,
121
122 pub(crate) pruned_checkpoint: DBMap<(), CheckpointSequenceNumber>,
125
126 pub(crate) total_iota_supply: DBMap<(), TotalIotaSupplyCheck>,
130
131 pub(crate) expected_storage_fund_imbalance: DBMap<(), i64>,
136
137 pub(crate) object_per_epoch_marker_table: DBMap<(EpochId, ObjectKey), MarkerValue>,
145}
146
147#[derive(DBMapUtils)]
148pub struct AuthorityPrunerTables {
149 pub(crate) object_tombstones: DBMap<ObjectId, Version>,
150}
151
152impl AuthorityPrunerTables {
153 pub fn path(parent_path: &Path) -> PathBuf {
154 parent_path.join("pruner")
155 }
156
157 pub fn open(parent_path: &Path) -> Self {
158 Self::open_tables_read_write(
159 Self::path(parent_path),
160 MetricConf::new("pruner")
161 .with_sampling(SamplingInterval::new(Duration::from_secs(60), 0)),
162 None,
163 None,
164 )
165 }
166}
167
168#[derive(Debug, Serialize, Deserialize)]
170pub(crate) struct TotalIotaSupplyCheck {
171 pub(crate) total_supply: u64,
173 pub(crate) last_check_epoch: EpochId,
175}
176
177impl AuthorityPerpetualTables {
178 pub fn path(parent_path: &Path) -> PathBuf {
179 parent_path.join("perpetual")
180 }
181
182 pub fn open(
183 parent_path: &Path,
184 db_options_override: Option<AuthorityPerpetualTablesOptions>,
185 ) -> Self {
186 let db_options_override = db_options_override.unwrap_or_default();
187 let db_options =
188 db_options_override.apply_to(default_db_options().optimize_db_for_write_throughput(4));
189 let table_options = DBMapTableConfigMap::new(BTreeMap::from([
190 (
191 "objects".to_string(),
192 objects_table_config(db_options.clone(), db_options_override.compaction_filter),
193 ),
194 (
195 "live_owned_object_markers".to_string(),
196 live_owned_object_markers_table_config(db_options.clone()),
197 ),
198 (
199 "transactions".to_string(),
200 transactions_table_config(db_options.clone()),
201 ),
202 (
203 "effects".to_string(),
204 effects_table_config(db_options.clone()),
205 ),
206 ]));
207 Self::open_tables_read_write(
208 Self::path(parent_path),
209 MetricConf::new("perpetual")
210 .with_sampling(SamplingInterval::new(Duration::from_secs(60), 0)),
211 Some(db_options.options),
212 Some(table_options),
213 )
214 }
215
216 pub fn open_readonly(parent_path: &Path) -> AuthorityPerpetualTablesReadOnly {
217 Self::get_read_only_handle(
218 Self::path(parent_path),
219 None,
220 None,
221 MetricConf::new("perpetual_readonly"),
222 )
223 }
224
225 pub fn find_object_lt_or_eq_version(
230 &self,
231 object_id: ObjectId,
232 version: Version,
233 ) -> IotaResult<Option<Object>> {
234 let mut iter = self.objects.safe_range_iter_reversed(
235 ObjectKey::min_for_id(&object_id)..=ObjectKey(object_id, version),
236 );
237 match iter.next() {
238 Some(Ok((key, o))) => self.object(&key, o),
239 Some(Err(e)) => Err(e.into()),
240 None => Ok(None),
241 }
242 }
243
244 fn construct_object(
245 &self,
246 object_key: &ObjectKey,
247 store_object: StoreObjectValueV2,
248 ) -> Result<Object, IotaError> {
249 try_construct_object(object_key, store_object)
250 }
251
252 pub fn object(
255 &self,
256 object_key: &ObjectKey,
257 store_object: StoreObjectWrapper,
258 ) -> Result<Option<Object>, IotaError> {
259 let StoreObject::Value(store_object) = store_object.migrate().into_inner() else {
260 return Ok(None);
261 };
262 Ok(Some(self.construct_object(object_key, *store_object)?))
263 }
264
265 pub fn object_reference(
266 &self,
267 object_key: &ObjectKey,
268 store_object: StoreObjectWrapper,
269 ) -> Result<ObjectReference, IotaError> {
270 let obj_ref = match store_object.migrate().into_inner() {
271 StoreObject::Value(object) => self.construct_object(object_key, *object)?.object_ref(),
272 StoreObject::Deleted => {
273 ObjectReference::new(object_key.0, object_key.1, ObjectDigest::OBJECT_DELETED)
274 }
275 StoreObject::Wrapped => {
276 ObjectReference::new(object_key.0, object_key.1, ObjectDigest::OBJECT_WRAPPED)
277 }
278 };
279 Ok(obj_ref)
280 }
281
282 pub fn tombstone_reference(
283 &self,
284 object_key: &ObjectKey,
285 store_object: &StoreObjectWrapper,
286 ) -> Result<Option<ObjectReference>, IotaError> {
287 let obj_ref = match store_object.inner() {
288 StoreObject::Deleted => Some(ObjectReference::new(
289 object_key.0,
290 object_key.1,
291 ObjectDigest::OBJECT_DELETED,
292 )),
293 StoreObject::Wrapped => Some(ObjectReference::new(
294 object_key.0,
295 object_key.1,
296 ObjectDigest::OBJECT_WRAPPED,
297 )),
298 _ => None,
299 };
300 Ok(obj_ref)
301 }
302
303 pub fn get_latest_object_ref_or_tombstone(
304 &self,
305 object_id: ObjectId,
306 ) -> Result<Option<ObjectReference>, IotaError> {
307 let mut iterator = self.objects.safe_iter_with_prefix_reversed(&object_id);
308
309 if let Some(Ok((object_key, value))) = iterator.next() {
310 if object_key.0 == object_id {
311 return Ok(Some(self.object_reference(&object_key, value)?));
312 }
313 }
314 Ok(None)
315 }
316
317 pub fn get_latest_object_or_tombstone(
318 &self,
319 object_id: ObjectId,
320 ) -> Result<Option<(ObjectKey, StoreObjectWrapper)>, IotaError> {
321 let mut iterator = self.objects.safe_iter_with_prefix_reversed(&object_id);
322
323 if let Some(Ok((object_key, value))) = iterator.next() {
324 if object_key.0 == object_id {
325 return Ok(Some((object_key, value.migrate())));
328 }
329 }
330 Ok(None)
331 }
332
333 pub fn get_recovery_epoch_at_restart(&self) -> IotaResult<EpochId> {
334 Ok(self
335 .epoch_start_configuration
336 .get(&())?
337 .expect("Must have current epoch.")
338 .epoch_start_state()
339 .epoch())
340 }
341
342 pub fn set_epoch_start_configuration(
343 &self,
344 epoch_start_configuration: &EpochStartConfiguration,
345 ) -> IotaResult {
346 let mut wb = self.epoch_start_configuration.batch();
347 wb.insert_batch(
348 &self.epoch_start_configuration,
349 std::iter::once(((), epoch_start_configuration)),
350 )?;
351 wb.write()?;
352 Ok(())
353 }
354
355 pub fn get_highest_pruned_checkpoint(
356 &self,
357 ) -> Result<Option<CheckpointSequenceNumber>, TypedStoreError> {
358 self.pruned_checkpoint.get(&())
359 }
360
361 pub fn set_highest_pruned_checkpoint(
362 &self,
363 wb: &mut DBBatch,
364 checkpoint_number: CheckpointSequenceNumber,
365 ) -> IotaResult {
366 wb.insert_batch(&self.pruned_checkpoint, [((), checkpoint_number)])?;
367 Ok(())
368 }
369
370 pub fn get_transaction(
371 &self,
372 digest: &TransactionDigest,
373 ) -> IotaResult<Option<TrustedTransaction>> {
374 let Some(transaction) = self.transactions.get(digest)? else {
375 return Ok(None);
376 };
377 Ok(Some(transaction))
378 }
379
380 pub fn get_effects(
381 &self,
382 digest: &TransactionDigest,
383 ) -> IotaResult<Option<TransactionEffects>> {
384 let Some(effect_digest) = self.executed_effects.get(digest)? else {
385 return Ok(None);
386 };
387 Ok(self.effects.get(&effect_digest)?)
388 }
389
390 pub fn get_checkpoint_sequence_number(
391 &self,
392 digest: &TransactionDigest,
393 ) -> IotaResult<Option<(EpochId, CheckpointSequenceNumber)>> {
394 Ok(self.executed_transactions_to_checkpoint.get(digest)?)
395 }
396
397 pub fn get_newer_object_keys(
398 &self,
399 object: &(ObjectId, Version),
400 ) -> IotaResult<Vec<ObjectKey>> {
401 let mut objects = vec![];
402 for result in self
403 .objects
404 .safe_iter_with_prefix_from(&object.0, &object.1.next().unwrap())
405 {
406 let (key, _) = result?;
407 objects.push(key);
408 }
409 Ok(objects)
410 }
411
412 pub fn set_highest_pruned_checkpoint_without_wb(
413 &self,
414 checkpoint_number: CheckpointSequenceNumber,
415 ) -> IotaResult {
416 let mut wb = self.pruned_checkpoint.batch();
417 self.set_highest_pruned_checkpoint(&mut wb, checkpoint_number)?;
418 wb.write()?;
419 Ok(())
420 }
421
422 pub fn database_is_empty(&self) -> IotaResult<bool> {
423 Ok(self.objects.safe_iter().next().is_none())
424 }
425
426 pub fn iter_live_object_set(&self) -> LiveSetIter<'_> {
427 LiveSetIter {
428 iter: Box::new(self.objects.safe_iter()),
429 tables: self,
430 prev: None,
431 }
432 }
433
434 pub fn range_iter_live_object_set(
435 &self,
436 lower_bound: Option<ObjectId>,
437 upper_bound: Option<ObjectId>,
438 ) -> LiveSetIter<'_> {
439 let lower_bound = lower_bound.as_ref().map(ObjectKey::min_for_id);
440 let upper_bound = upper_bound.as_ref().map(ObjectKey::max_for_id);
441
442 LiveSetIter {
443 iter: Box::new(self.objects.safe_iter_with_bounds(lower_bound, upper_bound)),
444 tables: self,
445 prev: None,
446 }
447 }
448
449 pub fn checkpoint_db(&self, path: &Path) -> IotaResult {
450 self.objects.checkpoint_db(path).map_err(Into::into)
452 }
453
454 pub fn get_root_state_hash(
455 &self,
456 epoch: EpochId,
457 ) -> IotaResult<Option<(CheckpointSequenceNumber, GlobalStateHash)>> {
458 Ok(self.root_state_hash_by_epoch.get(&epoch)?)
459 }
460
461 pub fn insert_root_state_hash(
462 &self,
463 epoch: EpochId,
464 last_checkpoint_of_epoch: CheckpointSequenceNumber,
465 hash: GlobalStateHash,
466 ) -> IotaResult {
467 self.root_state_hash_by_epoch
468 .insert(&epoch, &(last_checkpoint_of_epoch, hash))?;
469 Ok(())
470 }
471
472 pub fn insert_store_object_v1_test_only(&self, object: Object) -> IotaResult {
473 use crate::authority::authority_store_types::{StoreObjectV1, StoreObjectValue};
474
475 let object_reference = object.object_ref();
476 let v2_value = match get_store_object(object, None).into_inner() {
477 StoreObject::Value(v) => *v,
478 other => unreachable!("get_store_object must produce a Value variant, got {other:?}"),
479 };
480 let v1_value = StoreObjectValue {
481 data: v2_value.data,
482 owner: v2_value.owner,
483 previous_transaction: v2_value.previous_transaction,
484 storage_rebate: v2_value.storage_rebate,
485 };
486 let wrapper = StoreObjectWrapper::V1(StoreObjectV1::Value(Box::new(v1_value)));
487
488 let mut wb = self.objects.batch();
489 wb.insert_batch(
490 &self.objects,
491 std::iter::once((ObjectKey::from(object_reference), wrapper)),
492 )?;
493 wb.write()?;
494 Ok(())
495 }
496
497 pub fn insert_store_object_v2_test_only(
498 &self,
499 object: Object,
500 previous_transaction_checkpoint: Option<CheckpointSequenceNumber>,
501 ) -> IotaResult {
502 let object_reference = object.object_ref();
503 let wrapper = get_store_object(object, previous_transaction_checkpoint);
504
505 let mut wb = self.objects.batch();
506 wb.insert_batch(
507 &self.objects,
508 std::iter::once((ObjectKey::from(object_reference), wrapper)),
509 )?;
510 wb.write()?;
511 Ok(())
512 }
513}
514
515impl ObjectStore for AuthorityPerpetualTables {
516 fn try_get_object(
518 &self,
519 object_id: &ObjectId,
520 ) -> Result<Option<Object>, iota_types::storage::error::Error> {
521 let obj_entry = self
522 .objects
523 .safe_iter_with_prefix_reversed(object_id)
524 .next();
525
526 match obj_entry.transpose()? {
527 Some((ObjectKey(obj_id, version), obj)) if obj_id == *object_id => Ok(self
528 .object(&ObjectKey(obj_id, version), obj)
529 .map_err(iota_types::storage::error::Error::custom)?),
530 _ => Ok(None),
531 }
532 }
533
534 fn try_get_object_by_key(
535 &self,
536 object_id: &ObjectId,
537 version: VersionNumber,
538 ) -> Result<Option<Object>, iota_types::storage::error::Error> {
539 Ok(self
540 .objects
541 .get(&ObjectKey(*object_id, version))
542 .map_err(iota_types::storage::error::Error::custom)?
543 .map(|object| self.object(&ObjectKey(*object_id, version), object))
544 .transpose()
545 .map_err(iota_types::storage::error::Error::custom)?
546 .flatten())
547 }
548}
549
550#[derive(Eq, PartialEq, Debug, Clone, Hash)]
559pub struct LiveObject {
560 pub object: Object,
561 pub previous_transaction_checkpoint: Option<CheckpointSequenceNumber>,
562}
563
564impl LiveObject {
565 pub fn object_id(&self) -> ObjectId {
566 self.object.id()
567 }
568
569 pub fn version(&self) -> Version {
570 self.object.version()
571 }
572
573 pub fn object_reference(&self) -> ObjectReference {
574 self.object.object_ref()
575 }
576}
577
578#[derive(Deserialize, Serialize)]
582pub struct SnapshotLiveObject {
583 pub object: Object,
584 pub previous_transaction_checkpoint: CheckpointSequenceNumber,
585}
586
587impl From<SnapshotLiveObject> for LiveObject {
588 fn from(snap: SnapshotLiveObject) -> Self {
589 let SnapshotLiveObject {
590 object,
591 previous_transaction_checkpoint,
592 } = snap;
593 LiveObject {
594 object,
595 previous_transaction_checkpoint: Some(previous_transaction_checkpoint),
596 }
597 }
598}
599
600pub struct LiveSetIter<'a> {
601 iter: DbIterator<'a, (ObjectKey, StoreObjectWrapper)>,
602 tables: &'a AuthorityPerpetualTables,
603 prev: Option<(ObjectKey, StoreObjectWrapper)>,
604}
605
606impl LiveSetIter<'_> {
607 fn store_object_wrapper_to_live_object(
608 &self,
609 object_key: ObjectKey,
610 store_object: StoreObjectWrapper,
611 ) -> Option<LiveObject> {
612 match store_object.migrate().into_inner() {
613 StoreObject::Value(value) => {
614 let previous_transaction_checkpoint = value.previous_transaction_checkpoint;
615 let object = self
616 .tables
617 .construct_object(&object_key, *value)
618 .expect("Constructing object from store cannot fail");
619 Some(LiveObject {
620 object,
621 previous_transaction_checkpoint,
622 })
623 }
624 StoreObject::Wrapped | StoreObject::Deleted => None,
625 }
626 }
627}
628
629impl Iterator for LiveSetIter<'_> {
630 type Item = LiveObject;
631
632 fn next(&mut self) -> Option<Self::Item> {
633 loop {
634 if let Some(Ok((next_key, next_value))) = self.iter.next() {
635 let prev = self.prev.take();
636 self.prev = Some((next_key, next_value));
637
638 if let Some((prev_key, prev_value)) = prev {
639 if prev_key.0 != next_key.0 {
640 let live_object =
641 self.store_object_wrapper_to_live_object(prev_key, prev_value);
642 if live_object.is_some() {
643 return live_object;
644 }
645 }
646 }
647 continue;
648 }
649 if let Some((key, value)) = self.prev.take() {
650 let live_object = self.store_object_wrapper_to_live_object(key, value);
651 if live_object.is_some() {
652 return live_object;
653 }
654 }
655 return None;
656 }
657 }
658}
659
660fn live_owned_object_markers_table_config(db_options: DBOptions) -> DBOptions {
662 DBOptions {
663 options: db_options
664 .clone()
665 .optimize_for_write_throughput()
666 .optimize_for_read(read_size_from_env(ENV_VAR_LOCKS_BLOCK_CACHE_SIZE).unwrap_or(1024))
667 .options,
668 rw_options: db_options.rw_options,
669 }
670}
671
672fn objects_table_config(
673 mut db_options: DBOptions,
674 compaction_filter: Option<ObjectsCompactionFilter>,
675) -> DBOptions {
676 if let Some(mut compaction_filter) = compaction_filter {
677 db_options
678 .options
679 .set_compaction_filter("objects", move |_, key, value| {
680 match compaction_filter.filter(key, value) {
681 Ok(decision) => decision,
682 Err(err) => {
683 error!("Compaction error: {:?}", err);
684 Decision::Keep
685 }
686 }
687 });
688 }
689 db_options
690 .optimize_for_write_throughput()
691 .optimize_for_read(read_size_from_env(ENV_VAR_OBJECTS_BLOCK_CACHE_SIZE).unwrap_or(5 * 1024))
692}
693
694fn transactions_table_config(db_options: DBOptions) -> DBOptions {
695 db_options
696 .optimize_for_write_throughput()
697 .optimize_for_point_lookup(
698 read_size_from_env(ENV_VAR_TRANSACTIONS_BLOCK_CACHE_SIZE).unwrap_or(512),
699 )
700}
701
702fn effects_table_config(db_options: DBOptions) -> DBOptions {
703 db_options
704 .optimize_for_write_throughput()
705 .optimize_for_point_lookup(
706 read_size_from_env(ENV_VAR_EFFECTS_BLOCK_CACHE_SIZE).unwrap_or(1024),
707 )
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use crate::authority::authority_store_types::StoreObjectV2;
714
715 #[tokio::test]
720 async fn live_set_iter_filters_wrapped_and_deleted_store_rows() {
721 let tmp_dir = iota_common::tempdir();
722 let perpetual_db = AuthorityPerpetualTables::open(tmp_dir.path(), None);
723
724 let live_id = ObjectId::random();
727 let wrapped_id = ObjectId::random();
728 let deleted_id = ObjectId::random();
729
730 let live_object = Object::immutable_with_id_for_testing(live_id);
731 perpetual_db
732 .insert_store_object_v2_test_only(live_object, None)
733 .unwrap();
734
735 let mut wb = perpetual_db.objects.batch();
736 let wrapped_key = ObjectKey(wrapped_id, Version::from_u64(1));
737 wb.insert_batch(
738 &perpetual_db.objects,
739 std::iter::once::<(ObjectKey, StoreObjectWrapper)>((
740 wrapped_key,
741 StoreObjectV2::Wrapped.into(),
742 )),
743 )
744 .unwrap();
745 let deleted_key = ObjectKey(deleted_id, Version::from_u64(1));
746 wb.insert_batch(
747 &perpetual_db.objects,
748 std::iter::once::<(ObjectKey, StoreObjectWrapper)>((
749 deleted_key,
750 StoreObjectV2::Deleted.into(),
751 )),
752 )
753 .unwrap();
754 wb.write().unwrap();
755
756 let yielded: Vec<_> = perpetual_db.iter_live_object_set().collect();
757 assert_eq!(yielded.len(), 1, "wrapped/deleted rows must be filtered");
758 assert_eq!(yielded[0].object.id(), live_id);
759 }
760
761 #[tokio::test]
770 async fn live_set_iter_propagates_previous_transaction_checkpoint() {
771 let tmp_dir = iota_common::tempdir();
772 let perpetual_db = AuthorityPerpetualTables::open(tmp_dir.path(), None);
773
774 let object = Object::immutable_with_id_for_testing(ObjectId::random());
776 let object_ref = object.object_ref();
777 let object_key = ObjectKey::from(object_ref);
778 let distinct_checkpoint: u64 = 0xCAFE_F00D_BEEF_1234;
779
780 let store_object_value =
781 match get_store_object(object, Some(distinct_checkpoint)).into_inner() {
782 StoreObject::Value(value) => value,
783 other => panic!("expected StoreObject::Value, got {other:?}"),
784 };
785 let wrapper: StoreObjectWrapper = StoreObjectV2::Value(store_object_value).into();
786 let mut wb = perpetual_db.objects.batch();
787 wb.insert_batch(
788 &perpetual_db.objects,
789 std::iter::once((object_key, wrapper)),
790 )
791 .unwrap();
792 wb.write().unwrap();
793
794 let yielded: Vec<_> = perpetual_db.iter_live_object_set().collect();
795 assert_eq!(yielded.len(), 1);
796 assert_eq!(
797 yielded[0].previous_transaction_checkpoint,
798 Some(distinct_checkpoint),
799 "LiveSetIter must surface the on-row checkpoint, not a default"
800 );
801 }
802
803 #[tokio::test]
809 async fn get_latest_object_or_tombstone_migrates_legacy_v1_row() {
810 let tmp_dir = iota_common::tempdir();
811 let perpetual_db = AuthorityPerpetualTables::open(tmp_dir.path(), None);
812
813 let object_id = ObjectId::random();
814 let object = Object::immutable_with_id_for_testing(object_id);
815 let object_ref = object.object_ref();
816 perpetual_db
817 .insert_store_object_v1_test_only(object)
818 .unwrap();
819
820 let (object_key, wrapper) = perpetual_db
821 .get_latest_object_or_tombstone(object_id)
822 .unwrap()
823 .expect("row must be found");
824 assert!(
825 matches!(wrapper, StoreObjectWrapper::V2(_)),
826 "read boundary must migrate the V1 row to V2"
827 );
828
829 assert!(
831 perpetual_db
832 .tombstone_reference(&object_key, &wrapper)
833 .unwrap()
834 .is_none(),
835 "a live value is not a tombstone"
836 );
837 let reconstructed = perpetual_db
838 .object(&object_key, wrapper)
839 .unwrap()
840 .expect("value must reconstruct");
841 assert_eq!(reconstructed.object_ref(), object_ref);
842 }
843}