Repository navigation
bug(api): singleflight on /v1/query has no clean context-cancellation choice #120
Description
Problem
internal/api/query.go QueryHandler.Handle wraps the per-request ClickHouse execution in singleflight.Group.Do(cacheKey, ...) so that N concurrent identical queries collapse to one round-trip. The function inside Do builds queryCtx from r.Context() plus a 30s timeout. There's no clean choice for which request's context drives that work:
| Choice | Behaviour | Failure mode |
|---|---|---|
r.Context() directly (current behaviour after PR #116 review) |
Caller-1's disconnect cancels the in-flight ClickHouse query — and everyone else waiting on the same singleflight key gets that cancellation as their error. | Multi-waiter regression. Caller-2 / Caller-3 each have to retry from scratch, losing the singleflight benefit and re-issuing the same expensive query. |
context.WithoutCancel(r.Context()) (what the observability_2 branch had) |
Detached context — the query runs to its 30s timeout regardless of who disconnected. Caller-2 always gets the result if it lands within 30s. | If all waiters disconnect, the query keeps burning ClickHouse cycles for up to 30s. Worst case under a thundering-herd-then-walk-away pattern: every cache-miss SQL keeps running after every client gave up. |
This was raised in PR #116 review (Gemini flagged the WithoutCancel change as a resource-exhaustion risk; reverting to r.Context() introduces the multi-waiter regression). Either choice is wrong for some shape of traffic.
Proposed Solution
Track active waiter count for each singleflight key. The query context should cancel only when the count drops to zero — i.e. when every caller has actually given up.
Sketch:
type waiterTracker struct {
mu sync.Mutex
refs map[string]int
cancels map[string]context.CancelFunc
}
// On entry:
// - inc refs[key]; if first, create cancellable detached ctx + store cancel
// - register a goroutine watching r.Context().Done() that decs refs and
// calls cancel only when refs hits 0
// On exit (singleflight returns):
// - cleanup map entryThis matches the singleflight invariant: one in-flight execution per key, alive as long as any waiter is interested. Implementation can be local to QueryHandler — no library swap needed.
Alternatives Considered
- Bound the work-after-disconnect window (e.g. detached ctx with 5s grace instead of 30s). Reduces but doesn't eliminate either failure mode; just trades them off.
- Drop singleflight entirely for
/v1/query. Removes the multi-waiter problem but reintroduces thundering-herd against ClickHouse for popular queries — the original reason singleflight was added. - Switch to a request-coalescing cache layer (e.g. groupcache-style). Heavier and arguably overkill for a single ClickHouse-fronting endpoint.
- Status quo (
r.Context()). Accept the multi-waiter regression as the lesser evil, document it. Reasonable if measurement shows multi-waiter collapse is rare in practice; needs a metric to confirm.
Additional Context
Surfaced during PR #116 review (Claude + Gemini both touched the area). Current code (post-PR-#116) uses r.Context() directly — the multi-waiter regression is live but not yet observed in production. Adding a wavehouse_query_singleflight_collapsed_total counter would tell us how often the collapse actually fires before deciding how much to invest here.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Activity
fix(observability): address PR #116 bot review
fix(observability): address PR #116 bot review
fix(observability): address PR #116 bot review
feat(observability): add otel pipeline and system tracing (#116)
EricAndrechek commentedon May 18, 2026
Demoting P1 → P3 as part of the pre-alpha priority pass.
Rationale: this is a known tradeoff between caller-disconnect and waiter-coalescing behavior on
/v1/querysingleflight — not a regression and not a user-reported incident. Both choices have failure modes only under specific traffic shapes (multi-waiter cancellation regression vs work-after-disconnect resource cost).Before investing in the waiter-tracker design described in the body, we should measure whether the collapse actually fires often enough to matter. A
wavehouse_query_singleflight_collapsed_totalcounter (one-line add) tells us the answer in production. Designing the full fix without that signal risks building for a problem that doesn't manifest in real traffic — and either way, the right design decision flows from data.Recommended path: add the counter pre-alpha (cheap), revisit post-alpha with the data.
fix(api): map ClickHouse query failures by class, not HTTP status (#627)