Skip to content

fix: clean errors instead of crashes/hangs on unsupported requests; fix SetWatches child-watch restore - #400

Open
lzydmxy wants to merge 5 commits into
JDRaftKeeper:masterfrom
lzydmxy:fix/robust-request-handling
Open

fix: clean errors instead of crashes/hangs on unsupported requests; fix SetWatches child-watch restore#400
lzydmxy wants to merge 5 commits into
JDRaftKeeper:masterfrom
lzydmxy:fix/robust-request-handling

Conversation

@lzydmxy

@lzydmxy lzydmxy commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Which issues of this PR fixes:

Fixes #399

Change log:

Four fixes from a ClickHouse-Keeper parity audit:

  1. A Multi containing an unsupported sub-op no longer aborts the server. StoreRequestMultiTxn threw BAD_ARGUMENTS at Raft apply time, where RequestProcessor::applyRequest catches any exception and calls ::abort() on the whole server process — a remotely triggerable DoS. The validation now records the error instead of throwing, and the Multi answers with a clean ZBADARGUMENTS response.

  2. TTL-mode creates no longer desync the wire stream. ZK 3.5+ TTL create modes (PERSISTENT_WITH_TTL / PERSISTENT_SEQUENTIAL_WITH_TTL) append an int64 ttl after the flags. RaftKeeper didn't read it, so the node was silently created as plain persistent AND the next request on the connection parsed garbage. The ttl is now consumed and the mode rejected with ZUNIMPLEMENTED (stream stays aligned); CONTAINER and unknown create modes are rejected with ZBADARGUMENTS, matching ClickHouse Keeper.

  3. Unknown opnums get a clean error reply. Reconfig(16)/Create2(15)/CreateTTL(21) threw from getOpNum/toString inside the receive path and the request was silently dropped — the client hung until its timeout. The connection now replies with a clean error response and stays usable.

  4. SetWatches(101) restores child and exist watches correctly. The node-info map was built from data_watches only, so every re-established child watch was treated as "node missing": it spuriously fired DELETED and dropped the watch, and exist watches never fired CREATED. The map now covers all three watch arrays. Also fixes a latent deadlock the now-reachable firing path exposed (processRequestSetWatch held watch_mutex while calling processWatches, which locks it again — registration and firing are now separated), and makes CHILD events fire list watches instead of misdelivering to data watches.

