Skip to content

Commit 8553958

Browse files
committed
Timeout QueryProgress CGO call to prevent health check stalls
When DuckDB's httpfs extension is mid-download on a large remote parquet file, it holds an internal mutex on the connection. The health check's QueryProgress CGO call blocks waiting for that mutex, the health check goroutine stalls inside the pool RLock, the CP's 3-second gRPC deadline fires, and after 3 consecutive failures the CP kills the worker — even though it's alive and making progress on the download. Observed in production: read_parquet('https://...14GB_file.parquet') caused both warm workers to be declared unresponsive and deleted within ~8 seconds of query submission. The fix restructures the health check in two ways: 1. Snapshot session data under RLock, then release the lock before calling QueryProgress. Previously the RLock was held for the entire loop including CGO calls, so a blocked QueryProgress also prevented session create/close operations. 2. Run each QueryProgress call in a goroutine with a 500ms timeout. If the CGO call doesn't return in time, report the session as "busy (pct=-1)" and skip stall detection for that cycle. The health check always responds promptly. Real crashes (process death) are detected by the K8s pod informer independently — the w.done channel fires immediately when the pod terminates, without waiting for a health check cycle. So this change doesn't create a blind spot for crash recovery. Stall detection still works for queries that don't block QueryProgress (the common case). For queries that do block it (httpfs downloads), the client's own statement timeout is the appropriate kill mechanism — it comes from the user who decided how long to wait, not from infrastructure guessing.
1 parent 747bb26 commit 8553958

3 files changed

Lines changed: 178 additions & 32 deletions

File tree

