Skip to content

fix(tiering): cache negative policy lookups - #498

Merged
xe-nvdk merged 3 commits into
mainfrom
fix/tiering-policy-negative-cache
Jun 12, 2026
Merged

xe-nvdk merged 3 commits into
mainfrom
fix/tiering-policy-negative-cache

Conversation

@xe-nvdk

@xe-nvdk xe-nvdk commented Jun 12, 2026

Copy link
Copy Markdown
Member

Closes #345.

Summary

  • PolicyStore.Get had a comment claiming "cache even if nil" but only cached found policies — every lookup for a database with no custom tiering policy (the common case) queried SQLite, twice per file per migration cycle (IsHotOnly + GetEffectivePolicy in Migrator.FindCandidates). Not-found results are now cached as nil in the existing map: key-presence is the sentinel, and the (nil, nil) caller contract is unchanged.
  • Race safety: getFromDB runs outside the mutex, so the cache write is double-checked under the write lock — a concurrent Set can no longer be shadowed by a stale negative entry (which, unlike today's uncached negatives, would never self-heal). The racing Get returns the fresher cached entry.
  • List skips cached negatives — it dereferences every cache value and would panic on the first nil (internal review blocker, proven with a test before flagging; regression test included).
  • Invalidation unchanged: Set overwrites the entry, Delete removes it. Transient DB errors are never cached (Get bails before the cache write on any non-ErrNoRows error).
  • Known accepted trade-offs: (1) negative entries from the admin-gated policy API are unbounded but pointer-sized and admin-only; (2) a policy row written to SQLite outside the API (manual edit / restore) now requires a restart to be visible — noted in release notes; (3) the pre-existing Delete/Get stale-positive race is out of scope — follow-up issue to be filed.
  • Tiering is enterprise-gated; PolicyStore does not exist in OSS.

Test plan

  • go build ./cmd/... ./internal/..., go vet, gofmt -l clean
  • go test -race ./internal/tiering/ -count=1 — all PolicyStore tests pass
  • TestPolicyStore_NegativeLookupCached verified to FAIL pre-fix (direct-SQL insert invisible to cached lookup proves SQLite is no longer queried)
  • TestPolicyStore_SetOverridesCachedNegative guards invalidation
  • TestPolicyStore_ListSkipsCachedNegatives regression test for the List panic + IsHotOnly/GetEffective negative-path coverage
  • Internal config-matrix + deep review completed (1 Blocker found and fixed pre-commit; M-findings addressed or tracked)

🤖 Generated with Claude Code

PolicyStore.Get claimed in a comment to cache nil results but only
cached found policies, so every lookup for a database with no custom
policy (the common case) hit SQLite — twice per file per migration
cycle. Not-found results are now cached as nil in the existing map;
key-presence is the sentinel and the (nil, nil) caller contract is
unchanged.

The cache write is double-checked under the write lock: getFromDB runs
outside the lock, so a concurrent Set could otherwise be shadowed by a
stale negative that never self-heals. List skips cached negatives —
it dereferences every cache value and would panic on the first nil
(caught by internal review, with regression test).

Set still overwrites the entry and Delete still removes it, so
invalidation is unchanged. Tiering is enterprise-gated; OSS unaffected.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes the tiering policy store by caching negative (not-found) lookups, preventing redundant SQLite queries for databases using global defaults. It also updates the List method to skip these cached negatives and adds comprehensive unit tests to verify the behavior. The reviewer suggested using defer s.mu.Unlock() to handle mutex unlocking more idiomatically and safely.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread internal/tiering/policy.go Outdated
Behaviorally identical — the function returns immediately after the
locked section — but defer survives future return paths.
@xe-nvdk

xe-nvdk commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

@gemini-code-assist Round-1 finding addressed: the cache-write block in Get now uses defer s.mu.Unlock(). Please take another look.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request optimizes tiering policy lookups by caching 'not-found' results, preventing redundant SQLite queries for databases without custom policies. It includes a fix for a potential race condition during cache updates and adds robust tests to verify the new caching logic. The reviewer highlighted a potential issue regarding shallow copying of the DatabasePolicy struct, noting that the HotMaxAgeDays pointer field should be deep-copied to ensure cache immutability and prevent data races.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread internal/tiering/policy.go

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces caching for negative (not-found) tiering policy lookups to avoid redundant SQLite queries on every migration cycle. It updates the Get method to cache nil results while handling concurrent race conditions, and modifies List to skip these cached negatives. A review comment suggests optimizing memory allocation in List by counting non-nil policies before pre-allocating the slice, preventing over-allocation when many negative lookups are cached.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread internal/tiering/policy.go Outdated
…nd 3)

len(s.cache) now includes nil not-found entries, often the majority —
count non-nil policies first so the capacity hint is exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@xe-nvdk

xe-nvdk commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

@gemini-code-assist Round-3 finding addressed: List now counts non-nil entries before allocating, so the capacity hint excludes cached negatives. Please take another look.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements caching of negative (not-found) tiering policy lookups to avoid redundant SQLite queries. It updates the Get method to cache nil results with safeguards against concurrent write races, adjusts the List method to skip cached negatives, and adds comprehensive unit tests. The reviewer suggested optimizing the List method by performing a single map iteration and pre-allocating the slice capacity to len(s.cache) to avoid the overhead of a double-pass iteration.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +220 to 238
// Size the slice from the non-nil count: the cache also holds nil
// not-found entries (#345), often the majority, and len(s.cache) would
// over-allocate. Both passes run under the same RLock.
var count int
for _, policy := range s.cache {
if policy != nil {
count++
}
}

policies := make([]DatabasePolicy, 0, count)
for _, policy := range s.cache {
// Skip cached negatives: nil marks "no custom policy" (#345) and
// must not be dereferenced or listed.
if policy == nil {
continue
}
policies = append(policies, *policy)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The current implementation of List performs two passes over the s.cache map: first to count the non-nil entries, and second to populate the slice. Map iteration in Go has non-trivial overhead due to bucket traversal and runtime iterator setup.

Since DatabasePolicy is a relatively small struct (~88 bytes), pre-allocating the slice with len(s.cache) as the capacity is highly efficient. It avoids the second map iteration entirely and guarantees that the slice backing array is allocated exactly once without any dynamic resizing overhead.

	policies := make([]DatabasePolicy, 0, len(s.cache))
	for _, policy := range s.cache {
		if policy == nil {
			continue
		}
		policies = append(policies, *policy)
	}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining — this suggestion reverts exactly what the round-3 review on this PR requested (count first for an exact capacity vs. single pass with len(s.cache)). Both variants are fine: List backs an admin-only endpoint (GET /api/v1/tiering/policies), called rarely, where neither the second map iteration nor the over-allocation is measurable. Oscillating between two unmeasured micro-optimizations is churn; keeping the round-3 version as committed. Happy to revisit with a benchmark if this ever shows up in a profile.

@xe-nvdk
xe-nvdk merged commit cd84361 into main Jun 12, 2026
5 checks passed
@xe-nvdk
xe-nvdk deleted the fix/tiering-policy-negative-cache branch June 12, 2026 18:05
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.

medium(tiering): PolicyStore.Get doesnt cache negative lookups

1 participant