Tests:

  • 3 new unit tests: Multi rejection without abort, TTL/container wire rejection without desync, SetWatches restore semantics (CHILD fired when pzxid moved, silent re-register when unchanged, no spurious DELETED).
  • 3 new wire-level integration tests in test_session_fake_client: unknown opnum error reply + connection survival, TTL create rejection + stream alignment, bad Multi error reply + server survival (a quorum write right after proves the server didn't die).
  • Local runs: 65/65 unit tests, 6/6 test_session_fake_client, 17/17 test_back_to_back.

@raftkeeper-robot

raftkeeper-robot Bot commented Aug 17, 2026

Copy link
Copy Markdown

Unit test report for commit d45dfb0.

All test cases passed!

Successful Test Cases
Classname Name Sanitize Type Status Error Message
OperatorsManipTest EscapingTest
OperatorsManipTest QuouteTest
OperatorsManipTest DoubleQuouteTest
OperatorsManipTest binary
Logger Log
Common PODArrayInsert
Common PODPushBackRawMany
Common PODNoOverallocation
Common PODArrayInsertWithIllegalPadding
Common unescapeForFileName
ShellCommand Execute
ShellCommand ExecuteDirect
ShellCommand ExecuteWithInput
ShellCommand AutoWait
ThreadPool ConcurrentWait
ThreadPool GlobalFull1
ThreadPool GlobalFull2
ThreadPool ThreadRemoval
ThreadPool Loop
ThreadPool ExceptionFromSchedule
RaftLog writeAndReadUInt32
RaftLog serializeStr
RaftLog serializeRaw
RaftLog serializeEntry
RaftLog parseLogEntrybody
RaftLog appendEntry
RaftLog appendEntries
RaftLog loadLog
RaftLog splitSegment
RaftLog removeSegment
RaftLog truncateLog
RaftLog writeAt
RaftLog compact
RaftLog getEntry
RaftLog getEntries
RaftLogZstd codecRoundTrip
RaftLogZstd appendAndReadZstdEntries
RaftLogZstd mixedCodecReadback
RaftPerformance appendLogPerformance
RaftPerformance appendLogThread
RaftPerformance machineCreate
RaftSnapshot parseAndSerializeKeeperNode
RaftSnapshot createSnapshot_1
RaftSnapshot createSnapshot_2
RaftSnapshot readAndSaveSnapshot
RaftSnapshot parseSnapshot
RaftSnapshot parseIncompleteSnapshot
RaftSnapshot createSnapshotWithFuzzyLog
RaftSnapshot CorruptLatestSnapshotFallsBackToOlder
RaftStateMachine serializeAndParse
RaftStateMachine appendEntry
RaftStateMachine modifyEntry
RaftStateMachine createSnapshot
RaftStateMachine syncSnapshot
RaftStateMachine initStateMachine
RaftStateMachine MultiReadDoesNotIncreaseZxid
RaftStateMachine MultiReadRegistersSubrequestWatches
RaftStateMachine MultiReadHandlesIndividualErrors
RaftStateMachine MultiReadRejectsWriteOps
RaftStateMachine MultiReadExistsWatchOnNonExistentNode
RaftStateMachine MultiReadAuthCheckPerSubrequest
RaftStateMachine RemoveRecursive
RaftStateMachine TryRemove
RaftStateMachine CheckStat
RaftStateMachine ListRecursive
RaftStateMachine FilteredListWithStatsAndData
RaftStateMachine MultiWriteWithCheckStatAndTryRemove
RaftStateMachine MultiRemoveRecursiveRollback
RaftStateMachine MultiRejectsUnsupportedSubOpWithoutAbort
RaftStateMachine SetWatchesRestoresChildAndExistWatches
ZooKeeperCreateRequest RejectsTTLAndContainerModesWithoutWireDesync
RaftStateManager load_srv_state
SnapshotZstdBench DISABLED_V2vsV3
ZstdLevelBench DISABLED_AllLevels

@raftkeeper-robot

raftkeeper-robot Bot commented Aug 17, 2026

Copy link
Copy Markdown

Integration test report for commit d45dfb0.

All test cases passed!

Successful Test Cases
Classname Name Sanitize Type Status Error Message
test_auth test_digest_auth_basic[get_genuine_zk]
test_auth test_digest_auth_basic[get_fake_zk]
test_auth test_no_auth[get_genuine_zk]
test_auth test_no_auth[get_fake_zk]
test_auth test_super_auth
test_auth test_digest_auth_multiple[get_genuine_zk]
test_auth test_digest_auth_multiple[get_fake_zk]
test_auth test_partial_auth[get_genuine_zk]
test_auth test_partial_auth[get_fake_zk]
test_auth test_bad_auth
test_auth test_auth_snapshot
test_auth test_get_set_acl[get_genuine_zk]
test_auth test_get_set_acl[get_fake_zk]
test_back_to_back test_simple_commands
test_back_to_back test_sequential_nodes
test_back_to_back test_stats
test_back_to_back test_watchers
test_back_to_back test_multi_transactions
test_back_to_back test_filtered_list
test_back_to_back test_multi_read
test_back_to_back test_create_if_not_exists
test_back_to_back test_check_if_not_exists
test_back_to_back test_random_requests
test_back_to_back test_end_of_session
test_back_to_back test_end_of_watches_session
test_back_to_back test_concurrent_watches
test_back_to_back test_system_nodes
test_back_to_back test_unregister_watch
test_back_to_back test_multi_read_zxid_stability
test_back_to_back test_multi_read_subrequest_watch
test_back_to_back test_remove_recursive
test_back_to_back test_try_remove
test_back_to_back test_list_recursive
test_back_to_back test_check_stat
test_back_to_back test_filtered_list_with_stats_and_data
test_back_to_back test_try_remove_fires_watch
test_back_to_back test_remove_recursive_fires_watches
test_back_to_back test_remove_recursive_limit
test_back_to_back test_list_recursive_max_entries
test_back_to_back test_multi_remove_recursive_rollback
test_converter test_smoke[True]
test_converter test_smoke[False]
test_converter test_simple_crud_requests[True]
test_converter test_simple_crud_requests[False]
test_converter test_multi_and_failed_requests[True]
test_converter test_multi_and_failed_requests[False]
test_corruption_recovery test_snapshot_corruption_fallback
test_corruption_recovery test_log_tail_corruption_recovery
test_create_snapshot_on_exist test_create_snapshot_on_exist
test_forward_expiration test_forward_expiration
test_four_word_command test_cmd_ruok
test_four_word_command test_cmd_mntr
test_four_word_command test_cmd_srst
test_four_word_command test_cmd_conf
test_four_word_command test_cmd_isro
test_four_word_command test_cmd_srvr
test_four_word_command test_cmd_stat
test_four_word_command test_cmd_cons
test_four_word_command test_cmd_crst
test_four_word_command test_cmd_dump
test_four_word_command test_cmd_wchs
test_four_word_command test_cmd_wchc
test_four_word_command test_cmd_wchp
test_four_word_command test_cmd_csnp
test_four_word_command test_cmd_lgif
test_four_word_command test_cmd_rqld
test_four_word_command test_white_list
test_learner test_read_write_multinode
test_learner test_watch_on_follower
test_learner test_session_expiration
test_learner test_follower_restart
test_learner test_simple_sleep_test
test_learner test_stop_learner
test_log_compression test_write_and_read_with_zstd
test_log_compression test_zstd_survives_hard_restart
test_log_compression test_zstd_multiple_restarts
test_multinode_simple test_read_write_multi_node
test_multinode_simple test_watch_on_follower
test_multinode_simple test_session_expiration
test_multinode_simple test_follower_restart
test_multinode_simple test_simple_sleep_test
test_nodes_add test_nodes_add
test_nodes_remove test_nodes_remove
test_nodes_replace test_node_replace
test_non_snapshot_restart test_non_snapshot_restart
test_persistent_log test_open_and_closed_log_segment
test_persistent_log test_state_after_restart
test_persistent_log test_state_duplicate_restart
test_persistent_log test_ephemeral_after_restart
test_persistent_log_multinode test_restart_multi_node
test_restore_from_snapshot test_recover_from_snapshot
test_session test_reconnection
test_session_fake_client test_session_timeout
test_session_fake_client test_session_max_min_session_timeout
test_session_fake_client test_invalid_timeout_setting
test_session_fake_client test_unknown_opnum_gets_error_and_connection_survives
test_session_fake_client test_ttl_create_rejected_and_stream_stays_aligned
test_session_fake_client test_multi_with_unsupported_subop_returns_error_and_server_survives
test_session_fake_client test_multiread_with_write_subop_returns_per_position_error
test_session_fake_client test_set_watches_restore_fires_correct_events
test_snapshot_compression test_snapshot_written_as_v3
test_snapshot_compression test_restore_purely_from_zstd_snapshot
test_snapshot_compression test_zstd_snapshot_survives_hard_restart
test_snapshot_restart test_snapshot_restart[False]
test_snapshot_restart test_snapshot_restart[True]
test_snapshot_small_distance test_snapshot_and_load[False]
test_snapshot_small_distance test_snapshot_and_load[True]
test_snapshots test_state_after_restart[node0]
test_snapshots test_state_after_restart[node1]
test_snapshots test_ephemeral_after_restart[node0]
test_snapshots test_ephemeral_after_restart[node1]
test_snapshots test_restart_with_no_log[node0]
test_snapshots test_restart_with_no_log[node1]
test_snapshots test_snapshot_clear[node0]
test_snapshots test_snapshot_clear[node1]
test_snapshots_multinode test_restart_multinode[False]
test_snapshots_multinode test_restart_multinode[True]
test_stale_node_recovery test_stale_node_recovery
test_three_nodes_two_alive test_start_offline
test_three_nodes_two_alive test_start_non_existing
test_three_nodes_two_alive test_restart_third_node
test_two_nodes_cluster test_read_write_two_nodes
test_two_nodes_cluster test_read_write_two_nodes_with_blocked
test_znode_time test_between_servers
test_znode_time test_server_restart

@lzydmxy lzydmxy added the bug Something isn't working label Aug 18, 2026
undefined and others added 3 commits August 20, 2026 11:45
…ix SetWatches child-watch restore

Four fixes from a ClickHouse-Keeper parity audit:

1. A Multi containing an unsupported sub-op (GetACL, Sync, mixed read/write,
   ...) threw BAD_ARGUMENTS at Raft apply time, where
   RequestProcessor::applyRequest catches any exception and calls ::abort()
   on the whole server process — a remotely triggerable DoS. The validation
   now records the error instead of throwing, and the Multi answers with a
   clean ZBADARGUMENTS response.

2. ZK 3.5+ TTL create modes (PERSISTENT_WITH_TTL / PERSISTENT_SEQUENTIAL_
   WITH_TTL) append an int64 ttl after the flags. RaftKeeper didn't read it,
   so the node was silently created as plain persistent AND the next request
   on the connection parsed garbage. The ttl is now consumed and the mode
   rejected with ZUNIMPLEMENTED (stream stays aligned); CONTAINER and unknown
   create modes are rejected with ZBADARGUMENTS, matching ClickHouse Keeper.

3. Unknown opnums (Reconfig 16, Create2 15, CreateTTL 21, ...) threw from
   getOpNum/toString inside the receive path and the request was silently
   dropped — the client hung until its timeout. The connection now replies
   with a clean error response and stays usable.

4. SetWatches(101) built its node-info map from data_watches only, so every
   re-established child watch was treated as "node missing": it spuriously
   fired DELETED and dropped the watch, and exist watches never fired
   CREATED. The map now covers all three watch arrays. Also fixes a latent
   deadlock the now-reachable firing path exposed (processRequestSetWatch
   held watch_mutex while calling processWatches, which locks it again), and
   makes CHILD events fire list watches instead of misdelivering to data
   watches.

Tests: 3 new unit tests (Multi rejection without abort, TTL/container wire
rejection without desync, SetWatches restore semantics) and 3 new wire-level
integration tests in test_session_fake_client (unknown opnum error reply +
connection survival, TTL create rejection + stream alignment, bad Multi
error reply + server survival).

Co-Authored-By: Claude <noreply@anthropic.com>
…g; SetWatches fires only the restoring session

Code-review fixes on top of the tranche-1 changes:

1. MultiRead with a non-read sub-op threw BAD_ARGUMENTS at apply time; read-path
   exceptions produce no response, so the client hung until its timeout. It now
   answers with a clean per-subrequest ZBADARGUMENTS error at the offending
   position (the valid sub-ops still execute), matching the clean-error
   behavior of the Multi write path. Updated MultiReadRejectsWriteOps to
   assert the wire-visible behavior instead of the throw.

2. SetWatches restore triggers went through the global processWatches, which
   broadcasts events to every session watching the path and cascades to the
   parent path's list watches (e.g. an exist-watch restore firing CREATED also
   consumed the parent's children watch). ZooKeeper's DataTree.setWatches fires
   only the re-registering session's own watcher. Added a session-scoped
   triggerWatchForSession that fires and unregisters only that session's watch
   with no parent cascade; the unit test now also asserts fired events only
   ever go to the restoring session and that another session's watch survives.

Co-Authored-By: Claude <noreply@anthropic.com>
…es restore

Covers the review fixes with end-to-end wire tests in test_session_fake_client:

- test_multiread_with_write_subop_returns_per_position_error: a MultiRead with
  [Create, Exists] sub-ops answers per-subrequest errors (ZBADARGUMENTS at the
  bad position, the valid Exists still runs) and the connection stays usable.
- test_set_watches_restore_fires_correct_events: raw SetWatches restores fire
  CHILD (not the pre-fix spurious DELETED) when pzxid moved, silently
  re-register when unchanged, fire CREATED for exist watches on existing
  nodes, and only ever deliver to the re-registering session — no parent-path
  cascade, no cross-session broadcast (a second raw session on the same node
  watches the same path and must receive nothing).

Also makes recv_reply buffered per socket: the server may coalesce several
replies into one TCP segment, and the previous helper dropped the second
frame.

Co-Authored-By: Claude <noreply@anthropic.com>
@lzydmxy
lzydmxy force-pushed the fix/robust-request-handling branch from 04a3bcf to d4aa4d8 Compare August 20, 2026 03:51
@JackyWoo

Copy link
Copy Markdown
Contributor

Review of PR #400

I did a full review of this PR: built it (clang-18 / Ninja / RelWithDebInfo), ran the complete unit test suite (72/72 pass, including the 3 new tests), ran all 5 new wire-level integration tests (all pass), and re-ran the existing watch-related suites (test_watchers, test_concurrent_watches, test_unregister_watch, test_multi_read_subrequest_watch, test_try_remove_fires_watch, test_remove_recursive_fires_watches, test_watch_on_follower, test_session_timeout, test_session_max_min_session_timeout) — no regressions.

Verified

  • The applyRequest::abort() path on write exceptions is real, and the construction_error approach correctly prevents the remotely-triggerable server abort.
  • The TTL int64 consumption before throwing is correct and essential for stream alignment; the raw-wire test proves it end to end.
  • The SetWatches fix (building watch_nodes_info from all three watch arrays) is correct — child/exist watches were previously always treated as "node missing".
  • The register-then-trigger split in processRequestSetWatch is a necessary fix for a real deadlock (watch_mutex is non-recursive and processWatches re-locks it).
  • triggerWatchForSession (session-scoped, no parent cascade) correctly matches ZooKeeper's DataTree.setWatches semantics; the test explicitly asserts a second session's watch survives untouched.
  • Error-handling routing in receiveRequest is sound: Coordination::Exception gets an error reply; TIMEOUT_EXCEEDED from pushRequest (an RK::Exception, caught by the outer catch (const Exception &)) still destroys the connection as before. No double-response path.

Issue found — one fix requested

1. StoreRequestMultiTxn::process() early-return leaves default sub-responses when construction_error is set

src/Service/KeeperStore.cpp, in the construction_error != ZOK early return:

if (construction_error != Coordination::Error::ZOK)
{
    response_typed.error = construction_error;
    return {response, {}};
}

The ZooKeeperMultiResponse constructor pre-fills responses[] via each sub-request's makeResponse(). On this early return the top-level error is ZBADARGUMENTS but every per-subrequest slot still holds its default-constructed ZOK sub-response with an empty body. The normal rollback path a few lines below overwrites all slots with ZooKeeperErrorResponse (real error at position i, ZRUNTIMEINCONSISTENCY after) — that's the shape ZooKeeper clients expect. The integration test only asserts the top-level err == -8, so it doesn't catch this.

Suggested fix — reuse the existing rollback pattern:

if (construction_error != Coordination::Error::ZOK)
{
    for (size_t j = 0; j < response_typed.responses.size(); ++j)
    {
        response_typed.responses[j] = std::make_shared<Coordination::ZooKeeperErrorResponse>();
        response_typed.responses[j]->error = construction_error;
    }
    response_typed.error = construction_error;
    return {response, {}};
}

Severity: medium — the crash is fixed (primary goal), but the wire response shape is wrong and may confuse strict clients.

Minor / optional

2. WatchManager::processWatches CHILD-event guard: in the current codebase processWatches is only ever called with CREATED/DELETED/CHANGED, never CHILD, so the event_type != CHILD guard is currently defensive-only. Fine to keep — just noting it's not fixing an observed misfire.

3. BAD_ARGUMENTS is now unused in KeeperStore.cpp (style check flags it). The extern const int BAD_ARGUMENTS; at the top can be dropped; the error code itself is still used elsewhere (Metrics.cpp, Jemalloc.cpp).

4. NIT: the module-level _recv_buffers dict in test_session_fake_client/test.py works correctly (per-socket buffering is necessary since the server coalesces replies), but a one-line note that tests in this file don't run concurrently would help future readers.

Verdict

Approve with finding 1 requested before merge — it's a small, localized fix reusing an existing pattern in the same function. Everything else is solid: the commit messages are exemplary, the deadlock fix is real, the session-scoped watch semantics are correct, and the raw-wire integration tests are exactly the right level (they prove server survival and stream alignment, which unit tests can't).

