Skip to content

scan_reader: parallel dump + batched sends + bounded queue; redis_writer: parallel connections#1056

Draft
oOraph wants to merge 2 commits into
tair-opensource:v4from
oOraph:bound_scan_reader_dump_queue
Draft

scan_reader: parallel dump + batched sends + bounded queue; redis_writer: parallel connections#1056
oOraph wants to merge 2 commits into
tair-opensource:v4from
oOraph:bound_scan_reader_dump_queue

Conversation

@oOraph

@oOraph oOraph commented Jun 25, 2026

Copy link
Copy Markdown

Summary

Throughput & memory improvements for the scan_reader (SCAN + DUMP + PTTL + RESTORE) path and the standalone redis_writer. On a ~40M-key standalone source, end-to-end scan-based sync went from ~25k to ~280k keys/s with flat memory.

Motivation

Migrating a large keyspace (tens of millions of keys) via scan_reader — e.g. from a server that can't serve PSYNC, like Dragonfly — was throughput- and memory-limited:

  • A single source connection doing DUMP+PTTL per key caps ~22k keys/s on round-trip latency.
  • client.Send() flushes after every command, so each key costs 2 source send syscalls; CPU profiling showed ~64% of time in socket syscalls.
  • needDumpQueue is hardcoded to 100_000_000, so on a large DB it buffers ~the whole keyspace in RAM (OOM), and the large live heap also degrades throughput via GC during scan-fill.
  • The standalone writer funnels all entries through one connection.

Changes

  1. redis_writer.parallel (default 1, unchanged behavior) — fan RESTOREs across N target connections draining a shared channel.
  2. scan_reader.dump_parallel (default 1, unchanged behavior) — N source connections running DUMP+PTTL, draining the shared scan queue (the Go channel hands each key to exactly one worker — no key partitioning).
  3. Batched DUMP/PTTL sends — add client.SendNoFlush(); dump() buffers source commands and flushes every 128 keys or 5ms instead of per-command. Amortizes hundreds of commands per syscall — the single biggest win. Internal only (no API change); preserves command/reply ordering.
  4. scan_reader.dump_queue_size (default 100000, was an effectively-unbounded hardcoded 100000000) — bound the scan→dump handoff queue. The old value buffered ~the whole keyspace in RAM (OOM risk on large DBs) and the oversized live heap hurt throughput via GC; the bounded default backpressures scan() to dump() speed and keeps memory flat. Set a large value to restore the previous behavior.

Benchmark (Tens of millions of keys, standalone → standalone)

config keys/s
stock (1 dump conn, 1 writer conn) ~22–28k
parallel dump+writer (8/8), pre-batching ~90k
+ batched sends ~252k
+ count=50000 ~282k

Memory stays ~50 MiB with the bounded queue (vs many GiB unbounded).

Compatibility

parallel and dump_parallel default to 1 (no change). The one default change is dump_queue_size (100000 vs the old hardcoded 100000000): it caps the in-flight scan buffer — throughput is unaffected or improved (benchmarks above), only peak memory changes (for the better). Set dump_queue_size large to opt back into the old unbounded behavior.

Throughput and memory improvements for the scan_reader (SCAN+DUMP+PTTL+RESTORE)
path and the standalone redis_writer. On a ~38M-key standalone source,
end-to-end scan-based sync goes from ~90k to ~280k keys/s with flat memory.
All new options default to the prior behavior except the scan queue bound (see
below), which fixes a latent OOM.

- redis_writer: add 'parallel' (default 1) — fan RESTOREs across N target
  connections draining a shared channel. A single connection was the cap while
  the target had ample headroom.
- scan_reader: add 'dump_parallel' (default 1) — N source connections each run
  DUMP+PTTL, draining the shared scan queue (the Go channel hands each key to
  exactly one worker; no key partitioning needed).
- scan_reader: batch DUMP/PTTL sends — add client.SendNoFlush(); dump() buffers
  source commands and flushes every 128 keys or 5ms instead of Send()'s
  flush-per-command. CPU profiling showed ~64% of time in socket syscalls;
  this was the single biggest win.
- scan_reader: bound the scan->dump queue via 'dump_queue_size'
  (default 100000, previously hardcoded to 100000000). The old value buffered
  ~the whole keyspace in RAM (OOM on large DBs) and the large live heap also
  degraded throughput via GC. Set a large value to restore the old behavior.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

…commands

The loader unconditionally collected every Rewrite() command into a slice before
deciding RESTORE-vs-individual, so a single huge collection (a 120M-member zset in
our data) materialized the whole raw value plus all rewrite commands in memory and
OOMed the loader (~54GiB).

Cap the raw-value capture at target_redis_proto_max_bulk_len via a small
io.Writer: while a value fits the cap, buffer its commands (it may RESTORE); once
it overflows, flush the buffer and stream the remaining commands directly, dropping
the raw bytes. Peak heap is now bounded by the threshold regardless of object size.
Small values still replay as a single RESTORE; oversized ones stream as individual
commands (Rewrite's leading del gives replace semantics).
@oOraph oOraph force-pushed the bound_scan_reader_dump_queue branch from 1fb5dc1 to 9e7d7be Compare June 26, 2026 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants