Skip to content

redis: with the disable-auto-pipelining flag, send one command at a time unless the client sets enableAutoPipelining - #46382

Open
robobun wants to merge 3 commits into
mainfrom
robobun/8f943236/redis-auto-pipelining-flag
Open

robobun wants to merge 3 commits into
mainfrom
robobun/8f943236/redis-auto-pipelining-flag

Conversation

@robobun

@robobun robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • BUN_FEATURE_FLAG_DISABLE_REDIS_AUTO_PIPELINING=1 does nothing for Bun.redis, new RedisClient() and new RedisClient(url). Without an options object a client takes Options::default(), which hardcodes true (src/runtime/valkey_jsc/js_valkey.rs:640).
  • With an options object the client still batches. Four GETs in one tick go out as 1, then 3 (send_next_command, src/runtime/valkey_jsc/valkey.rs:706).

Fix

  • Options::from_js now builds the options of every client. With the flag, a client that does not set enableAutoPipelining gets one_command_at_a_time. Meta::check then marks none of its commands pipelineable, so each goes out only when in_flight is empty, like INFO.
  • When a ready connection drops, such a client also rejects its queue. No part of a burst runs on the next connection.
  • Verified: test/js/valkey/reliability/connection-failures.test.ts (3 new tests, 2 fail on main). Other suites: Notes.
  • Self-reviewed in two rounds: 52 concerns, 49 addressed, 2 in part, 1 rejected (Notes).

Background

Downsides

  • An explicit enableAutoPipelining still overrides the flag, and false still batches. Notes has two questions for a maintainer.
  • With the flag, a drop also rejects a never-batched command that waits and what follows it. A default client keeps those.
  • ValkeyClient grows from 320 to 328 bytes.
Notes

Origin. There is no issue. I found the defect by reading the code. The flag has had this split since #18812 added the client. Closed #34829 carried a flag read in Options::default() (3877aff) with no test.

Who changes. Only a client that does not set enableAutoPipelining, in a process with the flag. What the server reads for four GETs issued in one tick on an idle connection:

Client No flag (main and this PR) Flag, main Flag, this PR
Bun.redis, new RedisClient(), (url), (url, undefined), (url, null), .duplicate() one batch one batch one at a time
(url, {}), (url, { enableAutoPipelining: undefined }) one batch 1, then 3 one at a time
(url, { enableAutoPipelining: false }), (url, { enableAutoPipelining: null }) 1, then 3 1, then 3 1, then 3
(url, { enableAutoPipelining: true }) one batch one batch one batch

Questions for a maintainer.

  1. Should enableAutoPipelining: false change too? Three meanings are possible.
    • As today: the first command of a burst goes out alone, then the rest as one batch.
    • One command at a time, like the flag. It is a few more lines, and it needs the drop rule of this PR. I built and measured it. For 100 flows of 5 GETs on one shared client, the 500 GETs then take 500 server reads, and 4 ms becomes about 170 ms on loopback here. The tracker has one statement of what users expect, the workaround in [CRITICAL] redis client response mismatch #27861: "Disable auto-pipelining to limit in_flight to at most one entry". Userland can do the same today with a promise chain:
      let tail = Promise.resolve()
      const one = fn => (tail = tail.then(fn, fn))
    • Write each command at once, with no batch and no wait. ioredis, where the option name comes from, does that when the option is false.
  2. Should the flag override an explicit enableAutoPipelining? On main the option wins, and this PR keeps that. So a client with true does not see the flag, and a client with false keeps the mode of main.

Also in this PR.

  • impl Default for valkey::Options is gone. Options::from_js holds the defaults and is the only builder.
  • js_send called Meta::check a second time, with the same result. That call is gone. ValkeyClient::send is the only caller.
  • The third commit deletes six JSDoc sentences in packages/bun-types/redis.d.ts. They said that subscribe() turns auto-pipelining off and that unsubscribe() turns it on again. No build does that: a subscribed client writes four ping() calls of one tick as one batch, on main, on this PR and on 1.4.2.

