Skip to content

Conversation

@flemzord
Copy link
Member

Optimize query performance by conditionally removing WHERE ledger = ? clauses when a bucket contains only one ledger. The optimization is automatically detected and applied at runtime, providing 5-15% query performance improvement for single-ledger deployments with no impact on multi-ledger scenarios.

Optimize query performance for single-ledger buckets by conditionally
skipping the WHERE ledger = ? clause when a bucket contains only one
ledger. This reduces unnecessary filtering and can provide 5-15%
performance improvement in single-ledger deployments.

Implementation:
- Add singleLedgerOptimization cache to ledger Store
- Add CountLedgersInBucket to system store
- Detect single-ledger state on CreateLedger and OpenLedger
- Refactor all query builders to use conditional filtering

Changes:
- internal/storage/ledger/store.go: Add cache and helper methods
- internal/storage/system/store.go: Add CountLedgersInBucket
- internal/storage/driver/driver.go: Detect single-ledger state
- internal/storage/ledger/resource_*.go: Apply conditional filtering
- internal/storage/ledger/{accounts,logs,transactions}.go: Apply conditional filtering
@flemzord flemzord requested a review from a team as a code owner October 29, 2025 16:54
@coderabbitai
Copy link

coderabbitai bot commented Oct 29, 2025

Walkthrough

This PR refactors ledger filtering across the storage layer by replacing hard-coded ledger = ? predicates with a centralized applyLedgerFilter helper function. It introduces single-ledger awareness to the Store to optimize queries when a bucket contains exactly one ledger. Context is now propagated through repository operations via a new Ctx field on ResourceQuery, and the system store gains a CountLedgersInBucket method to support bucket-ledger introspection.

Changes

Cohort / File(s) Summary
Ledger filtering refactor
internal/storage/ledger/accounts.go, internal/storage/ledger/logs.go, internal/storage/ledger/transactions.go
Replaced explicit Where("ledger = ?", ...) clauses with staged query construction using applyLedgerFilter() or conditional getLedgerFilterSQL() injection for update/delete operations.
Complex resource query handlers
internal/storage/ledger/resource_accounts.go, internal/storage/ledger/resource_aggregated_balances.go, internal/storage/ledger/resource_volumes.go
Removed direct ledger equality predicates across BuildDataset, ResolveFilter, and Expand methods; now consistently apply applyLedgerFilter() to accounts, moves, accounts_volumes, and accounts_metadata subqueries.
Transaction resource handler
internal/storage/ledger/resource_transactions.go
Centralized ledger filtering via applyLedgerFilter() in BuildDataset and metadata history paths; introduced innerMostQuery pattern in Expand to pre-compute post_commit_effective_volumes with ledger filtering before deriving moves-based data.
Logs resource handler
internal/storage/ledger/resource_logs.go
Updated BuildDataset signature to accept named opts parameter instead of unnamed context; ledger filtering now applied via applyLedgerFilter() helper.
Store-level ledger filtering infrastructure
internal/storage/ledger/store.go
Added isSingleLedger(ctx) check, applyLedgerFilter(), and getLedgerFilterSQL() helpers to conditionally apply ledger filters based on bucket-ledger count; added public option WithCountLedgersInBucketFunc().
Context propagation in repository
internal/storage/common/resource.go
Added public Ctx context.Context field to ResourceQuery[Opts] struct and reordered fields; GetOne, Count, and Paginate now assign provided context to query before execution.
System store bucket-ledger counting
internal/storage/system/store.go
Added public method CountLedgersInBucket(ctx, bucketName) to Store interface and implemented on DefaultStore; queries Ledger records by bucket and returns count or wrapped error.
Dependency injection wiring
internal/storage/driver/module.go
Introduced SystemStoreFactory provider and added it to FX injection parameters; updated Driver construction to use injected factory; wired WithCountLedgersInBucketFunc() ledger store option to delegate to the factory's DB.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant repo as Repository
    participant resourceHandler as Resource Handler
    participant store as Ledger Store
    participant systemStore as System Store
    participant db as Database

    Client->>repo: GetOne(ctx, query)
    Note over repo: Assign query.Ctx = ctx
    repo->>resourceHandler: BuildDataset(opts with ctx)
    resourceHandler->>store: applyLedgerFilter(ctx, query, table)
    store->>systemStore: isSingleLedger(ctx) check
    systemStore->>db: Count ledgers in bucket
    db-->>systemStore: count
    alt Single Ledger
        store-->>resourceHandler: return query unchanged
    else Multiple Ledgers
        store->>db: append WHERE ledger = ?
        db-->>store: modified query
        store-->>resourceHandler: return filtered query
    end
    resourceHandler-->>repo: filtered SelectQuery
    repo->>db: Execute query
    db-->>repo: results
    repo-->>Client: data
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • High-complexity files requiring careful review:
    • internal/storage/ledger/resource_accounts.go, resource_aggregated_balances.go, resource_volumes.go: Multiple query paths (BuildDataset, ResolveFilter, Expand) with ledger filter injection across subqueries; verify join logic, column selections, and window expressions remain correct.
    • internal/storage/ledger/resource_transactions.go: Introduction of innerMostQuery pattern adds complexity; validate that effective volume calculations and grouping semantics are preserved across the ledger filtering.
    • internal/storage/common/resource.go: Structural change to ResourceQuery with context field addition; verify all instantiation sites are compatible and context flows correctly through pagination paths.
  • Medium-complexity areas:
    • Ledger helper functions in store.go: Verify isSingleLedger() error handling (conservative fallback) and that applyLedgerFilter() applies filters in the correct query position.
    • DI wiring in driver/module.go: Confirm SystemStoreFactory injection and CountLedgersInBucket delegation are correctly configured.

Poem

