From 84a5069b39162a6d19ed79efde1f5cfeeb819481 Mon Sep 17 00:00:00 2001 From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:02:42 -0400 Subject: [PATCH] perf: index core commitments by creation bucket --- ...tment_core_created_between_bucket_index.md | 31 ++++ contracts/commitment_core/src/lib.rs | 66 +++++++- contracts/commitment_core/src/tests.rs | 151 +++++++++++++++++- 3 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 benchmarks/results/commitment_core_created_between_bucket_index.md diff --git a/benchmarks/results/commitment_core_created_between_bucket_index.md b/benchmarks/results/commitment_core_created_between_bucket_index.md new file mode 100644 index 00000000..48b15a01 --- /dev/null +++ b/benchmarks/results/commitment_core_created_between_bucket_index.md @@ -0,0 +1,31 @@ +# commitment_core created_between bucket index + +## Scope + +`commitment_core::get_commitments_created_between(from_ts, to_ts)` previously loaded +`DataKey::AllCommitmentIds` and read every commitment before filtering by `created_at`. + +This change adds a UTC-day creation index: + +- `DataKey::CommitmentCreatedBucketDays` +- `DataKey::CommitmentsCreatedInBucket(day)` + +`create_commitment` appends each new commitment ID to its creation-day bucket, and +`get_commitments_created_between` reads only bucket IDs for non-empty days whose bucket +falls inside the requested timestamp range. + +## Cost shape + +Before: + +- Storage reads: `AllCommitmentIds` plus one commitment read for every commitment ever created. +- Query cost: `O(total_commitments)`. + +After: + +- Storage reads: `CommitmentCreatedBucketDays`, relevant bucket vectors, and commitment records + inside those relevant buckets. +- Query cost: `O(non_empty_bucket_days + commitments_in_matching_buckets)`. + +The new tests cover empty ranges, reversed ranges, single-bucket boundaries, multi-bucket +queries, and equivalence with the old full-scan filter order. diff --git a/contracts/commitment_core/src/lib.rs b/contracts/commitment_core/src/lib.rs index 0269de6b..4a09ef8d 100644 --- a/contracts/commitment_core/src/lib.rs +++ b/contracts/commitment_core/src/lib.rs @@ -31,6 +31,8 @@ pub mod fuzzing; /// Maximum page size for paginated owner-commitment queries. const MAX_PAGE_SIZE: u32 = 50; +/// Commitment creation timestamps are indexed into UTC-day buckets. +const COMMITMENT_CREATED_BUCKET_SECONDS: u64 = 86_400; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -183,6 +185,10 @@ pub enum DataKey { AuthorizedOperator(Address), /// All commitment IDs for time-range queries (analytics). Appended on create. AllCommitmentIds, + /// Non-empty creation-day buckets. Used to avoid scanning every commitment for range queries. + CommitmentCreatedBucketDays, + /// Commitment IDs created during a specific UTC-day bucket. + CommitmentsCreatedInBucket(u64), /// Fee recipient (protocol treasury) for fee withdrawals FeeRecipient, /// Creation fee rate in basis points (0-10000) @@ -220,6 +226,35 @@ fn transfer_assets(e: &Env, from: &Address, to: &Address, asset_address: &Addres token_client.transfer(from, to, &amount); } +fn created_bucket_day(created_at: u64) -> u64 { + created_at / COMMITMENT_CREATED_BUCKET_SECONDS +} + +fn add_commitment_to_created_bucket(e: &Env, commitment: &Commitment) { + let bucket_day = created_bucket_day(commitment.created_at); + let bucket_key = DataKey::CommitmentsCreatedInBucket(bucket_day); + let mut bucket_ids = e + .storage() + .instance() + .get::<_, Vec>(&bucket_key) + .unwrap_or(Vec::new(e)); + + if bucket_ids.is_empty() { + let mut bucket_days = e + .storage() + .instance() + .get::<_, Vec>(&DataKey::CommitmentCreatedBucketDays) + .unwrap_or(Vec::new(e)); + bucket_days.push_back(bucket_day); + e.storage() + .instance() + .set(&DataKey::CommitmentCreatedBucketDays, &bucket_days); + } + + bucket_ids.push_back(commitment.commitment_id.clone()); + e.storage().instance().set(&bucket_key, &bucket_ids); +} + /// Helper function to call NFT contract mint function. fn call_nft_mint( e: &Env, @@ -596,6 +631,7 @@ impl CommitmentCoreContract { e.storage() .instance() .set(&DataKey::AllCommitmentIds, &all_ids); + add_commitment_to_created_bucket(&e, &commitment); let contract_address = e.current_contract_address(); transfer_assets(&e, &owner, &contract_address, &asset_address, amount); @@ -712,16 +748,34 @@ impl CommitmentCoreContract { /// Get commitment IDs created between two timestamps (inclusive). pub fn get_commitments_created_between(e: Env, from_ts: u64, to_ts: u64) -> Vec { - let all_ids = e + if from_ts > to_ts { + return Vec::new(&e); + } + + let from_bucket = created_bucket_day(from_ts); + let to_bucket = created_bucket_day(to_ts); + let bucket_days = e .storage() .instance() - .get::<_, Vec>(&DataKey::AllCommitmentIds) + .get::<_, Vec>(&DataKey::CommitmentCreatedBucketDays) .unwrap_or(Vec::new(&e)); let mut out = Vec::new(&e); - for id in all_ids.iter() { - if let Some(c) = read_commitment(&e, &id) { - if c.created_at >= from_ts && c.created_at <= to_ts { - out.push_back(id.clone()); + + for bucket_day in bucket_days.iter() { + if bucket_day < from_bucket || bucket_day > to_bucket { + continue; + } + + let bucket_ids = e + .storage() + .instance() + .get::<_, Vec>(&DataKey::CommitmentsCreatedInBucket(bucket_day)) + .unwrap_or(Vec::new(&e)); + for id in bucket_ids.iter() { + if let Some(c) = read_commitment(&e, &id) { + if c.created_at >= from_ts && c.created_at <= to_ts { + out.push_back(id.clone()); + } } } } diff --git a/contracts/commitment_core/src/tests.rs b/contracts/commitment_core/src/tests.rs index 54e849bc..35d450f8 100644 --- a/contracts/commitment_core/src/tests.rs +++ b/contracts/commitment_core/src/tests.rs @@ -355,6 +355,48 @@ fn store_commitment(e: &Env, contract_id: &Address, commitment: &Commitment) { }); } +fn store_commitment_with_created_indexes(e: &Env, contract_id: &Address, commitment: &Commitment) { + e.as_contract(contract_id, || { + set_commitment(e, commitment); + + let mut all_ids = e + .storage() + .instance() + .get::<_, Vec>(&DataKey::AllCommitmentIds) + .unwrap_or(Vec::new(e)); + all_ids.push_back(commitment.commitment_id.clone()); + e.storage() + .instance() + .set(&DataKey::AllCommitmentIds, &all_ids); + + add_commitment_to_created_bucket(e, commitment); + }); +} + +fn full_scan_commitments_created_between( + e: &Env, + contract_id: &Address, + from_ts: u64, + to_ts: u64, +) -> Vec { + e.as_contract(contract_id, || { + let all_ids = e + .storage() + .instance() + .get::<_, Vec>(&DataKey::AllCommitmentIds) + .unwrap_or(Vec::new(e)); + let mut out = Vec::new(e); + for id in all_ids.iter() { + if let Some(c) = read_commitment(e, &id) { + if c.created_at >= from_ts && c.created_at <= to_ts { + out.push_back(id.clone()); + } + } + } + out + }) +} + #[test] fn test_create_commitment_passes_core_contract_as_nft_caller() { let e = Env::default(); @@ -1198,8 +1240,21 @@ fn test_create_commitment_updates_storage_layout() { .get::<_, Vec>(&DataKey::AllCommitmentIds) .unwrap() }); + let bucket_day = created_bucket_day(e.ledger().timestamp()); + let bucket_days = e.as_contract(&contract_id, || { + e.storage() + .instance() + .get::<_, Vec>(&DataKey::CommitmentCreatedBucketDays) + .unwrap() + }); + let bucket_ids = e.as_contract(&contract_id, || { + e.storage() + .instance() + .get::<_, Vec>(&DataKey::CommitmentsCreatedInBucket(bucket_day)) + .unwrap() + }); - assert_eq!(created_id, String::from_str(&e, "c_0")); + assert_eq!(created_id, String::from_str(&e, "COMMIT_0")); assert_eq!(commitment.commitment_id, created_id); assert_eq!(commitment.owner, owner); assert_eq!(commitment.asset_address, asset_address.clone()); @@ -1216,11 +1271,105 @@ fn test_create_commitment_updates_storage_layout() { assert_eq!(total_commitments, 1); assert_eq!(total_value_locked, amount); assert_eq!(all_ids, vec![&e, created_id.clone()]); + assert_eq!(bucket_days, vec![&e, bucket_day]); + assert_eq!(bucket_ids, vec![&e, created_id.clone()]); assert_eq!(client.get_collected_fees(&asset_address), 0); assert_eq!(token_client.balance(&owner), amount); assert_eq!(token_client.balance(&contract_id), amount); } +#[test] +fn test_get_commitments_created_between_uses_empty_bucket_index() { + let e = Env::default(); + let contract_id = e.register_contract(None, CommitmentCoreContract); + let client = CommitmentCoreContractClient::new(&e, &contract_id); + + assert_eq!(client.get_commitments_created_between(&0, &u64::MAX).len(), 0); + assert_eq!(client.get_commitments_created_between(&100, &99).len(), 0); +} + +#[test] +fn test_get_commitments_created_between_reads_single_bucket_boundaries() { + let e = Env::default(); + let contract_id = e.register_contract(None, CommitmentCoreContract); + let owner = Address::generate(&e); + let client = CommitmentCoreContractClient::new(&e, &contract_id); + + let start = 2 * COMMITMENT_CREATED_BUCKET_SECONDS; + let first = create_test_commitment(&e, "first", &owner, 1000, 1000, 10, 30, start); + let middle = create_test_commitment(&e, "middle", &owner, 1000, 1000, 10, 30, start + 10); + let last = create_test_commitment( + &e, + "last", + &owner, + 1000, + 1000, + 10, + 30, + start + COMMITMENT_CREATED_BUCKET_SECONDS - 1, + ); + store_commitment_with_created_indexes(&e, &contract_id, &first); + store_commitment_with_created_indexes(&e, &contract_id, &middle); + store_commitment_with_created_indexes(&e, &contract_id, &last); + + assert_eq!( + client.get_commitments_created_between( + &start, + &(start + COMMITMENT_CREATED_BUCKET_SECONDS - 1) + ), + vec![&e, first.commitment_id, middle.commitment_id, last.commitment_id] + ); +} + +#[test] +fn test_get_commitments_created_between_reads_multiple_relevant_buckets() { + let e = Env::default(); + let contract_id = e.register_contract(None, CommitmentCoreContract); + let owner = Address::generate(&e); + let client = CommitmentCoreContractClient::new(&e, &contract_id); + + let day = COMMITMENT_CREATED_BUCKET_SECONDS; + let before = create_test_commitment(&e, "before", &owner, 1000, 1000, 10, 30, day - 1); + let day_one = create_test_commitment(&e, "day_one", &owner, 1000, 1000, 10, 30, day); + let day_two = create_test_commitment(&e, "day_two", &owner, 1000, 1000, 10, 30, day * 2 + 5); + let after = create_test_commitment(&e, "after", &owner, 1000, 1000, 10, 30, day * 3); + store_commitment_with_created_indexes(&e, &contract_id, &before); + store_commitment_with_created_indexes(&e, &contract_id, &day_one); + store_commitment_with_created_indexes(&e, &contract_id, &day_two); + store_commitment_with_created_indexes(&e, &contract_id, &after); + + assert_eq!( + client.get_commitments_created_between(&day, &(day * 2 + 5)), + vec![&e, day_one.commitment_id, day_two.commitment_id] + ); +} + +#[test] +fn test_get_commitments_created_between_matches_full_scan_order() { + let e = Env::default(); + let contract_id = e.register_contract(None, CommitmentCoreContract); + let owner = Address::generate(&e); + let client = CommitmentCoreContractClient::new(&e, &contract_id); + + for (id, created_at) in [ + ("c0", 10), + ("c1", COMMITMENT_CREATED_BUCKET_SECONDS + 1), + ("c2", COMMITMENT_CREATED_BUCKET_SECONDS * 2), + ("c3", COMMITMENT_CREATED_BUCKET_SECONDS * 2 + 50), + ("c4", COMMITMENT_CREATED_BUCKET_SECONDS * 4), + ] { + let commitment = create_test_commitment(&e, id, &owner, 1000, 1000, 10, 30, created_at); + store_commitment_with_created_indexes(&e, &contract_id, &commitment); + } + + let from_ts = COMMITMENT_CREATED_BUCKET_SECONDS; + let to_ts = COMMITMENT_CREATED_BUCKET_SECONDS * 3; + let expected = full_scan_commitments_created_between(&e, &contract_id, from_ts, to_ts); + let indexed = client.get_commitments_created_between(&from_ts, &to_ts); + + assert_eq!(indexed, expected); +} + #[test] fn test_create_commitment_validation_failures_do_not_mutate_storage() { let e = Env::default();