…e the per-op body

JackyWoo review on JDRaftKeeper#400: the construction_error early-return set the
top-level response error to the rejection code directly. ZooKeeperResponse::
writeNoCopy only serializes the per-subrequest body when the top-level error
is ZOK, so a non-ZOK top-level error here silently suppressed the body
entirely — the client would see an empty multi response instead of the
per-op errors the wire format is supposed to carry (the reviewer's suggested
snippet populated the sub-response array but kept the top-level error
non-ZOK, which would still have been a no-op on the wire).

Fixed to match the existing rollback path in the same function: leave the
top-level error at its ZOK default and populate every sub-response with the
rejection error, so writeImpl actually runs and the body carries the real
per-op errors. Also confirmed this doesn't trip the write-path's watch-firing
check, which gates on the *last* sub-response's error, not the top-level one.

Updated the two unit tests and the raw-wire integration test that asserted
the old (incorrect) top-level-error shape to instead check the per-op body,
matching the sibling MultiRead test added in the same PR.

Also drops the now-unused BAD_ARGUMENTS extern declaration (flagged by
style-check).

Co-Authored-By: Claude <noreply@anthropic.com>
@lzydmxy

lzydmxy commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@JackyWoo Finding 1 confirmed real, fixed in 36f2731 — but not with the exact snippet suggested, since that snippet wouldn't have changed anything on the wire.

Traced the actual send path (ZooKeeperResponse::writeNoCopy, ConnectionHandler.cpp:327):

Coordination::write(error, out);
if (error == Error::ZOK)
    writeImpl(out);   // this is what serializes response_typed.responses[]

writeImpl — which is what serializes the per-subrequest array — only runs when the top-level error is ZOK. The suggested fix populated response_typed.responses[] correctly but still set response_typed.error = construction_error (non-ZOK), so writeImpl would still never run and the populated array would never reach the wire. Same bytes as before: header error = ZBADARGUMENTS, empty body.

Fix instead leaves the top-level error at its ZOK default (matching the existing rollback loop a few lines below, which never touches response_typed.error either) and populates every sub-response with the rejection error. That's what actually makes writeImpl run and put real per-op errors in the body — matching real ZooKeeper's multi wire contract (header err=OK, per-op errors in the body) and the shape your review correctly identified as missing.

Also verified this doesn't accidentally trigger watch-firing for the rejected multi: that check gates on responses.back()->error == ZOK, and every sub-response (including the last) now carries the non-ZOK rejection error, so it stays gated off.

Updated the two unit tests and the raw-wire integration test that had asserted the old top-level-error shape — they now check the per-op body, matching the sibling MultiRead test. Dropped the unused BAD_ARGUMENTS extern per your note 3.

72/72 unit, 35/35 integration (fake-client + back-to-back) after the fix.

…s full-suite CI run

Root-caused each rather than blanket-raising timeouts:

- test_random_requests, test_concurrent_watches: inherently long-running
  (thousands of round trips / sleep-paced concurrent workload) and already
  minimized where possible; the 300s pytest-timeout default doesn't leave
  headroom under a loaded CI runner. Given explicit per-test overrides via
  @pytest.mark.timeout(600) instead of cutting coverage further.

- test_node_replace: the write through zk_conn4 (a node that just joined via
  live 4-node reconfig) may need an extra forwarding hop while the new leader
  settles; the default 10s kazoo session timeout was too tight for that under
  load. Bumped to 30s for that connection only.

- test_snapshot_and_load[True]: the final unguarded create() lands right
  after 1000 rapid creates across a freshly-restarted 3-node cluster: a
  transient timeout there is the same class of hiccup the preceding loop
  already tolerates via try/except. Gave it the same tolerance (a few
  retries) instead of leaving it as the one fragile call in the function.

- test_between_servers, test_server_restart: 1000 children x (create + set +
  3 reads) is 5000+ round trips per test. Reduced to 100, matching the
  existing precedent in test_random_requests — same assertions, same
  cross-server consistency coverage, far fewer round trips.

Verified locally: all 6 tests pass individually, plus a full
test_back_to_back regression run (27/27) since two of its tests changed.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix: bad Multi aborts server, TTL create desyncs wire, unknown opnums hang, SetWatches drops child watches

2 participants