‎duckdbservice/flight_handler.go‎

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -176,61 +176,109 @@ func (h *FlightSQLHandler) doHealthCheck(body []byte, stream flight.FlightServic
176176
<-h.pool.warmupDone
177177

178178
// Poll DuckDB query progress for each active session.
179+
//
180+
// QueryProgress is a CGO call into DuckDB that *should* return instantly
181+
// (it reads atomic progress counters), but can block if DuckDB holds an
182+
// internal lock — for example, when the httpfs extension is mid-download
183+
// on a large remote parquet file. If that happens while we hold the pool
184+
// RLock, both the health check and any session create/close operations
185+
// stall, the CP's 3-second health check timeout fires, and after 3
186+
// consecutive failures the CP kills the worker — even though it's alive
187+
// and making progress on the download.
188+
//
189+
// To prevent this:
190+
// 1. Snapshot the session data we need under RLock, then release it.
191+
// 2. Call QueryProgress outside the lock, with a per-session timeout.
192+
// If the CGO call doesn't return within queryProgressTimeout, we
193+
// report the session as "busy" (pct=-1) and skip stall detection
194+
// for this cycle. The health check always responds promptly.
195+
//
196+
// Real crashes (process death) are detected by the K8s pod informer
197+
// independently of health checks, so skipping stall detection during
198+
// I/O-heavy operations doesn't create a blind spot for crash recovery.
179199
type sessionProgressInfo struct {
180200
Pct float64 `json:"pct"`
181201
Rows uint64 `json:"rows"`
182202
Total uint64 `json:"total"`
183203
Stalled bool `json:"stalled,omitempty"`
184204
}
185-
sessionProgress := make(map[string]sessionProgressInfo)
205+
206+
type sessionSnapshot struct {
207+
key string
208+
conn duckdbConnHandle
209+
progress *progressState
210+
}
186211

187212
h.pool.mu.RLock()
213+
snapshots := make([]sessionSnapshot, 0, len(h.pool.sessions))
188214
for token, session := range h.pool.sessions {
189215
if session.duckdbConn.Ptr == nil {
190216
continue
191217
}
192-
193-
qp := bindings.QueryProgress(session.duckdbConn)
194-
pct, rows, total := bindings.QueryProgressTypeMembers(&qp)
195-
196-
// Use truncated token as key to avoid leaking full bearer tokens
197-
// in the health check JSON response. 16 hex chars = 8 bytes of entropy,
198-
// sufficient for matching on the control plane side.
199218
key := token
200219
if len(key) > 16 {
201220
key = key[:16]
202221
}
222+
snapshots = append(snapshots, sessionSnapshot{
223+
key: key,
224+
conn: session.duckdbConn,
225+
progress: &session.progress,
226+
})
227+
}
228+
h.pool.mu.RUnlock()
203229

204-
stalled := false
230+
sessionProgress := make(map[string]sessionProgressInfo, len(snapshots))
231+
for _, snap := range snapshots {
232+
if !snap.progress.queryActive.Load() {
233+
snap.progress.lastRowsProcessed.Store(0)
234+
snap.progress.stalledChecks.Store(0)
235+
sessionProgress[snap.key] = sessionProgressInfo{}
236+
continue
237+
}
205238

206-
if !session.progress.queryActive.Load() {
207-
// No query running — reset stall tracking.
208-
session.progress.lastRowsProcessed.Store(0)
209-
session.progress.stalledChecks.Store(0)
210-
} else if pct < 0 {
211-
// pct == -1 means DuckDB can't track this query; skip stall detection.
212-
} else {
213-
if rows == session.progress.lastRowsProcessed.Load() {
214-
session.progress.stalledChecks.Add(1)
239+
type qpResult struct {
240+
pct float64
241+
rows uint64
242+
total uint64
243+
}
244+
ch := make(chan qpResult, 1)
245+
go func(conn duckdbConnHandle) {
246+
qp := bindings.QueryProgress(conn)
247+
pct, rows, total := bindings.QueryProgressTypeMembers(&qp)
248+
ch <- qpResult{pct, rows, total}
249+
}(snap.conn)
250+
251+
select {
252+
case pr := <-ch:
253+
stalled := false
254+
if pr.pct < 0 {
255+
// pct == -1 means DuckDB can't track this query; skip stall detection.
215256
} else {
216-
session.progress.lastRowsProcessed.Store(rows)
217-
session.progress.stalledChecks.Store(0)
218-
}
219-
220-
if session.progress.stalledChecks.Load() >= stallCheckThreshold {
221-
stalled = true
222-
// Log once when first crossing the threshold (exact match avoids log spam).
223-
if session.progress.stalledChecks.Load() == stallCheckThreshold {
224-
slog.Warn("Query appears stuck — no progress detected.",
225-
"session", key, "rows_processed", rows, "total_rows", total,
226-
"stalled_checks", stallCheckThreshold)
257+
if pr.rows == snap.progress.lastRowsProcessed.Load() {
258+
snap.progress.stalledChecks.Add(1)
259+
} else {
260+
snap.progress.lastRowsProcessed.Store(pr.rows)
261+
snap.progress.stalledChecks.Store(0)
262+
}
263+
if snap.progress.stalledChecks.Load() >= stallCheckThreshold {
264+
stalled = true
265+
if snap.progress.stalledChecks.Load() == stallCheckThreshold {
266+
slog.Warn("Query appears stuck — no progress detected.",
267+
"session", snap.key, "rows_processed", pr.rows, "total_rows", pr.total,
268+
"stalled_checks", stallCheckThreshold)
269+
}
227270
}
228271
}
272+
sessionProgress[snap.key] = sessionProgressInfo{Pct: pr.pct, Rows: pr.rows, Total: pr.total, Stalled: stalled}
273+
274+
case <-time.After(queryProgressTimeout):
275+
// CGO call blocked — DuckDB is busy with I/O (e.g., httpfs
276+
// download). Report as untrackable and don't advance stall
277+
// counters so a legitimately slow operation doesn't get
278+
// flagged as stuck.
279+
sessionProgress[snap.key] = sessionProgressInfo{Pct: -1}
229280
}
230-
231-
sessionProgress[key] = sessionProgressInfo{Pct: pct, Rows: rows, Total: total, Stalled: stalled}
232281
}
233-
h.pool.mu.RUnlock()
234282

235283
resp, _ := json.Marshal(map[string]interface{}{
236284
"healthy": true,

‎duckdbservice/progress.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"reflect"
77
"sync/atomic"
8+
"time"
89
"unsafe"
910

1011
bindings "github.com/duckdb/duckdb-go-bindings"
@@ -19,6 +20,16 @@ type duckdbConnHandle = bindings.Connection
1920
// 300 checks × 2s = ~10 minutes of zero progress.
2021
const stallCheckThreshold = 300
2122

23+
// queryProgressTimeout is the maximum time the health check waits for a
24+
// single QueryProgress CGO call before treating the session as "busy
25+
// (untrackable)". DuckDB's QueryProgress is normally instant (reads
26+
// atomic counters), but can block when the connection holds an internal
27+
// mutex — e.g., during a long httpfs download. Without this timeout the
28+
// health check itself stalls, the CP's 3-second gRPC deadline fires, and
29+
// after 3 consecutive failures the CP kills the worker even though it's
30+
// alive and working.
31+
const queryProgressTimeout = 500 * time.Millisecond
32+
2233
// progressState tracks query activity and stall detection for a session.
2334
// All fields are atomic because queryActive is written by query goroutines
2435
// while lastRowsProcessed and stalledChecks are written by the health check

‎duckdbservice/progress_test.go‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package duckdbservice
33
import (
44
"context"
55
"database/sql"
6+
"encoding/json"
67
"testing"
8+
"time"
79

810
bindings "github.com/duckdb/duckdb-go-bindings"
911
_ "github.com/duckdb/duckdb-go/v2"
@@ -44,3 +46,88 @@ func TestStallCheckThreshold(t *testing.T) {
4446
t.Errorf("expected stallCheckThreshold=300, got %d", stallCheckThreshold)
4547
}
4648
}
49+
50+
func TestQueryProgressTimeout(t *testing.T) {
51+
// Verify the timeout is long enough for normal QueryProgress calls
52+
// but short enough to prevent health check stalls.
53+
if queryProgressTimeout < 100*time.Millisecond {
54+
t.Errorf("queryProgressTimeout too short: %v", queryProgressTimeout)
55+
}
56+
if queryProgressTimeout > 2*time.Second {
57+
t.Errorf("queryProgressTimeout too long (must be well under the 3s health check gRPC deadline): %v", queryProgressTimeout)
58+
}
59+
}
60+
61+
func TestHealthCheckRespondsWhenQueryProgressBlocks(t *testing.T) {
62+
pool := &SessionPool{
63+
sessions: make(map[string]*Session),
64+
stopRefresh: make(map[string]func()),
65+
warmupDone: make(chan struct{}),
66+
startTime: time.Now(),
67+
}
68+
close(pool.warmupDone)
69+
70+
// Create a session with a fake DuckDB connection whose QueryProgress
71+
// would block. We can't actually block the CGO call in a unit test,
72+
// but we CAN verify the restructured code path: snapshot sessions
73+
// outside the RLock, call QueryProgress per-session, and always
74+
// produce a response.
75+
db, err := sql.Open("duckdb", "")
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
defer func() { _ = db.Close() }()
80+
81+
conn, err := db.Conn(context.Background())
82+
if err != nil {
83+
t.Fatal(err)
84+
}
85+
defer func() { _ = conn.Close() }()
86+
87+
duckConn, err := extractDuckDBConnection(conn)
88+
if err != nil {
89+
t.Fatalf("extractDuckDBConnection: %v", err)
90+
}
91+
92+
session := &Session{ID: "test-session", duckdbConn: duckConn}
93+
session.progress.queryActive.Store(true) // simulate active query
94+
pool.sessions["abcdef1234567890xx"] = session
95+
96+
handler := &FlightSQLHandler{pool: pool}
97+
stream := &mockDoActionStream{}
98+
99+
done := make(chan error, 1)
100+
go func() {
101+
done <- handler.doHealthCheck([]byte(`{}`), stream)
102+
}()
103+
104+
select {
105+
case err := <-done:
106+
if err != nil {
107+
t.Fatalf("health check returned error: %v", err)
108+
}
109+
case <-time.After(2 * time.Second):
110+
t.Fatal("health check blocked longer than 2s — would trigger CP timeout")
111+
}
112+
113+
if len(stream.results) != 1 {
114+
t.Fatalf("expected 1 result, got %d", len(stream.results))
115+
}
116+
117+
var resp map[string]interface{}
118+
if err := json.Unmarshal(stream.results[0].Body, &resp); err != nil {
119+
t.Fatalf("unmarshal response: %v", err)
120+
}
121+
if resp["healthy"] != true {
122+
t.Errorf("expected healthy=true, got %v", resp["healthy"])
123+
}
124+
125+
// Verify session progress is reported (with pct=-1 since no real query)
126+
sp, ok := resp["session_progress"].(map[string]interface{})
127+
if !ok {
128+
t.Fatalf("expected session_progress map, got %T", resp["session_progress"])
129+
}
130+
if len(sp) != 1 {
131+
t.Fatalf("expected 1 session in progress, got %d", len(sp))
132+
}
133+
}

0 commit comments

Comments
 (0)