🐰 Ledger filters, once scattered and spread,
Now dance through one helper, centrally fed!
Single-ledger awareness brings queries such cheer,
Context flows freely from tier to tier.
Refactored and nimble, the storage's now blessed! ✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title Check ❓ Inconclusive The title references "query oprimization with where," which directly relates to the changeset's focus on query optimization and WHERE clause handling. However, the title is awkwardly phrased with a typo ("oprimization" instead of "optimization") and lacks specificity about the key optimization strategy—conditionally removing WHERE ledger clauses in single-ledger scenarios. While the title does reference relevant concepts from the changes, the vague phrasing makes it difficult for someone scanning commit history to understand what is actually being optimized or how. Consider revising the title to be more specific and clear, such as "fix: conditionally skip WHERE ledger filter for single-ledger deployments" or "fix: optimize queries by removing redundant ledger filters in single-ledger buckets." This would help developers immediately understand the optimization being applied without needing to read the description. Additionally, correct the typo "oprimization" to "optimization."
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed The description is clearly related to the changeset and provides meaningful, specific information about the optimization. It explains that the PR conditionally removes WHERE ledger = ? clauses when a bucket contains only one ledger, mentions that this is automatically detected at runtime, and quantifies the expected performance improvement (5-15% for single-ledger deployments with no impact on multi-ledger scenarios). The description directly corresponds to the refactoring work shown in the raw summary across all modified files.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/v2.3/query-oprimization-with-where

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
internal/storage/ledger/transactions.go (1)

215-233: Optional: move the ledger WHERE near the top for clarity and plan stability.

Adding the ledger constraint immediately after ModelTableExpr keeps the WHERE predicate close to the base relation; planner often benefits from early constraints in complex builders.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1d90239 and d2e281f.