One command at a time, in detail.

  • SUPPORTS_AUTO_PIPELINING is a bit on each command, and Meta::check sets it. The queue writes a command without the bit only when in_flight is empty. INFO, MULTI, EXEC and WATCH take that path on main.
  • The handshake does not go through the queue. With a database in the URL, the client writes HELLO and SELECT in one write, with and without the flag.
  • A subscribe() of several channels leaves in_flight at its first confirmation. The next command goes out then, before the other confirmations. Main does the same for a never-batched command.
  • A command that the server does not answer (CLIENT REPLY OFF) stops the client until close(), a connection drop, or the idle timeout if idleTimeout is set. Main does the same for INFO and for a client with enableAutoPipelining: false.
  • A reply that the client reads while it still writes the command also stops the client, because nothing runs the queue when the last bytes go out. It needs a command too large for one write and a peer that answers early and keeps the connection open. redis-server closes the connection after such a reply. Main does the same for a client with enableAutoPipelining: false.
  • The queue has no bound. Above one command per round trip it grows.

The connection drop.

  • When a connection drops and the client is going to reconnect, main rejects the commands in flight and keeps the queue for the next connection.
  • A one-at-a-time client has at most one command in flight. Without the second commit it sent the rest of a burst on the next connection, and the body of a MULTI ran outside its transaction.
  • Now such a client rejects its queue when the connection was ready (HELLO and SELECT answered). ValkeyClient::on_close gets the status that the connection had. The client keeps its queue across a dial that fails and across a drop in the handshake, as on main.
  • For a burst of commands that can be batched, on a ready connection, the promises equal those of a default client.
  • Three differences remain. A default client keeps a never-batched command that waits (INFO, EXEC), and what follows it, for the next connection. A client with enableAutoPipelining: false keeps everything behind the first command of a burst. At a drop before the reply to the SELECT of a URL database, a default client rejects the burst that it has in flight, and a one-at-a-time client keeps its burst.

Left alone on purpose.

  • The note in Entry::create about a check call there. Remove dead code from the bundler, the resolver, bun_css, the Valkey client, and six smaller areas #45723 edits the same lines.
  • Two branches of the queue code that do nothing on main: the second flusher registration in ValkeyClient::send, and the drain() call with an empty queue in send_next_command.
  • ConnectionFlags::default() still has enable_auto_pipelining: true. Both construction sites set the field.
  • SubscriptionCtx saves and restores enable_auto_pipelining, and nothing changes the value between the two.
  • A client that is not one-at-a-time keeps its queue across a reconnect, also with enableOfflineQueue: false, as on main.
  • The comment on the flag in src/bun_core/env_var.rs. It is still true, and an edit there rebuilds every crate that depends on bun_core.

Tests.

  • 3 new tests in describe("Valkey: Auto-Pipelining"). Each runs the client in a child process (two with the flag, one without) against a mock RESP server that logs the order of commands and replies.
    • 13 constructor shapes with the flag, and the same 13 without it.
    • 14 queue states with the flag: the continuation of connect(), the offline queue, a database in the URL, onconnect, a never-batched command, an error reply, an 8 MB command, a command issued from the continuation of a reply, and 6 connection drops (under a burst, with enableOfflineQueue: false, in the body of a MULTI, after an early error reply to a large command, before the connection is ready, and for a client that sets the option).
  • A debug (ASAN) build and a release build of main fail the two flag tests and pass the no-flag test. The debug build and the release build of this PR pass all three (6 runs and 10 runs).
  • The whole file in the default environment here (no Docker): 49 pass, 13 skip, 1 todo, 0 fail.
  • test/js/valkey/ (15 files) on the debug build of this PR with a 120 s timeout, against a local redis-server 8.0.2 with the TLS and ACL files of the repo: 1341 pass, 3 skip, 1 todo, 2 fail. The 2 failures assert the error text of another server version. They fail on main in the same way.
  • test/integration/bun-types/bun-types.test.ts: 24 pass.

Measurements. Release builds of main ea6a2c7 and of this change on top of it, linux-x64. Tools: gdb, nm, size, llvm-objdump. perf, valgrind, strace and bloaty are not installed here. "Option-less" means a client that does not set enableAutoPipelining.

  • Flag reads: 1 flag getter call per option-less construction (main: 0), 0 per command (main: 0), 1 getenv per process (gdb hit counts, 1000 constructions and 10000 GETs).
  • Instructions per pipelined GET in JSValkeyClient::send, default client: 2297 -> 2291. First in a tick: 2434 -> 2428 (gdb stepi, 3 runs agree). mi_malloc inside send: 1 -> 1.
  • Queue service, default client: send_next_command 55 -> 55 instructions per batch-final reply and 30 -> 30 per mid-batch reply. Flush of 4 / 8 commands: 643 / 1059 -> 643 / 1059.
  • Disassembly of send_next_command, drain, the flusher trampoline, handle_response and on_data: the same instructions in the same order on both builds (120 / 183 / 216 / 1216 / 294). With addresses masked, 6 / 1 / 4 / 26 / 0 lines differ, each in one field offset. The new bool moved the fields behind it.
  • Default client, 250 bursts of 4 GETs: deferred-task registrations 250 -> 250. estimateShallowMemoryUsageOf(client) after a burst of 8: 1120 -> 1128 bytes.
  • sendto per 4-GET burst (gdb catch syscall): default 1 -> 1. enableAutoPipelining: false 2 -> 2. Bun.redis with the flag 1 -> 4. Explicit true with the flag 1 -> 1.
  • One shared client, 100 flows of 5 GETs, server reads that carry a GET: option-less with the flag 9 to 23 -> 500 in every run. For every client that batches, the count is 8 to 61 on both builds, and it depends on how the reads fall.
  • The same flows on a loopback redis-server 8.0.2, medians of 10 interleaved runs: option-less with the flag 5.74 ms -> 168.13 ms. One burst of 20000 GETs: 19.77 ms -> 3219.74 ms. enableAutoPipelining: false: 4.56 ms -> 4.26 ms (second main series: 3.40 ms). One round trip costs about 0.34 ms in this container.
  • Default-mode throughput, 1000 x Promise.all of 100 / 1000 GETs, medians of 10 interleaved runs: 392.45 / 970.64 ms -> 383.34 / 986.93 ms. Second main series: 400.26 / 966.27 ms. No difference outside the spread of the runs.
  • Release binary text: 107094269 -> 107094781 bytes (size). JSValkeyClient::send 4602 -> 5093 bytes, and the standalone Meta::check (605 bytes) is gone. create_no_js_no_pubsub 7624 -> 7755. on_close 1508 -> 1716 (nm -S).
  • sizeof: ValkeyClient 320 -> 328, ConnectionFlags 9 -> 10 (gdb, debug builds).
  • Ordered wire log, 52 probe rows: all 18 rows with the flag and no enableAutoPipelining option become one command per server read. The other 34 rows do not change.
  • redis-server 8.0.2 with proto-max-bulk-len 1mb, then MULTI, SET of 32 MB, INCR, EXEC in one tick. The server answers the SET with an error and closes. With the flag and without it, the INCR rejects and the counter stays unset.
  • MULTI, 3 INCR, EXEC with a whole-transaction retry and a drop at the 1st, 2nd or 3rd INCR, flag set: the counter ends at 3 in 9 of 9 rows.

Self-review. Two rounds.

  • The first version also made enableAutoPipelining: false send one command at a time. The first round asked to take that out: nobody asked for it, it costs users of the option one round trip per command, and the same queue then sent commands again after a drop. That is question 1.
  • The second round found that the drop rule looked only at the command in flight. A reply that empties in_flight before the close let the queue through.
  • In part: the first round asked for a tracking issue for the command without a reply, and this PR only names it above. It also asked on_close to reject the queue of every client with enableOfflineQueue: false, and this PR does that for one-at-a-time clients only.
  • Rejected: the removal of the note in Entry::create.

Open PRs in the same functions. #41418 and #42054 conflict with this branch in js_valkey.rs. Both conflict with main already. #38897, #45723, #41967 and #41645 merge cleanly. None of them fixes this.

…ime unless the client sets enableAutoPipelining

BUN_FEATURE_FLAG_DISABLE_REDIS_AUTO_PIPELINING had no effect on Bun.redis,
new RedisClient() and new RedisClient(url). Only the options parser read it,
and a client with no options object took a default that hardcoded
auto-pipelining on. Where the flag was read, the client still sent batches:
the first command of a burst went out alone and the rest in one write.

Options::from_js now builds the options of every client, with or without an
options object, so impl Default for valkey::Options is gone. With the flag
set, a client that does not set enableAutoPipelining sends one command at a
time: Meta::check gives none of its commands SUPPORTS_AUTO_PIPELINING, so
the client writes each one only when no command is in flight, as it does
for INFO. A client that sets enableAutoPipelining, to either value, behaves
as before. So does a process without the flag.

js_send no longer calls Meta::check. ValkeyClient::send already checks
every command.
…on drops

When a connection drops and the client is going to reconnect, it rejects
the commands in flight and keeps its queue for the next connection. A client
under BUN_FEATURE_FLAG_DISABLE_REDIS_AUTO_PIPELINING has at most one command
in flight and queues the rest of a burst. It sent that rest on the next
connection, where the body of a MULTI runs outside its transaction.

ValkeyClient::on_close now gets the status that the connection had. If the
connection was ready (HELLO and SELECT answered), such a client rejects its
queue too. It keeps the queue across a dial that fails and across a drop in
the handshake, as before. A client that sets enableAutoPipelining and a
process without the flag do not change: they keep a command that waits, for
the next connection.
…lining off

Six sentences said that subscribe() disables auto-pipelining and that
unsubscribe() enables it again. No build does that. A subscribed client
still writes four ping() calls of one tick as one batch.
@robobun
robobun requested a review from alii as a code owner October 21, 2026 18:15
@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 9 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 4e8b98bc-4d04-4fc7-a9a6-c02e4dae9fb7
📥 Commits

Reviewing files that changed from the base of the PR and between 9634520 and 567f909.

📒 Files selected for processing: 6
  • packages/bun-types/redis.d.ts
  • src/runtime/valkey_jsc/ValkeyCommand.rs
  • src/runtime/valkey_jsc/js_valkey.rs
  • src/runtime/valkey_jsc/js_valkey_functions.rs
  • src/runtime/valkey_jsc/valkey.rs
  • test/js/valkey/reliability/connection-failures.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status

How to reproduce. Save the script as repro.mjs. Run BUN_FEATURE_FLAG_DISABLE_REDIS_AUTO_PIPELINING=1 bun repro.mjs.

import { RedisClient } from "bun";

// A mock server. It answers HELLO and every GET, and counts the GETs in each read.
const reads = [];
const server = Bun.listen({
  hostname: "127.0.0.1",
  port: 0,
  socket: {
    data(socket, chunk) {
      const text = chunk.toString();
      if (text.includes("HELLO")) return void socket.write("+OK\r\n");
      const gets = text.split("GET").length - 1;
      reads.push(gets);
      socket.write("$1\r\nx\r\n".repeat(gets));
    },
  },
});

const client = new RedisClient(`redis://127.0.0.1:${server.port}`);
await client.connect();
await new Promise(resolve => setImmediate(resolve));
await Promise.all(["a", "b", "c", "d"].map(key => client.get(key)));
console.log("GETs per server read:", reads);
client.close();
server.stop(true);
Build With the flag Without the flag
1.4.2 [ 4 ] [ 4 ]
main (ea6a2c7) [ 4 ] [ 4 ]
this PR [ 1, 1, 1, 1 ] [ 4 ]

The flag has no effect on 1.4.2 and on main: the four GETs go out as one batch. With new RedisClient(url, {}) main prints [ 1, 3 ].

PR. #46382

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:43 PM PT - Oct 21st, 2026

✅ @robobun, your commit 567f9091ce1d80035ad992677f7fc636fadc56b8 passed in Build #130272! 🎉


🧪   To try this PR locally:

bunx bun-pr 46382

That installs a local version of the PR into your bun-46382 executable, so you can run:

bun-46382 --bun

@claude claude Bot left a comment

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.

Beyond the inline findings, I also checked the two refactors folded into this PR: Options::from_js taking Option<JSValue> keeps the old null/undefined handling (JSValue::is_object requires a cell, so null still falls through to the defaults), and dropping the meta.check call in js_send is behavior-preserving because ValkeyClient::send recomputes checked_command.meta from scratch before routing. The fake-server batch-boundary concern (one socket read treated as one batch) was also looked at and ruled out as a product bug.

Extended reasoning...

The change touches the Rust Redis/Valkey client (option parsing, command metadata, close/reconnect queue handling), a types file, and adds a ~380-line subprocess test block; it touches no auth, crypto, or injection surface. Two confirmed findings on the flag-on drop/subscribe path and the partial-write hang are posted inline, so a human look is already signaled; this note only records what else was examined and ruled out.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/valkey_jsc/valkey.rs — With the env flag set, a client whose peer answers a command before its last bytes are written can hang every later command forever, with no error and no timeout. In one-at-a-time mode only one command is ever in flight, so when its reply arrives while write_buffer still holds its tail, send_next_command at valkey.rs:714 skips the queue and valkey.rs:744 only flushes the tail; nothing re-runs the queue once the flush completes, and no later reply will come because in_flight is empty. Fix: after flush_data at valkey.rs:744 drains write_buffer, re-run the queue step (or register the flusher) whenever queue is non-empty and in_flight is empty, so a one-at-a-time client cannot be left with an unsent head.

    Why this was flagged

    Trigger: a one-at-a-time client (Bun.redis or new RedisClient without enableAutoPipelining, in a process with BUN_FEATURE_FLAG_DISABLE_REDIS_AUTO_PIPELINING) sends a command larger than the socket buffer, then more commands. drain at valkey.rs:1343-1353 writes part of it and keeps the tail in write_buffer. A peer that replies before reading the full command and keeps the socket open pops the only in-flight entry at valkey.rs:1146. send_next_command at valkey.rs:714 requires an empty write_buffer, so it does nothing; valkey.rs:744 flushes the tail and returns. On the base branch a default client batches the following commands into the same write, so a later reply to one of them re-enters send_next_command; in one-at-a-time mode there is no later reply. The only remaining trigger is uws on_writable at valkey.rs:1365, which fires only if the kernel reported a full buffer. Population: every flag user by default after this PR; before it only enableAutoPipelining:false clients. Remedy: re-run the queue after the flush at valkey.rs:744 when in_flight is empty and queue is not.

    Verification: pre-existing (route untouched by the diff). When a reply pops the in-flight pair (valkey.rs:1146), line 714 is false so the queue block is skipped and line 744 only drains the tail. Once the tail is gone, queue still holds the next command, in_flight is empty, nothing re-enters send_next_command. The base branch already hangs this way for enableAutoPipelining: false clients.

Comment on lines +691 to +698
let waiting = if self.flags.one_command_at_a_time
&& closed == Status::Connected
&& !self.flags.is_selecting_db_internal
{
core::mem::take(&mut self.queue)
} else {
command::entry::Queue::new()
};

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.

🔴 With the flag on, a user whose subscribe() was waiting behind an in-flight command when the connection drops gets a rejected promise but a listener it can never remove. The new queue rejection at valkey.rs:691-698 rejects the queued SUBSCRIBE entry, yet the handler stored by upsert_receive_handler at js_valkey_functions.rs:1894/1903 stays in the map. Since is_subscriber is only set on the server's confirmation (valkey.rs:938), unsubscribe() throws REDIS_INVALID_STATE, and has_subscriptions() keeps the reconnected client's poll ref alive (js_valkey.rs:1659-1671). Fix: when a queued entry carrying SUBSCRIPTION_REQUEST is rejected on a client that is not yet a subscriber, remove its receive handlers, or keep such entries in the queue for the next connection.

Why this was flagged

Process runs with BUN_FEATURE_FLAG_DISABLE_REDIS_AUTO_PIPELINING=1 and a client that does not set enableAutoPipelining. The script calls client.get(k) and then client.subscribe("ch", fn) in the same tick; every command is non-pipelineable so the SUBSCRIBE is pushed to self.queue behind the in-flight GET (valkey.rs:1391, 1400). Before the GET reply the server closes the connection. on_close takes self.queue at valkey.rs:695 and reject_all_pending_commands rejects the SUBSCRIBE promise with ERR_REDIS_CONNECTION_CLOSED (valkey.rs:502-507). The listener added at js_valkey_functions.rs:1903 remains in subscriptionCallbackMap; add_subscription (valkey.rs:938) never ran so is_subscriber is false. After the reconnect, update_poll_ref computes listening = has_subscriptions() && Connected (js_valkey.rs:1659-1665) and refs the event loop, so the process does not exit until close(). On the base branch the queued SUBSCRIBE is kept (reject_in_flight_commands passed an empty Queue) and sent on the next connection, so the subscription is established and unsubscribe() works.

Verification: ValkeyClient::on_close takes the queue (valkey.rs:691-698), then reject_all_pending_commands only does cmd.promise.reject(...) for queue entries (valkey.rs:502-507). is_subscriber is set only from the server's subscribe push at valkey.rs:938, so unsubscribe() throws REDIS_INVALID_STATE via require_subscriber (js_valkey_functions.rs:45-57). On base the same script keeps the queued SUBSCRIBE across the reconnect.

This branch has not been deployed

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant