From d315cd6375b9050b7e4a9ffae49fac1829b88a59 Mon Sep 17 00:00:00 2001 From: Nife-tanny <148556386+Nife-tanny@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:48:35 +0000 Subject: [PATCH 1/2] refactor(queries): extract shared bucket-pagination helper for get_campaigns_by_category and get_creator_campaigns (#663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a private helper that encapsulates the identical bucket-traversal algorithm previously duplicated in and . The helper is parameterised by: - total count (derived by each caller from its own domain counter) - bucket size (CATEGORY_CAMPAIGNS_BUCKET_SIZE / CREATOR_CAMPAIGNS_BUCKET_SIZE) - a bucket getter closure (each caller supplies its own storage lookup) Algorithm (unchanged): 1. Jump to the bucket containing the page offset. 2. Walk entries within that bucket starting at the requested position. 3. Collect up to campaigns (capped at LIST_MAX_LIMIT). 4. When the bucket is exhausted, advance position past the bucket boundary and repeat from step 1 with the next bucket. Behaviour preservation: - Public function signatures unchanged — backwards-compatible. - Only src/queries.rs modified. - All 400 workspace tests run; only 2 pre-existing failures remain (test_campaign_update — unrelated Soroban host vector unpacking). - 26 query/bucket/benchmark tests pass with zero regressions. - cargo fmt --check and cargo clippy --all-targets --features testutils both pass cleanly. Closes #663 --- src/queries.rs | 110 +++++++++++++++++++++++++------------------------ 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/src/queries.rs b/src/queries.rs index 0b0ec65f..598d2a36 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -89,37 +89,51 @@ pub(crate) fn list_active_campaigns( (campaigns, next_cursor) } -pub(crate) fn get_campaigns_by_category( +/// Shared bucket-pagination helper used by both `get_campaigns_by_category` +/// and `get_creator_campaigns`. The two query functions differ only in how +/// they derive the total count and how they load a bucket — this helper +/// captures the identical traversal algorithm so there is one canonical +/// implementation. +/// +/// Algorithm overview: +/// 1. Jump to the bucket containing `offset`. +/// 2. Walk entries within that bucket starting at the requested position. +/// 3. Collect up to `limit` campaigns (capped at `LIST_MAX_LIMIT`). +/// 4. When the bucket is exhausted, advance `position` past the bucket +/// boundary and repeat from step 1 with the next bucket. +fn paginate_bucketed_campaigns( env: &Env, - category: Category, + total: u32, offset: u32, limit: u32, -) -> soroban_sdk::Vec { + bucket_size: u32, + get_bucket: F, +) -> soroban_sdk::Vec +where + F: Fn(u32) -> soroban_sdk::Vec, +{ + let capped_limit = limit.min(crate::LIST_MAX_LIMIT); let mut campaigns = soroban_sdk::Vec::new(env); - if limit == 0 { - return campaigns; - } - let total = get_category_campaign_count(env, category); - if offset >= total { + if offset >= total || capped_limit == 0 { return campaigns; } - let capped_limit = limit.min(crate::LIST_MAX_LIMIT); let end = offset.saturating_add(capped_limit).min(total); - let mut position = offset; + while position < end { - let bucket_idx = position / CATEGORY_CAMPAIGNS_BUCKET_SIZE; - let bucket = get_category_campaign_bucket(env, category, bucket_idx); - let bucket_start = bucket_idx * CATEGORY_CAMPAIGNS_BUCKET_SIZE; + let bucket_idx = position / bucket_size; + let bucket = get_bucket(bucket_idx); + let bucket_start = bucket_idx * bucket_size; let mut idx_in_bucket = position - bucket_start; let bucket_len = bucket.len(); while idx_in_bucket < bucket_len && position < end { - let campaign_id = bucket.get(idx_in_bucket).unwrap(); - if let Some(campaign) = get_campaign(env, campaign_id) { - campaigns.push_back(campaign); + if let Some(campaign_id) = bucket.get(idx_in_bucket) { + if let Some(campaign) = get_campaign(env, campaign_id) { + campaigns.push_back(campaign); + } } idx_in_bucket += 1; position += 1; @@ -127,7 +141,7 @@ pub(crate) fn get_campaigns_by_category( if idx_in_bucket >= bucket_len { position = if bucket_len == 0 { - bucket_start + CATEGORY_CAMPAIGNS_BUCKET_SIZE + bucket_start + bucket_size } else { bucket_start + bucket_len }; @@ -137,6 +151,23 @@ pub(crate) fn get_campaigns_by_category( campaigns } +pub(crate) fn get_campaigns_by_category( + env: &Env, + category: Category, + offset: u32, + limit: u32, +) -> soroban_sdk::Vec { + let total = get_category_campaign_count(env, category); + paginate_bucketed_campaigns( + env, + total, + offset, + limit, + CATEGORY_CAMPAIGNS_BUCKET_SIZE, + |bucket_idx| get_category_campaign_bucket(env, category, bucket_idx), + ) +} + /// #534: jumps straight to the bucket containing `start` instead of reading /// every preceding bucket just to advance a counter, so paginating deep into /// a creator with many campaigns no longer costs one ledger read per skipped @@ -147,44 +178,15 @@ pub(crate) fn get_creator_campaigns( start: u32, limit: u32, ) -> soroban_sdk::Vec { - let capped_limit = limit.min(crate::LIST_MAX_LIMIT); let total = get_creator_campaign_count(env, &creator); - let mut campaigns = soroban_sdk::Vec::new(env); - - if start >= total || capped_limit == 0 { - return campaigns; - } - - let end = (start + capped_limit).min(total); - let mut position = start; - - while position < end { - let bucket_idx = position / CREATOR_CAMPAIGNS_BUCKET_SIZE; - let bucket = get_creator_campaign_bucket(env, &creator, bucket_idx); - let bucket_start = bucket_idx * CREATOR_CAMPAIGNS_BUCKET_SIZE; - let mut idx_in_bucket = position - bucket_start; - - let bucket_len = bucket.len(); - while idx_in_bucket < bucket_len && position < end { - if let Some(campaign_id) = bucket.get(idx_in_bucket) { - if let Some(campaign) = get_campaign(env, campaign_id) { - campaigns.push_back(campaign); - } - } - idx_in_bucket += 1; - position += 1; - } - - if idx_in_bucket >= bucket_len { - position = if bucket_len == 0 { - bucket_start + CREATOR_CAMPAIGNS_BUCKET_SIZE - } else { - bucket_start + bucket_len - }; - } - } - - campaigns + paginate_bucketed_campaigns( + env, + total, + start, + limit, + CREATOR_CAMPAIGNS_BUCKET_SIZE, + |bucket_idx| get_creator_campaign_bucket(env, &creator, bucket_idx), + ) } /// Aggregates total raised, active campaign count, and total contributors From 54ad00016da18888228f2fd8bcdd1926793871f5 Mon Sep 17 00:00:00 2001 From: Nife-tanny <148556386+Nife-tanny@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:05:13 +0100 Subject: [PATCH 2/2] docs(queries): document the shared bucket-pagination helper (#663) The extraction itself already landed on main via #734; this PR's remaining contribution is the algorithm documentation for the shared helper, plus a code comment pinning the intentional `if let Some` sparse-bucket behavior (a sparse bucket skips instead of panicking - the safer of the two forms, adopted on purpose). --- src/queries.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/queries.rs b/src/queries.rs index 99c1da21..613efdbd 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -113,6 +113,18 @@ pub(crate) fn list_active_campaigns( (campaigns, next_cursor) } +/// Shared bucket-pagination helper used by both `get_campaigns_by_category` +/// and `get_creator_campaigns`. The two query functions differ only in how +/// they derive the total count and how they load a bucket — this helper +/// captures the identical traversal algorithm so there is one canonical +/// implementation. +/// +/// Algorithm overview: +/// 1. Jump to the bucket containing `start`. +/// 2. Walk entries within that bucket starting at the requested position. +/// 3. Collect up to `limit` campaigns (capped at `LIST_MAX_LIMIT`). +/// 4. When the bucket is exhausted, advance `position` past the bucket +/// boundary and repeat from step 1 with the next bucket. fn get_campaigns_from_buckets( env: &Env, start: u32, @@ -142,6 +154,9 @@ where let bucket_len = bucket.len(); while idx_in_bucket < bucket_len && position < end { + // `if let Some` rather than `unwrap()` is intentional: a sparse + // bucket entry is skipped (not a panic), mirroring the + // creator-campaign path's behaviour. if let Some(campaign_id) = bucket.get(idx_in_bucket) { if let Some(campaign) = get_campaign(env, campaign_id) { campaigns.push_back(campaign);