📒 Files selected for processing (11)
  • internal/storage/driver/driver.go (2 hunks)
  • internal/storage/ledger/accounts.go (1 hunks)
  • internal/storage/ledger/logs.go (1 hunks)
  • internal/storage/ledger/resource_accounts.go (6 hunks)
  • internal/storage/ledger/resource_aggregated_balances.go (4 hunks)
  • internal/storage/ledger/resource_logs.go (1 hunks)
  • internal/storage/ledger/resource_transactions.go (3 hunks)
  • internal/storage/ledger/resource_volumes.go (4 hunks)
  • internal/storage/ledger/store.go (4 hunks)
  • internal/storage/ledger/transactions.go (4 hunks)
  • internal/storage/system/store.go (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
internal/storage/system/store.go (1)
internal/ledger.go (1)
  • Ledger (18-26)
internal/storage/ledger/store.go (2)
internal/ledger.go (1)
  • Ledger (18-26)
internal/storage/system/store.go (1)
  • Store (20-32)
🔇 Additional comments (18)
internal/storage/system/store.go (1)

65-74: LGTM! Clean implementation of bucket ledger counting.

The implementation follows established patterns in the codebase with proper error handling via postgres.ResolveError. The count operation is straightforward and will support the single-ledger optimization cache.

internal/storage/driver/driver.go (2)

100-102: LGTM! Consistent cache refresh pattern.

The cache update follows the same best-effort pattern as in CreateLedger. Refreshing on OpenLedger ensures the optimization state remains current for frequently accessed ledgers.


84-86: Incorrect analysis: UpdateSingleLedgerState has proper mutex protection.

The actual implementation at internal/storage/ledger/store.go:305-307 uses store.singleLedgerCache.mu.Lock() with defer unlock, making the cache state update atomic. The review comment's race condition concern is unfounded—concurrent CreateLedger calls will serialize their cache updates through the mutex, eliminating the inconsistency window described.

Likely an incorrect or invalid review comment.

internal/storage/ledger/accounts.go (2)

89-96: LGTM! Clean refactoring to centralized ledger filtering.

The two-step query construction (create base query, then conditionally apply ledger filter) is a clean pattern that supports the single-ledger optimization. The conditional check ensures the ledger filter is applied only when needed, maintaining correctness across both single and multi-ledger scenarios.


120-148: LGTM! Correct ledger value binding for insert operations.

The explicit Value("ledger", "?", store.ledger.Name) on line 132 is correct for insert operations. The optimization applies only to WHERE clause filtering, not to column value assignment in INSERT/UPSERT operations.

internal/storage/ledger/logs.go (1)

121-128: LGTM! Consistent refactoring to centralized ledger filtering.

The refactoring follows the established pattern of building the base query and applying ledger filtering via the shared helper. The implementation maintains correctness while enabling the single-ledger optimization.

internal/storage/ledger/resource_aggregated_balances.go (2)

26-76: LGTM! Comprehensive refactoring of PIT query path.

The refactoring consistently applies applyLedgerFilter across the moves query and its lateral join subqueries (accounts, accounts_metadata). This ensures the single-ledger optimization is applied uniformly throughout the query tree while maintaining correctness in multi-ledger scenarios.


79-108: LGTM! Consistent filtering in non-PIT query path.

The refactoring pattern matches the PIT path, applying applyLedgerFilter to the accounts_volumes query and its lateral join subqueries. The optimization is applied consistently across both query paths.

internal/storage/ledger/resource_logs.go (1)

24-28: LGTM! Straightforward refactoring to centralized filtering.

The two-step query construction is clean and consistent with the pattern used throughout this PR. The change enables the single-ledger optimization for log queries.

internal/storage/ledger/resource_volumes.go (2)

36-67: LGTM! Consistent refactoring of non-PIT volume queries.

The refactoring applies applyLedgerFilter to the accounts_volumes query and its lateral join for accounts. The pattern is clean and maintains correctness while enabling the optimization.


68-126: LGTM! Comprehensive refactoring of PIT volume queries.

The refactoring consistently applies applyLedgerFilter across the moves query and its lateral joins (accounts, accounts_metadata). The optimization is uniformly applied throughout the complex query tree for historical volume queries.

internal/storage/ledger/resource_accounts.go (3)

28-57: LGTM! Clean refactoring of account dataset with metadata history support.

The refactoring applies applyLedgerFilter to both the main accounts query and the accounts_metadata history subquery when using PIT. The pattern is consistent and maintains the existing feature flag logic for metadata history.


60-108: LGTM! Balance filter refactoring handles both PIT and non-PIT paths.

The refactoring applies applyLedgerFilter to balance subqueries in both the PIT (moves) and non-PIT (accounts_volumes) paths. The feature flag check for FeatureMovesHistory is preserved, and the optimization is applied consistently.


114-160: LGTM! Volume expansion refactoring covers all query paths.

The refactoring applies applyLedgerFilter to expansion queries for both the PIT (moves) and non-PIT (accounts_volumes) paths, handling both volumes and effectiveVolumes expansions. The feature flag checks are preserved, and the pattern is consistent throughout.

internal/storage/ledger/resource_transactions.go (3)

47-49: Good: centralized ledger scoping on the base dataset.

Using applyLedgerFilter on transactions keeps BuildDataset consistent with the optimization switch. Looks good.


59-66: Join-safety improvement acknowledged; confirm alias ‘transactions_metadata’.

Filtering the distinct-on metadata subquery prevents cross-ledger joins on transactions_id. Verify the subquery alias matches the one used in the JOIN clause.


127-134: Correct: scope inner moves subquery by ledger.

Applying applyLedgerFilter to innerMostQuery avoids mixing moves across ledgers when aggregating effective volumes. LGTM.

internal/storage/ledger/transactions.go (1)

184-190: LGTM: Fallback SELECT is correctly ledger-scoped.

Fallback path uses applyLedgerFilter on transactions, preventing cross-ledger retrieval. Good.

Comment on lines 23 to 26
type singleLedgerOptimization struct {
mu sync.RWMutex
enabled bool
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Blocker: Per-Store cache can go stale and leak cross‑ledger data after a new ledger is created.

singleLedgerOptimization is instantiated per Store. If bucket X starts single-ledger (cache=true) and later a second ledger is created, any existing Store instances for that bucket will continue skipping WHERE ledger = ?, causing cross-ledger reads/updates until they’re explicitly refreshed.

Make the optimization state bucket‑scoped and shared across all Store instances. Minimal fix: a package‑level registry keyed by bucket name returning a shared pointer.

Apply this pattern:

+// bucket-scoped registry of single-ledger optimization states
+var singleLedgerRegistry sync.Map // map[string]*singleLedgerOptimization
+
+func getOrCreateSingleLedgerOpt(bucketName string) *singleLedgerOptimization {
+  if v, ok := singleLedgerRegistry.Load(bucketName); ok {
+    return v.(*singleLedgerOptimization)
+  }
+  slo := &singleLedgerOptimization{enabled: false}
+  if actual, _ := singleLedgerRegistry.LoadOrStore(bucketName, slo); actual != nil {
+    return actual.(*singleLedgerOptimization)
+  }
+  return slo
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type singleLedgerOptimization struct {
mu sync.RWMutex
enabled bool
}
// bucket-scoped registry of single-ledger optimization states
var singleLedgerRegistry sync.Map // map[string]*singleLedgerOptimization
func getOrCreateSingleLedgerOpt(bucketName string) *singleLedgerOptimization {
if v, ok := singleLedgerRegistry.Load(bucketName); ok {
return v.(*singleLedgerOptimization)
}
slo := &singleLedgerOptimization{enabled: false}
if actual, _ := singleLedgerRegistry.LoadOrStore(bucketName, slo); actual != nil {
return actual.(*singleLedgerOptimization)
}
return slo
}
type singleLedgerOptimization struct {
mu sync.RWMutex
enabled bool
}
🤖 Prompt for AI Agents
In internal/storage/ledger/store.go around lines 23-26, the
singleLedgerOptimization struct is currently instantiated per Store which allows
stale per-store caches to skip ledger WHERE clauses after a new ledger is
created; change this to a bucket-scoped, shared registry so all Store instances
for the same bucket observe the same optimization state. Implement a
package-level map keyed by bucket name that returns a shared
*singleLedgerOptimization (protected by its own mutex for concurrent access),
replace per-Store instances with lookups into that registry during Store
construction, and ensure updates to the optimization state mutate the shared
pointer so all Stores pick up changes immediately.

Comment on lines 176 to 180
db: db,
ledger: l,
bucket: bucket,
singleLedgerCache: &singleLedgerOptimization{enabled: false},
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use the shared bucket-scoped state when constructing Store.

Initialize singleLedgerCache from the shared registry so all Stores in the same bucket observe the same flag.

-    singleLedgerCache: &singleLedgerOptimization{enabled: false},
+    singleLedgerCache: getOrCreateSingleLedgerOpt(l.Bucket),

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In internal/storage/ledger/store.go around lines 176 to 180, the Store is
currently initializing singleLedgerCache to a new local instance
(&singleLedgerOptimization{enabled: false}) instead of using the bucket-scoped
shared state; replace that local initialization with the shared registry's
bucket-scoped singleLedgerOptimization instance (e.g. obtain the shared bucket
state from the registry using the bucket identifier and assign its
singleLedgerOptimization pointer to Store.singleLedgerCache), and add a nil-safe
fallback to the existing behavior if the registry returns nil, ensuring all
Stores for the same bucket observe the same flag and that the assignment remains
concurrency-safe.

Comment on lines 285 to 291
func (store *Store) applyLedgerFilter(query *bun.SelectQuery, tableAlias string) *bun.SelectQuery {
if store.isSingleLedger() {
return query
}
return query.Where(tableAlias+".ledger = ?", store.ledger.Name)
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Alias correctness check for applyLedgerFilter.

applyLedgerFilter assumes tableAlias resolves in the query context. Confirm that all call sites either:

  • use the unaliased table name consistently in FROM, or
  • pass the actual alias used in the query.

Run a quick scan:


🏁 Script executed:

#!/bin/bash
# Show applyLedgerFilter usages with surrounding context to verify alias correctness
rg -n -C2 'applyLedgerFilter\(' --type go internal/storage | sed -n '1,200p'

Length of output: 10790


I need to examine the full query construction to verify if the aliases are properly defined. Let me check some representative query contexts.

Let me examine specific query constructions more closely to verify if the aliases are properly defined:

I need to check the model definitions to see what aliases are actually declared in the struct tags. Let me search for the model definitions:




🏁 Script executed:

#!/bin/bash
# Search for model struct definitions related to the tables used in applyLedgerFilter calls
rg -n "type.*struct.*bun:" internal/storage/ledger/ | head -40

Length of output: 0


Now I need to find the model definitions to confirm the actual aliases. Let me search more broadly:

Let me search for the actual model files in the codebase:

Based on my verification, the original review comment correctly identifies a real bug: when ModelTableExpr("treatment_3d_scans") is called without an explicit "AS alias" clause, Bun still uses the model's auto-generated alias (e.g., "treatment3d_scan"), causing SQL errors like "missing FROM-clause entry for table « treatment3d_scan »".

The same pattern appears in your codebase. Looking at the script output, calls like:

  • ModelTableExpr(h.store.GetPrefixedRelationName("accounts_volumes"))
  • applyLedgerFilter(selectVolumes, "accounts_volumes")

The function then generates: WHERE accounts_volumes.ledger = ?

However, using ModelTableExpr, you can override the table name, but not the alias. Bun generates aliases from struct names by underscoring them; for example, struct ArticleCategory gets table name article_categories and alias article_category (singular, not plural). This means the alias won't match the table name you pass to applyLedgerFilter, causing runtime SQL errors.

Fix all call sites to use explicit "AS alias" in ModelTableExpr matching the actual model alias, or modify applyLedgerFilter to receive the correct alias from the query context.

🤖 Prompt for AI Agents
internal/storage/ledger/store.go around lines 285-291: applyLedgerFilter uses
the table name string as if it were the query alias, but Bun's ModelTableExpr
auto-generates a different alias (derived from the model struct), causing
mismatched aliases and SQL errors; update all call sites that pass
ModelTableExpr(...) to include an explicit "AS <alias>" that matches the model's
auto-generated alias (or change those ModelTableExpr calls to use ModelExpr or
TableExpr with the correct alias), or alternatively change applyLedgerFilter to
accept and use the actual alias returned/used by the SelectQuery (e.g., pass the
alias along from the caller) so the WHERE clause references the real alias
instead of the table name.

Comment on lines 292 to 310
func (store *Store) getLedgerFilterSQL() (string, []any) {
if store.isSingleLedger() {
return "", nil
}
return "ledger = ?", []any{store.ledger.Name}
}

func (store *Store) UpdateSingleLedgerState(ctx context.Context, countFunc func(ctx context.Context, bucketName string) (int, error)) error {
count, err := countFunc(ctx, store.ledger.Bucket)
if err != nil {
return fmt.Errorf("failed to count ledgers in bucket: %w", err)
}

store.singleLedgerCache.mu.Lock()
defer store.singleLedgerCache.mu.Unlock()
store.singleLedgerCache.enabled = (count == 1)

return nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Always guard DML with a ledger predicate; add a DML-specific helper.

For UPDATE/DELETE, omitting the ledger clause is risky because transaction IDs are sequence-per-ledger. If the cache is ever stale, DML can hit the wrong ledger. The perf win on DML is negligible vs correctness.

Add a helper that never bypasses the filter and use it for DML paths.

 func (store *Store) getLedgerFilterSQL() (string, []any) {
   if store.isSingleLedger() {
     return "", nil
   }
   return "ledger = ?", []any{store.ledger.Name}
 }

+// DML must always be constrained by ledger to avoid cross-ledger updates
+func (store *Store) getLedgerFilterSQLForDML() (string, []any) {
+  return "ledger = ?", []any{store.ledger.Name}
+}

Optional hardening: in UpdateSingleLedgerState, no-op if state unchanged and emit a debug metric/log when the flag flips.

Ensure UpdateSingleLedgerState is called on:

  • CreateLedger (after commit),
  • DeleteLedger (after commit),
  • OpenLedger (on startup),
    and for every Store instance of the same bucket, or use the shared registry above to avoid per-Store refresh requirements.

I'll verify the concerns raised in this review comment by examining the codebase.


---

🏁 Script executed:

```shell
#!/bin/bash
# Search for UPDATE and DELETE statements in store.go
rg -n "UPDATE|DELETE" internal/storage/ledger/store.go

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find all usages of getLedgerFilterSQL
rg -n "getLedgerFilterSQL" internal/storage/ledger/

Length of output: 574


🏁 Script executed:

#!/bin/bash
# Find all call sites of UpdateSingleLedgerState
rg -rn "UpdateSingleLedgerState" internal/

Length of output: 406


🏁 Script executed:

#!/bin/bash
# Check for isSingleLedger usage in context of DML
rg -n -B2 -A2 "isSingleLedger" internal/storage/ledger/store.go

Length of output: 449


🏁 Script executed:

#!/bin/bash
# Find UPDATE/DELETE patterns to see how ledger filter is applied
rg -n -B3 -A3 "ledger\s*=\s*\?" internal/storage/ledger/store.go | head -50

Length of output: 470



---

🏁 Script executed:

```shell
#!/bin/bash
# Search for UPDATE/DELETE in transactions.go and accounts.go
rg -n "UPDATE|DELETE" internal/storage/ledger/transactions.go internal/storage/ledger/accounts.go

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Get context around lines 222, 258, 290 in transactions.go to see if they're DML
rg -n -B5 -A5 "getLedgerFilterSQL" internal/storage/ledger/transactions.go | head -80

Length of output: 1366


🏁 Script executed:

#!/bin/bash
# Search for UpdateSingleLedgerState in driver.go
rg -n -B5 -A5 "UpdateSingleLedgerState" internal/storage/driver/driver.go

Length of output: 697


🏁 Script executed:

#!/bin/bash
# Check if there are any UPDATE or DELETE query builders in the entire ledger package
rg -rn "\.Update\(|\.Delete\(|UPDATE |DELETE " internal/storage/ledger/ | head -30

Length of output: 0



---

🏁 Script executed:

```shell
#!/bin/bash
# Search for DeleteLedger to see if UpdateSingleLedgerState is called after it
rg -n -A10 "DeleteLedger" internal/storage/driver/driver.go

Length of output: 604


🏁 Script executed:

#!/bin/bash
# Check the actual method name - review says it's "n" but expected UpdateSingleLedgerState
rg -n "func.*UpdateSingleLedgerState\|func.*singleLedgerCache" internal/storage/ledger/store.go

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Look for shared registry or coordination between Store instances
rg -n "registry|shared.*store\|sync.*store" internal/storage/driver/driver.go -i

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check full context of store creation to see if multiple instances exist
cat internal/storage/driver/driver.go | grep -n -A2 -B2 "Create(d.db)"

Length of output: 2592


🏁 Script executed:

#!/bin/bash
# Verify the method call signature - check if "n" is an alias or typo
rg -n "\.n\(" internal/storage/driver/driver.go | head -5

Length of output: 0



---

🏁 Script executed:

```shell
#!/bin/bash
# Extract the actual method around line 299 to verify method name
sed -n '299,320p' internal/storage/ledger/store.go

Length of output: 588


🏁 Script executed:

#!/bin/bash
# Search for all occurrences of UpdateSingleLedgerState method definition and usage across codebase
rg -n "UpdateSingleLedgerState\|func.*\(\*Store\).*ctx.*countFunc" internal/storage/ledger/store.go

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for DeleteLedger operations in systemstore (where ledger creation/deletion likely lives)
rg -rn "DeleteLedger" internal/storage/systemstore/

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Look for ledger creation/deletion flow
rg -n "CreateLedger\|DeleteLedger" internal/storage/driver/ -A3 -B3

Length of output: 0


Add DML-specific ledger filter helper to prevent cross-ledger mutations.

The review is correct: DML operations in transactions.go (lines 222, 258, 290) currently bypass the ledger filter when isSingleLedger() is true. All three UPDATE operations follow the pattern:

if filterSQL, filterArgs := store.getLedgerFilterSQL(); filterSQL != "" {
    query = query.Where(filterSQL, filterArgs...)
}

When the cache enables single-ledger mode, getLedgerFilterSQL() returns ("", nil), causing the WHERE clause to be skipped entirely. This is a real risk if the cache becomes stale.

UpdateSingleLedgerState is called after CreateLedger (driver.go:84) and OpenLedger (driver.go:100), but no call found after DeleteLedger. Additionally, each OpenLedger creates a new Store instance with its own independent cache, so multiple Store instances can hold conflicting state without coordination.

Required actions:

  • Implement getLedgerFilterSQLForDML() that always includes the ledger predicate
  • Apply it to all UPDATE/DELETE operations in transactions.go and accounts.go
  • Verify UpdateSingleLedgerState is called after all ledger state mutations (including deletions)
  • Evaluate whether multiple Store instances per bucket should share cache state or use a shared registry

Comment on lines 222 to 224
if filterSQL, filterArgs := store.getLedgerFilterSQL(); filterSQL != "" {
query = query.Where(filterSQL, filterArgs...)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use DML-safe ledger filter here to avoid cross-ledger updates.

Replace getLedgerFilterSQL with getLedgerFilterSQLForDML (always returns the predicate).

-      if filterSQL, filterArgs := store.getLedgerFilterSQL(); filterSQL != "" {
-        query = query.Where(filterSQL, filterArgs...)
-      }
+      if filterSQL, filterArgs := store.getLedgerFilterSQLForDML(); filterSQL != "" {
+        query = query.Where(filterSQL, filterArgs...)
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if filterSQL, filterArgs := store.getLedgerFilterSQL(); filterSQL != "" {
query = query.Where(filterSQL, filterArgs...)
}
if filterSQL, filterArgs := store.getLedgerFilterSQLForDML(); filterSQL != "" {
query = query.Where(filterSQL, filterArgs...)
}
🤖 Prompt for AI Agents
In internal/storage/ledger/transactions.go around lines 222 to 224, the code
uses getLedgerFilterSQL which may return an empty predicate and is not DML-safe;
replace that call with getLedgerFilterSQLForDML (which always returns the
predicate) and pass its returned SQL and args directly to query.Where without
the empty-check branch so the DML-safe ledger filter is always applied; ensure
you update variable names if needed and spread the returned args into the Where
call.

Comment on lines 290 to 292
if filterSQL, filterArgs := store.getLedgerFilterSQL(); filterSQL != "" {
updateQuery = updateQuery.Where(filterSQL, filterArgs...)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Same as above for DELETE‑style metadata update.

Use the DML-safe helper.

-      if filterSQL, filterArgs := store.getLedgerFilterSQL(); filterSQL != "" {
-        updateQuery = updateQuery.Where(filterSQL, filterArgs...)
-      }
+      if filterSQL, filterArgs := store.getLedgerFilterSQLForDML(); filterSQL != "" {
+        updateQuery = updateQuery.Where(filterSQL, filterArgs...)
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if filterSQL, filterArgs := store.getLedgerFilterSQL(); filterSQL != "" {
updateQuery = updateQuery.Where(filterSQL, filterArgs...)
}
if filterSQL, filterArgs := store.getLedgerFilterSQLForDML(); filterSQL != "" {
updateQuery = updateQuery.Where(filterSQL, filterArgs...)
}

- Remove singleLedgerOptimization cache structure
- Add countLedgersInBucket function to Store
- Modify isSingleLedger to query count on each call
- Update applyLedgerFilter and getLedgerFilterSQL to accept context
- Inject countFunc via factory options in module.go
- Remove all UpdateSingleLedgerState calls from driver
- Add Ctx field to ResourceQuery for context propagation
- Update all call sites to pass context parameter (26 locations)
- Handle errors properly in isSingleLedger by returning (bool, error)
- Record errors in trace span instead of silently ignoring them
- Add explanatory comment for SystemStoreFactory provider
- Apply conservative behavior (use filter) when counting fails

Addresses review comments from PR #1090
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/storage/ledger/resource_transactions.go (1)

122-155: Now I need to examine the actual code more carefully to check if there's a SELECT-level ORDER BY clause after DISTINCT ON:

Add required ORDER BY clause to DISTINCT ON in Expand method.

The innerMostQuery uses DISTINCT ON without a matching ORDER BY clause at the SELECT level. The DISTINCT ON expression(s) must match the leftmost ORDER BY expression(s), and the "first row" of each set is unpredictable unless ORDER BY is used to ensure that the desired row appears first. This can result in nondeterministic query results.

Add .OrderBy("transactions_id, accounts_address, asset, seq DESC") to the innerMostQuery chain to ensure that the latest post_commit_effective_volumes (by seq DESC) is consistently selected for each (transactions_id, accounts_address, asset) tuple.

♻️ Duplicate comments (1)
internal/storage/ledger/store.go (1)

303-317: Critical correctness risk: DML operations must never skip ledger filter.

getLedgerFilterSQL returns ("", nil) when isSingleLedger is true, allowing UPDATE/DELETE operations to execute without a ledger predicate. This is used in DeleteAccountMetadata (accounts.go:94-96).

Risk: If the count is stale, incorrect, or a race condition occurs (second ledger created mid-operation), DML can mutate the wrong ledger's data. Transaction IDs and sequences are per-ledger, not globally unique.

Performance vs correctness: DML operations are infrequent compared to SELECT. The performance gain from skipping the predicate is negligible, but the correctness risk is severe.

Apply this pattern:

 func (store *Store) getLedgerFilterSQL(ctx context.Context) (string, []any) {
+  // Note: Only use for SELECT queries. DML must always include ledger filter.
   singleLedger, err := store.isSingleLedger(ctx)
   ...
 }

+// getLedgerFilterSQLForDML always returns the ledger filter for correctness in mutations.
+func (store *Store) getLedgerFilterSQLForDML() (string, []any) {
+  return "ledger = ?", []any{store.ledger.Name}
+}

Then update DeleteAccountMetadata in accounts.go to use getLedgerFilterSQLForDML().

🧹 Nitpick comments (1)
internal/storage/ledger/store.go (1)

333-337: Add documentation for caching expectations.

The option doesn't document that countFunc should implement caching to avoid performance degradation. Callers might assume the framework handles caching.

Add godoc:

+// WithCountLedgersInBucketFunc configures the ledger counting function for single-ledger optimization.
+// The countFunc SHOULD cache results and invalidate on CreateLedger/DeleteLedger to avoid
+// executing a COUNT query on every data operation.
 func WithCountLedgersInBucketFunc(countFunc func(ctx context.Context, bucketName string) (int, error)) Option {
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d2e281f and 643d767.

📒 Files selected for processing (11)
  • internal/storage/common/resource.go (4 hunks)
  • internal/storage/driver/module.go (2 hunks)
  • internal/storage/ledger/accounts.go (1 hunks)
  • internal/storage/ledger/logs.go (1 hunks)
  • internal/storage/ledger/resource_accounts.go (6 hunks)
  • internal/storage/ledger/resource_aggregated_balances.go (4 hunks)
  • internal/storage/ledger/resource_logs.go (1 hunks)
  • internal/storage/ledger/resource_transactions.go (3 hunks)
  • internal/storage/ledger/resource_volumes.go (4 hunks)
  • internal/storage/ledger/store.go (3 hunks)
  • internal/storage/ledger/transactions.go (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/storage/ledger/transactions.go
  • internal/storage/ledger/logs.go
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-05-20T13:07:54.504Z
Learnt from: gfyrag
PR: formancehq/ledger#935
File: internal/controller/system/state_tracker.go:50-55
Timestamp: 2025-05-20T13:07:54.504Z
Learning: In the ledger codebase's `handleState` method, when updating ledger state from `StateInitializing` to `StateInUse`, it's intentional to proceed silently when `rowsAffected == 0`. This indicates another parallel transaction has already updated the ledger state and configured the sequences, so no error needs to be returned and no sequence updating is required.

Applied to files:

  • internal/storage/ledger/store.go
🧬 Code graph analysis (2)
internal/storage/ledger/store.go (1)
internal/storage/system/store.go (2)
  • Store (20-32)
  • Option (157-157)
internal/storage/driver/module.go (5)
internal/storage/system/factory.go (2)
  • StoreFactory (7-9)
  • NewStoreFactory (21-23)
internal/storage/ledger/store.go (3)
  • WithTracer (327-331)
  • WithCountLedgersInBucketFunc (333-337)
  • New (168-255)
internal/storage/system/store.go (2)
  • WithTracer (159-163)
  • New (145-155)
internal/storage/driver/driver.go (3)
  • WithTracer (319-323)
  • Driver (28-37)
  • New (286-303)
internal/storage/ledger/factory.go (2)
  • NewFactory (18-23)
  • Factory (9-11)
🔇 Additional comments (17)
internal/storage/common/resource.go (1)

411-418: LGTM: Context propagation implemented correctly.

The Ctx field addition and propagation in GetOne, Count, and Paginate ensures downstream query builders receive the request context for optimization checks. The json:"-" tag appropriately prevents context serialization.

internal/storage/ledger/resource_accounts.go (3)

114-160: Verify alias correctness in Expand.

applyLedgerFilter is called with "moves" (line 133) and "accounts_volumes" (line 148). Same alias mismatch risk as in other methods.

Ensure consistency: either all ModelTableExpr calls use explicit AS table_name aliases, or all applyLedgerFilter calls pass struct-derived aliases.


60-108: Reject the review comment — Bun table names are explicitly specified, not auto-generated.

The Move and AccountsVolumes structs explicitly declare their table names via Bun tags: bun:"table:moves" and bun:"accounts_volumes" respectively. When these table names are passed to applyLedgerFilter, they correctly reference the actual tables. Bun does not generate singular aliases when table names are explicitly specified in struct tags, so the concern about alias mismatch is unfounded. The suggested diff to add AS moves is unnecessary.

Likely an incorrect or invalid review comment.


28-58: The review comment is incorrect and should be dismissed.

Bun automatically generates table aliases from struct names. For example, struct ArticleCategory generates alias article_category, and when using ModelTableExpr, explicit AS clauses ensure consistent aliases. However, the code path in question works correctly because:

  1. ModelTableExpr is designed for runtime table selection while maintaining consistent aliases through explicit AS clauses, but when no explicit alias is provided, Bun infers the alias from the table name itself.

  2. The code passes schema-qualified table names to ModelTableExpr (e.g., "bucket".accounts), and Bun extracts the table name portion as the default alias (e.g., accounts).

  3. The applyLedgerFilter function receives the same table names ("accounts", "accounts_metadata", "moves", etc.) that are used as aliases in the WHERE clauses, which matches the expected behavior.

  4. The JOIN clause explicitly specifies the alias in the SQL template (left join (?) accounts_metadata on accounts_metadata.accounts_address = accounts.address), confirming this pattern is intentional and working across the codebase.

  5. This pattern is consistently used throughout the codebase (resource_transactions.go, resource_volumes.go, etc.) with no reported issues.

internal/storage/ledger/resource_volumes.go (2)

68-126: Verify alias correctness in PIT/OOT path.

applyLedgerFilter calls use "moves" (line 82), "accounts" (line 101), and "accounts_metadata" (line 120) as aliases. Confirm these match Bun's struct-generated aliases or add explicit AS clauses.

All applyLedgerFilter calls across the codebase should use consistent alias resolution to prevent runtime SQL errors.


29-67: No alias mismatch exists—struct names and table definitions match the code.

The review comment claims structs are named AccountVolume and Account (singular), generating account_volume and account as table names. However, the actual struct definitions in internal/account.go are:

  • AccountsVolumes (plural) with explicit bun:"accounts_volumes" tag
  • Account with explicit bun:"table:accounts" tag

Both have explicit Bun table tags that define their table names precisely as used in the code ("accounts_volumes" and "accounts"). The literals passed to GetPrefixedRelationName are correct. No changes are needed.

Likely an incorrect or invalid review comment.

internal/storage/ledger/resource_transactions.go (2)

47-48: LGTM: Centralized ledger filtering applied correctly.

The refactoring to use applyLedgerFilter is consistent with the PR objectives and correctly passes the context and table name.


58-65: LGTM: Metadata history query correctly filtered.

The ledger filter is correctly applied to the transactions_metadata subquery with proper context propagation.

internal/storage/ledger/resource_logs.go (1)

23-28: LGTM: Clean refactoring to centralized ledger filtering.

The changes correctly adopt the centralized applyLedgerFilter pattern while preserving existing query logic.

internal/storage/driver/module.go (3)

32-38: LGTM: SystemStoreFactory provider added correctly.

The new provider is properly wired with tracing support and well-documented for cross-module usage.


39-60: Verify the performance impact of creating store instances on each count call.

The ledger store factory now includes a WithCountLedgersInBucketFunc option that creates a new system store instance via params.SystemStoreFactory.Create(params.DB) each time ledger counting is needed.

While this approach is functionally correct and maintains clean separation of concerns, consider whether the store creation overhead is acceptable if this function is called frequently (e.g., on every query in multi-ledger buckets).

Based on the PR description indicating 5-15% performance improvement, the current approach appears acceptable. However, if profiling reveals store creation overhead, consider caching the system store instance.


61-75: LGTM: Driver provider correctly updated.

The Driver provider now receives and passes the systemStoreFactory dependency as expected.

internal/storage/ledger/resource_aggregated_balances.go (5)

29-30: LGTM: Moves query correctly filtered.

The ledger filter is properly applied to the moves query in the point-in-time path.


52-61: LGTM: Accounts subquery correctly filtered for address resolution.

The ledger filter is properly applied to the accounts lateral subquery used for partial address matching.


63-75: LGTM: Accounts metadata subquery correctly filtered.

The ledger filter is properly applied to the accounts_metadata lateral subquery with correct context and table name.


79-83: LGTM: Accounts volumes query correctly filtered.

The ledger filter is properly applied to the accounts_volumes query in the non-point-in-time path.


85-105: LGTM: Accounts subquery correctly filtered in non-PIT path.

The ledger filter is properly applied to the accounts lateral subquery when metadata or partial address filtering is needed.

Comment on lines +89 to +98
query := store.db.NewUpdate().
ModelTableExpr(store.GetPrefixedRelationName("accounts")).
Set("metadata = metadata - ?", key).
Where("address = ?", account).
Where("ledger = ?", store.ledger.Name).
Exec(ctx)
Where("address = ?", account)

if filterSQL, filterArgs := store.getLedgerFilterSQL(ctx); filterSQL != "" {
query = query.Where(filterSQL, filterArgs...)
}

_, err := query.Exec(ctx)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: DML operation must always include ledger filter.

DeleteAccountMetadata executes an UPDATE (metadata removal) and conditionally applies the ledger filter via getLedgerFilterSQL(ctx). When isSingleLedger returns true, the UPDATE executes without a ledger predicate:

UPDATE accounts SET metadata = metadata - ? WHERE address = ?
-- Missing: AND ledger = ?

If the single-ledger optimization is incorrectly triggered (count error, race condition, stale state), this UPDATE mutates accounts across all ledgers in the bucket.

Use a DML-specific helper that never skips the filter:

- if filterSQL, filterArgs := store.getLedgerFilterSQL(ctx); filterSQL != "" {
-   query = query.Where(filterSQL, filterArgs...)
- }
+ // DML must always include ledger filter for correctness
+ if filterSQL, filterArgs := store.getLedgerFilterSQLForDML(); filterSQL != "" {
+   query = query.Where(filterSQL, filterArgs...)
+ }

(Requires implementing getLedgerFilterSQLForDML as suggested in store.go review)

🤖 Prompt for AI Agents
In internal/storage/ledger/accounts.go around lines 89 to 98, the UPDATE that
removes metadata conditionally uses getLedgerFilterSQL(ctx) which may return an
empty filter for the single-ledger optimization and thus perform a DML without
any ledger predicate; replace that conditional with the DML-specific helper
getLedgerFilterSQLForDML(ctx) and apply its returned SQL and args
unconditionally so the WHERE clause always includes the ledger predicate (append
the returned args to the query parameters and call query =
query.Where(filterSQL, filterArgs...) without skipping when filterSQL is empty),
ensuring the UPDATE cannot affect rows across other ledgers.

Comment on lines +272 to +285
// isSingleLedger returns true if the bucket optimization is enabled for single-ledger scenarios.
// This allows queries to skip the WHERE ledger = ? clause when there's only one ledger in the bucket.
// isSingleLedger checks in real-time if the bucket contains only one ledger.
// This query is fast since the ledgers table has very few rows.
func (store *Store) isSingleLedger(ctx context.Context) (bool, error) {
if store.countLedgersInBucket == nil {
return false, nil
}
count, err := store.countLedgersInBucket(ctx, store.ledger.Bucket)
if err != nil {
return false, fmt.Errorf("failed to count ledgers in bucket: %w", err)
}
return count == 1, nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical performance regression: real-time counting on every query negates optimization benefits.

isSingleLedger calls countLedgersInBucket on every query operation (SELECT, UPDATE, DELETE), converting a simple predicate skip into an additional COUNT query per operation. For a bucket with 1000 queries/sec, this introduces 1000 extra COUNT queries/sec just to save a WHERE clause.

The optimization only makes sense with caching:

  • Cache the ledger count per bucket
  • Invalidate when CreateLedger or DeleteLedger is called
  • Amortize the COUNT cost across thousands of queries

Without caching, the overhead exceeds any benefit from skipping the WHERE clause.

Consider implementing a bucket-scoped cache as suggested in the past review comments, or use a periodic refresh strategy (e.g., check every N seconds rather than on every query).

🤖 Prompt for AI Agents
In internal/storage/ledger/store.go around lines 272-285, the isSingleLedger
function currently calls countLedgersInBucket on every query which creates a
costly COUNT per operation; replace this real-time check with a cached
bucket-scoped value (e.g., map[bucket]{count, expiresAt} or atomic struct) that
is read fast on each query, update or invalidate the cache when CreateLedger or
DeleteLedger run (and/or use a short TTL refresh like N seconds to self-expire),
and ensure cache reads are concurrency-safe and fall back to calling
countLedgersInBucket on cache miss or refresh failure while returning the cached
boolean to callers to amortize the COUNT cost across many queries.

Comment on lines +287 to +301
// applyLedgerFilter conditionally applies the WHERE ledger = ? clause to a query.
// If the bucket contains only one ledger, the filter is skipped for performance optimization.
// On error, conservatively applies the filter.
func (store *Store) applyLedgerFilter(ctx context.Context, query *bun.SelectQuery, tableAlias string) *bun.SelectQuery {
singleLedger, err := store.isSingleLedger(ctx)
if err != nil {
// Log error but continue with conservative behavior (apply filter)
trace.SpanFromContext(ctx).RecordError(err)
return query.Where(tableAlias+".ledger = ?", store.ledger.Name)
}
if singleLedger {
return query
}
return query.Where(tableAlias+".ledger = ?", store.ledger.Name)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Critical SQL error: alias mismatch between ModelTableExpr and applyLedgerFilter.

applyLedgerFilter constructs WHERE tableAlias.ledger = ? using the tableAlias parameter (e.g., "accounts_volumes"). However, Bun's ModelTableExpr auto-generates aliases from struct names, not table names. If the struct is AccountVolume, the generated alias is account_volume (singular), causing:

ERROR: missing FROM-clause entry for table "accounts_volumes"

Fix: Call sites must either:

  1. Use explicit AS aliases matching the parameter: ModelTableExpr(name + " AS accounts_volumes")
  2. Or pass the actual Bun-generated alias (from struct name) instead of table name

Verify all call sites in resource_accounts.go, resource_volumes.go, resource_logs.go, etc.


🏁 Script executed:

#!/bin/bash
# Verify applyLedgerFilter call sites pass correct aliases
rg -n -C3 'applyLedgerFilter\(' internal/storage/ledger/ --type go | head -100

Length of output: 8519


🏁 Script executed:

#!/bin/bash
# Find struct definitions for the tables being queried
rg -n 'type (AccountVolume|Move|Account|Log|Transaction)' internal/storage/ledger/ --type go

Length of output: 289


🏁 Script executed:

#!/bin/bash
# Check if any queries use explicit "AS" alias assignments
rg -n ' AS ' internal/storage/ledger/resource_*.go --type go -C2

Length of output: 1043


🏁 Script executed:

#!/bin/bash
# Look at the full query construction for one problematic case
rg -n -B10 'applyLedgerFilter.*accounts_volumes' internal/storage/ledger/resource_volumes.go --type go | head -30

Length of output: 564


🏁 Script executed:

#!/bin/bash
# Find all struct definitions that might be models
fd -e go -x grep -l 'bun\.BaseModel\|tablename' {} \; internal/storage/ledger/

Length of output: 401


🏁 Script executed:

#!/bin/bash
# Check GetPrefixedRelationName implementation
rg -n 'GetPrefixedRelationName' internal/storage/ledger/store.go --type go -A5

Length of output: 256


🏁 Script executed:

#!/bin/bash
# Look for Move struct definition
rg -n 'type Move struct' internal/storage/ledger/ --type go -A10

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for Account struct definition
rg -n 'type Account struct' internal/storage/ledger/ --type go -A10

Length of output: 0


🌐 Web query:

Bun Go ModelTableExpr alias generation from struct name

💡 Result:

Short answer: ModelTableExpr can change the table expression/name at runtime but it cannot change the model alias Bun uses — the alias comes from the model (set by the bun tag or the default Bun-generated alias). Always provide the same alias in ModelTableExpr (e.g. "my_table AS u") or explicitly set the alias on the struct via bun.BaseModel bun:"...,alias:xxx".

Sources:

  • Bun docs — Defining models (shows alias tag and ModelTableExpr must use same alias). [1]
  • Bun docs — Select query API (ModelTableExpr usage). [2]

References:
[1] https://bun.uptrace.dev/guide/models.html
[2] https://bun.uptrace.dev/guide/query-select.html


🏁 Script executed:

#!/bin/bash
# Search for model definitions in broader directory
find internal -name "*.go" -type f | xargs grep -l 'type Move struct\|type Account struct\|type Log struct\|type Transaction struct' | head -20

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Search for model definitions with bun package reference
rg -n 'type (Move|Account|Log|Transaction|AccountVolume|AccountMetadata).*struct' internal/ --type go -A15 | head -150

Length of output: 10103


🏁 Script executed:

#!/bin/bash
# Check if models are in a models package
find internal -type d -name models -o -name model

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find Account struct with bun tag
rg -n 'type Account struct' internal/ --type go -A5 -B2

Length of output: 490


🏁 Script executed:

#!/bin/bash
# Find AccountsVolumes struct definition
rg -n 'type AccountsVolumes struct' internal/ --type go -A5 -B2

Length of output: 403


🏁 Script executed:

#!/bin/bash
# Find any other table model structs (accounts_metadata, transactions_metadata, etc.)
rg -n 'bun:"table:' internal/ --type go | grep -E 'accounts_metadata|transactions_metadata|accounts_address'

Length of output: 0


Critical SQL error: alias mismatch between table names and Bun-generated aliases.

The review comment correctly identifies a runtime SQL error. When ModelTableExpr is used without an explicit AS alias, Bun auto-generates the alias from the struct name. However, applyLedgerFilter receives table names that don't match these auto-generated aliases:

  • Move struct → auto-alias: "move" but passed: "moves"
  • Account struct → auto-alias: "account" but passed: "accounts"
  • AccountsVolumes struct → auto-alias: "accounts_volume" but passed: "accounts_volumes"
  • Transaction struct → explicit alias: "transactions" (set via bun:"table:transactions,alias:transactions") ✓
  • Log struct → explicit alias: "logs" (set via bun:"table:logs,alias:logs") ✓

This causes: ERROR: missing FROM-clause entry for table "moves" (or "accounts", etc.) at runtime.

Fix required: Either (1) add explicit alias tags to Move, Account, and AccountsVolumes struct definitions matching the table names, or (2) update all applyLedgerFilter call sites to pass the actual Bun-generated aliases.

🤖 Prompt for AI Agents
internal/storage/ledger/store.go lines 287-301: applyLedgerFilter is using table
aliases like "moves", "accounts", "accounts_volumes" that do not match Bun's
auto-generated aliases ("move", "account", "accounts_volume"), causing missing
FROM-clause SQL errors; fix by either adding explicit alias tags to the affected
structs (Move, Account, AccountsVolumes) such that their bun struct tags include
alias:"moves"/alias:"accounts"/alias:"accounts_volumes" to match existing call
sites, or consistently update every call site of applyLedgerFilter to pass Bun's
actual generated alias names (e.g., "move", "account", "accounts_volume");
implement one of these two fixes project-wide and run tests to confirm SQL no
longer references unknown aliases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants