Skip to content

bug(api): singleflight on /v1/query has no clean context-cancellation choice #120

Description

@EricAndrechek

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 entry

This 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.

Activity

  1. added
    bugSomething isn't working
    area/apiHTTP handlers, routing, middleware
    area/queryStructured query AST, SQL builder
    on May 12, 2026
  2. EricAndrechek commented on May 18, 2026

    @EricAndrechek
    MemberAuthor

    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/query singleflight — 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_total counter (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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/apiHTTP handlers, routing, middlewarearea/queryStructured query AST, SQL builderbugSomething isn't working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions