Skip to content

fix(cubestore): Report a websocket peer that goes away as debug, not error - #11786

Merged
waralexrom merged 5 commits into
masterfrom
cubestore-websocket-reset-log-level
Sep 9, 2026
Merged

fix(cubestore): Report a websocket peer that goes away as debug, not error#11786
waralexrom merged 5 commits into
masterfrom
cubestore-websocket-reset-log-level

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Summary

A websocket client that disconnects without a close handshake was logged as an
error, even though that is how connections normally end: the Cube Store driver
terminates them by design on a heartbeat timeout, on a write error and on
dispose, and a killed process or a closed browser tab looks the same from the
server. A rolling restart of the API therefore produced one error line per
connection and pushed the error rate of a perfectly healthy node over its
alerting threshold. This classifies the read-stream error instead of reporting
every one of them at error.

Changes

  • websocket_error_level() / tungstenite_error_level() in http/mod.rs pick
    the level for an error from the websocket read stream; the call site logs
    through log::log! with the level they return.
  • debug: a peer that is simply gone —
    Protocol(ResetWithoutClosingHandshake), Io with kind ConnectionReset,
    ConnectionAborted, BrokenPipe, NotConnected or UnexpectedEof, plus
    ConnectionClosed / AlreadyClosed. The last two cannot reach this branch
    with the current tokio-tungstenite, which turns them into the end of the
    stream, but they are a normal close either way and are listed so a version
    bump cannot turn a graceful close into an error.
  • warn: Protocol(ReceivedAfterClosing) — a frame that raced the peer's own
    close frame. Harmless for the connection, worth seeing if a client does it
    often.
  • error, unchanged: every other protocol violation (InvalidOpcode,
    NonZeroReservedBits, ControlFrameTooBig, ...), Tls, Capacity, Utf8,
    AttackAttempt, WriteBufferFull, and any Io error whose kind is not a
    vanished peer. Io is split by kind rather than lowered as a group, so a
    genuine failure of the connection is still reported as one.

Testing

  • http::tests::websocket_error_levels covers one representative per class,
    including both shapes a vanished peer arrives in and an Io error that must
    stay at error. The classification is a function over tungstenite::Error
    rather than warp::Error precisely so it is constructible in a test —
    warp::Error has no public constructor.
  • cargo check -p cubestore --lib --all-targets clean, cargo fmt applied.

…error

A client that closes without a close handshake ends the connection the same
way a graceful one does: the driver terminates connections by design on a
heartbeat timeout, a write error and on dispose, and a killed process or a
closed browser tab looks the same. Every one of those was logged as an error,
so a rolling restart of the API produced one error line per connection and
pushed the error rate of a healthy node over its alerting threshold.

Classify the read-stream error instead: a vanished peer is reported at debug,
a frame arriving after the peer's own close frame at warn, and everything else
-- other protocol violations, TLS, capacity and IO errors that are not a
vanished peer -- stays at error. IO errors are split by kind rather than
lowered as a group, so a genuine failure of the connection is still an error.
@waralexrom
waralexrom requested a review from a team as a code owner September 6, 2026 14:25
@github-actions github-actions Bot added cube store Issues relating to Cube Store rust Pull requests that update Rust code labels Sep 6, 2026
@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 1m 55s —— View job


Review: approach is sound — 1 medium (send path still logs vanished peers at error), 1 low (comment verbosity)

Full review
  • Read the diff (git show HEAD -- rust/cubestore/cubestore/src/http/mod.rs)
  • Verify the warp::Errortungstenite::Error downcast actually resolves
  • Check the classification against tungstenite 0.20 error variants
  • Review test coverage
  • Check comment density against repo guidance
  • Post inline comments

What's correct

The downcast holds: Cargo.lock pins a single tungstenite 0.20.1 shared by warp 0.3.6 and the direct tokio-tungstenite 0.20.1 dependency, so source().downcast_ref::<tungstenite::Error>() matches the same type. The pattern is already proven in message_too_large_reason (mod.rs:122). If those ever diverge, the fallback is Level::Error — the pre-PR behavior — so the failure mode is safe.

Splitting Io by ErrorKind rather than lowering the variant as a group is the right call, and the _ => Level::Error default means a new tungstenite variant is loud rather than silently swallowed. Match-arm ordering is unambiguous (the two Protocol(..) arms don't overlap). log::log!(level, ...) is used correctly, and websocket_error_level is only reached in the None branch of message_too_large_reason, so the too-large path keeps its dedicated handling.

The test compiles against use super::* (which supplies tungstenite, ProtocolError, Level, io), covers both shapes of a vanished peer plus two must-stay-error cases, and testing over tungstenite::Error instead of warp::Error is the right factoring given warp::Error has no public constructor. ProtocolError::InvalidOpcode(u8) and ResetWithoutClosingHandshake both exist in 0.20.

No security or performance concerns — this is a pure log-level classification with no allocation on the hot path and no change to what is logged.

Findings

# Severity Location Issue
1 Medium mod.rs:530, 541, 567 Send-path errors still log at error. A peer that goes away mid-query hits Websocket message send error with ConnectionClosed/Io(BrokenPipe), so the rolling-restart scenario the PR targets can still emit one error line per connection. Routing those three sites through the same websocket_error_level would close the gap — the classifier is already direction-agnostic.
2 Low mod.rs:155–166 The 11-line doc block exceeds the repo's 3-line guidance for explanatory comments, repeats the source() sentence verbatim from mod.rs:115, and restates the commit message. The four inline blocks in tungstenite_error_level are similar; the _ => one mostly re-lists the arms above it.

Notes, not blocking

ConnectionClosed/AlreadyClosed are listed defensively for a future tokio-tungstenite bump — worth keeping, and the comment says as much. After this change a connection ending abnormally is invisible at the default info level; that's the intent, but if connection-lifecycle visibility matters operationally, a single debug! on loop exit would be a cheaper place to get it than an error per disconnect.

· branch [`cubestore-websocket-reset-log-level`](https://github.com/cube-js/cube/tree/cubestore-websocket-reset-log-level)

Comment thread rust/cubestore/cubestore/src/http/mod.rs
Comment thread rust/cubestore/cubestore/src/http/mod.rs Outdated
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.16%. Comparing base (9237c26) to head (880d836).
⚠️ Report is 21 commits behind head on master.

❗ There is a different number of reports uploaded between BASE (9237c26) and HEAD (880d836). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (9237c26) HEAD (880d836)
cubesql 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11786       +/-   ##
===========================================
- Coverage   84.67%   60.16%   -24.51%     
===========================================
  Files         261      239       -22     
  Lines       86451    19203    -67248     
  Branches        0     3886     +3886     
===========================================
- Hits        73204    11554    -61650     
+ Misses      13247     7099     -6148     
- Partials        0      550      +550     
Flag Coverage Δ
cube-backend 60.16% <ø> (?)
cubesql ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A connection breaks in both directions. A peer that goes away with a query in
flight fails the send instead of the read, and the send towards a peer that
already closed reports it as `ConnectionClosed`, `AlreadyClosed` or
`Io(BrokenPipe)` -- so one error line per connection was still logged for a
client that merely left.

Route every write towards the peer through the same classifier, which never
depended on the direction. Also drop the comments that restated the arms they
sit on.
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 1m 39s —— View job


Re-review after 48be719: both prior findings addressed — no blocking issues; 1 low note on a remaining error! path

Full review
  • Diff the new commit (git diff a6539b7..HEAD -- rust/cubestore/cubestore/src/http/mod.rs)
  • Verify every websocket transport log site is now classified
  • Re-check the classifier against the send direction
  • Re-check comment density after the trim
  • Review test coverage
  • Resolve my two now-stale threads

What changed

48be719 routes all seven send/close/ping error sites through websocket_error_level via log::log!, extracts is_peer_gone(io::ErrorKind) as a named predicate, and trims the doc block from 11 lines to 5. Both findings from the previous round are addressed; I resolved both threads.

Verification

The send direction is sound: warp's WebSocket Sink wraps the tungstenite error with Error::new(e), so source().downcast_ref::<tungstenite::Error>() resolves the same way it does on the read side — the classifier really is direction-agnostic, and ConnectionClosed / Io(BrokenPipe) are the shapes a send to a departed peer actually takes, so the rolling-restart path is now quiet end to end.

Every remaining error! in the connection loop is a non-transport failure and correctly stays at error: message-too-large (540, 578), flatbuffer deserialization (560), HttpMessage::read (590), the bounded-channel try_send (604), non-binary frame (623). websocket_error_level is still only reached in the None branch of message_too_large_reason, so the too-large close path keeps its dedicated handling.

is_peer_gone is a good extraction — it puts the intent in the name and drops the comment that was explaining the list. Splitting Io by kind rather than lowering the variant wholesale, and keeping _ => Level::Error so a new tungstenite variant is loud, both still hold. Tests cover both shapes of a vanished peer, both send-side variants, the ReceivedAfterClosing warn case, and two must-stay-error cases (InvalidOpcode, Io(PermissionDenied)); Level is PartialEq + Debug, so the asserts compile.

The 5-line doc block is now within reason — every sentence is load-bearing (why a vanished peer isn't actionable, why the error must come back through source(), what the fallback is).

Note, not blocking

Two response-dispatch sites still log a departed client at error, in the same class of noise this PR targets but a different mechanism — the per-connection mpsc::Sender<Arc<HttpMessage>>, not the websocket:

  • mod.rs:783error!("Websocket send error. Skipping send for '{:?}' key: ..."). The is_closed() check on line 779 covers most of it, but a client that leaves between that check and the send still lands here.
  • mod.rs:700error!("Websocket send completed message error: ...") has no such guard at all: a cached-complete result delivered to a connection that already ended goes straight to error.

A SendError on that channel only means the connection task exited and dropped the receiver, which is the benign case by construction. Both lines are outside this diff, so I haven't commented inline, and neither undermines the fix — a query completing after its client left is rarer than one disconnect per connection. Worth a follow-up (trace!/debug! for the dropped-receiver case) if the error rate is still above threshold after this ships.

Not verified

I could not run cargo fmt --check or cargo check (both need approval in this environment). Line lengths in the touched closure exceed 100 columns, but so do ~20 pre-existing lines in the same block — rustfmt gives up on this deeply nested on_upgrade(async move |..| { .. }) expression, so the new lines are consistent with what CI already accepts. Worth confirming the Run cargo fmt step in rust-cubestore.yml is green.

· branch [`cubestore-websocket-reset-log-level`](https://github.com/cube-js/cube/tree/cubestore-websocket-reset-log-level)

…_threshold

The test imported a CSV over HTTP from a third-party host, which refuses
automated downloads, so the table was never created and the test failed
wherever egress is not allowed.

The location only ever contributed its size: the threshold under test comes
from `estimate_rows`, which divides the file size by a fixed average row
length without reading the file. Generate the CSV locally instead, with rows
padded so the estimate still lands above the cap being asserted. Both
assertions are unchanged, and the test no longer spends a download and ten
retries getting there.
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 2m 14s —— View job


Re-review after f1a0ef4: test fix is correct and the arithmetic holds — no blocking issues; 1 low (comment/magic numbers), 1 note on scope

Full review
  • Diff the new commit (git show f1a0ef4)
  • Verify the generated CSV still exercises the threshold cap
  • Check local-path LOCATION support and error conversion
  • Confirm the websocket change is untouched by this commit
  • Check existing review threads (both already resolved)
  • Post inline comment

What changed

f1a0ef4 touches only sql/mod.rs: table_partition_split_threshold no longer imports a CSV over HTTP from data.wprdc.org, it generates one in env::temp_dir() and points LOCATION at that path. The websocket work in http/mod.rs is unchanged since 48be719, which I reviewed above; both of my earlier threads are resolved and nothing in this commit reopens them.

Verification

The claim in the commit message is accurate — ImportServiceImpl::estimate_rows (import/mod.rs:849) divides location_file_size by a fixed average_row_length = 256 and never opens the file, so a synthetic file of the right size is a faithful stand-in for the downloaded one.

The generated file still clears the cap with room to spare. Each row is len(id) + 1 + 24 + 1 + 256 + 1 bytes, so 10×284 + 90×285 + 713×286 + 32 header = 232,440 bytes232440 / 256 = 907 estimated rows. table_creator.rs:455 computes (size / sel_workers_count).min(max_partition_split_threshold).max(partition_split_threshold); with the 2 select workers configured at line 4847 that is min(453, 200).max(20) = 200, matching the unchanged assert. The margin is ~2.3× — the test would need to drop below 400 estimated rows to become vacuous, and it would fail loudly rather than silently if it did.

Both assertions still line up with the data: 813 data rows for count(*), and Response ID is int over 0..813 while the two padded columns are text. Config::test sets partition_split_threshold: 20 (config/mod.rs:2131), so the .max() clamp does not interfere.

Mechanics are fine: location_file_size falls through to tokio::fs::metadata(location) for a bare path (import/mod.rs:1007), a local-path LOCATION built from env::temp_dir() is already the established pattern in this file (line 5255), env is in scope, and CubeError: From<std::io::Error> (lib.rs:206) makes the ? on tokio::fs::write compile. The temp file is not removed afterwards, which matches every other CSV-generating test here — not worth changing.

Net effect is a strict improvement: the test no longer depends on a third-party host, drops a download plus its retries, and is hermetic.

Finding

# Severity Location Issue
1 Low mod.rs:4881–4891 5-line comment over 6 lines of code, whose second half restates the constants below it. Only the first sentence is load-bearing (estimate_rows stats but never reads). 256 is not arbitrary — it is exactly estimate_rows's average_row_length, so one padded row ≈ one estimated row — and 813 is what the count(*) assert pins; naming them would carry that better than prose. Commented inline.

Note, not blocking

This commit is unrelated to the PR's subject. The title, description and the other two commits are all about websocket log levels; a flaky-test fix riding along is easy to miss in review and in git log --oneline for the log-level change. Worth a line in the PR description, or splitting it out — your call, and I'd not hold the PR for it.

Also still open from the previous round, unchanged and outside this diff: mod.rs:700 and mod.rs:783 log a dropped per-connection mpsc receiver at error, which is the same class of disconnect noise this PR targets. Follow-up material if the error rate is still above threshold after this ships.

Not verified

I could not run cargo test -p cubestore table_partition_split_threshold, cargo check or cargo fmt --check — all need approval in this environment. The row-size arithmetic above is by hand from the literals in the diff. Worth confirming the cubestore CI job is green.

· branch [`cubestore-websocket-reset-log-level`](https://github.com/cube-js/cube/tree/cubestore-websocket-reset-log-level)

Comment on lines +4881 to +4891
// The threshold is derived from the size of the location,
// not from its contents: see ImportServiceImpl::estimate_rows.
// Rows are padded so the estimate lands above
// max_partition_split_threshold per select worker, which is
// what makes the cap observable.
let path = env::temp_dir().join(format!("{}.csv", test_name));
let padding = "x".repeat(256);
let mut csv = "Response ID,Start Date,End Date\n".to_string();
for id in 0..813 {
csv += &format!("{},2020-01-01T00:00:00.000Z,{}\n", id, padding);
}

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.

The generated file is the right call, and the arithmetic checks out (232,440 bytes → 907 estimated rows → 453 per select worker, comfortably above the 200 cap). Two small things about how that arithmetic is expressed:

The comment is 5 lines for 6 lines of code, and its second half restates what the constants below already do. The load-bearing fact is the first sentence — estimate_rows never opens the file, only stats it — because that is what makes a synthetic file valid here at all. The rest would read better as names on the numbers: 256 is not an arbitrary pad width, it is exactly estimate_rows's average_row_length, so one padded row ≈ one estimated row, and 813 is the row count the count(*) assert below pins. Something like:

// estimate_rows only stats the location, it never reads it.
let rows = 813;
// One padded row is about one estimated row: estimate_rows divides by 256.
let padding = "x".repeat(256);

makes the "813 rows / 2 workers > 200" chain visible without prose. Not blocking either way.

Fix this →

The test imported a CSV over HTTP from a third-party host, which refuses the
download, so the table never became ready and the test ran into the harness
timeout. This is the only test covering the HTTP import path, so the location
has to stay a URL rather than become a local file.

Serve the CSV from a listener the test owns, as the TODO the test carried
asked for. The body is held back for a moment because the test asserts that a
concurrent query cannot see a table whose import is still running, and a local
download would otherwise finish before the query is planned.
The test restated the match arms of the classification it covered, so it could
only ever fail by being edited alongside them.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 3m 22s —— View job


Re-review after e799245 + 880d836: the test server is sound and the third-party host is gone — no blocking issues; 2 low (test-server ergonomics), 1 note on the dropped unit test

Full review
  • Diff the two new commits
  • Verify the local HTTP server satisfies both the HEAD (size estimate) and GET (import) paths
  • Check the concurrency assertion still holds with a locally-served body
  • Confirm the websocket change is untouched since 48be719
  • Assess the removal of websocket_error_levels
  • Check existing review threads, post inline comments

What changed

e799245 replaces the data.wprdc.org download in create_table_with_url with a 77-line TestFileServer in cubestore-sql-tests/src/files.rs that serves one in-memory body over a 127.0.0.1:0 listener. 880d836 deletes the websocket_error_levels unit test. http/mod.rs is otherwise unchanged since 48be719, which I reviewed in the previous round; no earlier finding is reopened.

Verification

The server satisfies both halves of the import path. LocationHelper::location_file_size (import/mod.rs:962) issues a HEAD and reads Content-Length — served immediately, with the correct length, and the amazonaws.com short-circuit at line 971 doesn't fire because Url::domain() is None for an IP literal. try_download_http_location (import/mod.rs:695) then does a plain reqwest::get with no client timeout, so the 500 ms hold is safe, and Connection: close plus an accurate Content-Length means bytes_stream() terminates cleanly. The URL keeps its .csv suffix, so format detection is unaffected.

Keeping the location a URL is the right call — create_table_with_url is the only coverage of the HTTP import branch, and the earlier table_partition_split_threshold fix (f1a0ef4) already moved the other wprdc consumer to a local path. Nothing in the tree references that host any more.

The concurrency assertion is more robust than the 500 ms suggests: if the SELECT reaches the planner before CREATE TABLE registers the table it errors with "not found", and if after, with "not ready" — both satisfy is_err(). The single flip case is the SELECT not completing inside the window on a loaded runner. 813 rows of a fixed shape match the unchanged count(*) assert, and the int/text/text column types match the generated data.

Lifetime handling is correct: url() borrows the live TestFileServer, Drop aborts the accept task, and Duration is already imported in tests.rs:27. Comments in both files are within the 3-line guidance and each states a why — why the body is held back, why HEAD is exempt.

Findings

# Severity Location Issue
1 Low files.rs:92, 96 while let Ok(..) ends the accept loop on the first transient accept error, turning the server into a black hole that surfaces as an import timeout rather than a server failure; and log::error! fires on a benign in-flight disconnect after Drop, which is the same noise class this PR is about. Commented inline.
2 Low tests.rs:2479 The 500 ms download_delay is an unnamed timing window; a oneshot fired from the SELECT branch of the join! would make the "import still running" precondition deterministic. Commented inline.

Note on 880d836

The rationale holds up as far as it goes — the assertions did mirror the match arms one-for-one, and that shape of test can't catch a semantic change in tungstenite itself, only an edit to our own match. But an edit to our own match is the realistic regression: a later refactor that reorders the two Protocol(..) arms, folds Io back into a single arm, or drops AlreadyClosed now lands silently, and the PR description argues for listing ConnectionClosed/AlreadyClosed defensively precisely because a future version bump might route through them. Deleting the only executable statement of that intent is a real, if small, loss for a test that cost nothing to run. Your call — I'd keep it, and I wouldn't hold the PR over it.

Related housekeeping: the PR description's Testing section still cites http::tests::websocket_error_levels as covering the classification, and doesn't mention either of the two flaky-test fixes now riding along. Worth a refresh before merge so the description matches what is being merged.

Still open, unchanged

  • The sql/mod.rs:4891 comment/magic-numbers thread from the previous round still applies to the current code — left unresolved, not re-posted.
  • Outside this diff: http/mod.rs:700 and http/mod.rs:783 still log a dropped per-connection mpsc receiver at error, the same disconnect-noise class this PR targets. Follow-up material.

Not verified

cargo fmt --check, cargo check and cargo test -p cubestore-sql-tests create_table_with_url all require approval in this environment, so the review is by reading. Two things I'd want CI to confirm: that no import in http/mod.rs's tests module went unused when websocket_error_levels was deleted, and that the cubestore SQL-test job passes with the new server.

· branch [`cubestore-websocket-reset-log-level`](https://github.com/cube-js/cube/tree/cubestore-websocket-reset-log-level)

// The body is held back because the query below has to run against a table
// whose import has not finished: only ready tables are visible to the
// planner.
let server = serve_file(csv, Duration::from_millis(500)).await?;

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.

Serving the file ourselves is the right fix, and the assertion is more robust than it looks — if the SELECT is planned before CREATE TABLE registers the table it fails with "not found" rather than "not ready", so either ordering satisfies is_err(). The one way it can flip is the SELECT future not being polled to completion within the 500 ms window on a loaded runner, at which point the import has finished and the table is queryable.

500 ms is probably enough in practice, but the window is a magic number that only the prose above explains. Releasing the body on a signal instead would remove the timing dependency entirely — have serve_file take a oneshot::Receiver, and fire the sender in the SELECT branch of the join! once the query has returned. Failing that, a named constant would at least say what the number is protecting.

Not blocking — the failure mode is a re-run, not a wrong result.

Fix this →

let body = body.clone();
tokio::spawn(async move {
if let Err(e) = serve_one_request(socket, &body, download_delay).await {
log::error!("Test file server: {}", e);

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.

Two small things about the accept loop, neither blocking:

  • log::error! here fires on a benign disconnect too — Drop aborts only the accept task, so an in-flight serve_one_request whose peer went away (the test finished, the importer dropped the connection after a retry) writes to a closed socket and logs an error. Given what this PR is about, debug! seems more consistent; nothing an operator or a CI reader can act on.
  • while let Ok(..) exits the loop on the first accept error, so a transient failure silently turns the server into a black hole and the test then fails as an import timeout rather than as "the server stopped". Errcontinue (or a break with a log) would make that legible.

Fix this →

@waralexrom
waralexrom merged commit fe263b9 into master Sep 9, 2026
74 checks passed
@waralexrom
waralexrom deleted the cubestore-websocket-reset-log-level branch September 9, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cube store Issues relating to Cube Store rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants