diff --git a/.claude/skills/park/SKILL.md b/.claude/skills/park/SKILL.md index 2b23b42ece..7e1030ac97 100644 --- a/.claude/skills/park/SKILL.md +++ b/.claude/skills/park/SKILL.md @@ -46,6 +46,19 @@ Park only when **the docs are as complete as the source allows** and the sole bl external event. If the work is merely unfinished, keep working. If the source may never ship, don't park — say so. There must be a concrete, testable **trigger condition**. +Two things make a trigger actually testable, and both have bitten: + +- **Point it at the source that will ship the feature, not the one you read.** A draft PR is + often one step in a series that lands on the default branch as a different, consolidated PR. + Watch the branch the release is cut from; if the PR you wrote against targets an integration + branch rather than `master`/`main`, say so in the trigger and name the umbrella once it + exists. +- **Require "in a released version", and make it mean an ancestry check.** Merge and release can + be minutes apart, so date order proves nothing, and the newest tag is often a beta the page's + version note must not cite. The trigger is met when the merge commit is an **ancestor of a + non-prerelease tag** — record the command that shows it (`gh api + repos///compare/...`, expecting `behind_by: 0`). + ## Step 2 — Harvest the loose ends already on the branch The branch's own commits hold the re-check items, from `/reflect`: @@ -84,6 +97,24 @@ Record URL + snapshot in the sources table, and the re-fetch command for unpark. against an unmerged diff — and say *why* it's low, e.g. "signatures differed between two reads of the diff"). +**Split that summary in two: the semantics, and the identifiers.** They decay at completely +different rates. What the feature *does* — the shape of the page, the ordering guarantees, the +caveats — usually survives to release intact. The **names** usually don't: types get renamed, +optional-arg methods split into no-arg plus `WithOptions` variants, fields appear, and fields +leave the type they were on. So enumerate every type, method, field and default the page +commits to as an explicit list unpark can tick off one by one, rather than describing them in +prose. A prose sentence covering four field names is one checklist item that can be half-right; +four listed names are four verdicts. + +Two traps worth calling out while you write that list: + +- **Distinguish "renamed" from "was never there".** Record *where* you saw each identifier + (which file, which struct), because the useful unpark finding is sometimes "this field isn't + part of this API at all" — a mis-attributed field looks exactly like a renamed one in the + snapshot, and only the location tells them apart. +- **Note where the changelog and the source disagree.** Release notes summarize and under-report; + the source is authoritative for which client types expose the API. + ## Step 4 — Compose the manifest & PR body Build the PR body: a short human summary, a prominent **do-not-merge warning** linking the @@ -119,6 +150,15 @@ outward-facing — if the branch isn't pushed or the user hasn't asked, confirm - **A stale snapshot is worse than none** — if you record a head SHA or API shape you didn't actually verify, unpark diffs against fiction. Snapshot only what you checked; leave the rest out and flag it in the checklist. +- **Expect the identifiers to be wrong, and don't let that shake your confidence in the page.** + Measured on DOC-6832 (go-redis automatic pipelining, parked ~3.5 weeks across two re-parks): + at release the page's structure, ordering semantics and every caveat still read correctly, + while nearly every type, method and field name had moved. That is the *normal* outcome, not a + sign the page was written too early — which is why the LOW confidence tag belongs on the + identifier list specifically, not smeared over the whole page. +- **Re-verifying during the park is what makes the final reconcile cheap.** Each re-park that + refreshes the snapshot converts churn into an already-answered checklist item; skip them and + every delta arrives at once, at the moment you least want a surprise. - Park **cannot judge whether the source will ship.** It records a trigger; it doesn't predict the future. A parked PR that never triggers is dead weight — `/unpark`'s scan mode is how you find and close those. diff --git a/content/develop/clients/go/autopipeline.md b/content/develop/clients/go/autopipeline.md new file mode 100644 index 0000000000..274a4b22e0 --- /dev/null +++ b/content/develop/clients/go/autopipeline.md @@ -0,0 +1,210 @@ +--- +bannerText: Automatic pipelining is an experimental feature and may be subject to change. +categories: +- docs +- develop +- stack +- oss +- rs +- rc +- oss +- kubernetes +- clients +description: Batch concurrent go-redis commands into pipelines automatically for high-throughput workloads. +linkTitle: Automatic pipelining +title: Automatic pipelining +weight: 42 +--- + +[Pipelining]({{< relref "/develop/using-commands/pipelining" >}}) sends a batch +of commands to the server in a single communication, which avoids the network +and processing overhead of sending each command separately. Normally you build +a pipeline by hand (see [Pipelines and transactions]({{< relref "/develop/clients/go/transpipe" >}})), +but this means you must know in advance which commands you want to batch. + +*Automatic pipelining* removes that requirement. When many goroutines issue +commands concurrently, `go-redis` coalesces them into deep pipelines for you, +without any pipeline code in your application. This is useful in high-throughput or +high-concurrency scenarios. At low concurrency, a plain client is +simpler and just as fast, and a hand-written pipeline is generally faster than +an auto-generated one. + +Automatic pipelining requires +`github.com/redis/go-redis/v9` v9.22.0 or later. + +## Blocking and asynchronous pipelining + +Automatic pipelining has two methods that share the same underlying engine: + +- **Blocking** (`AutoPipeline()`) is a drop-in replacement for a normal + client. Each command call blocks until it executes and returns its own + value and error, exactly like a plain client, so existing code keeps + working unchanged. Under concurrency, the engine batches commands from all + goroutines into back-to-back pipelines behind the scenes. Per-goroutine + ordering is preserved. +- **Asynchronous** (`AsyncAutoPipeline()`) offers the highest throughput. + Command calls return immediately; reading a result with + `Val()`, `Result()`, or `Err()` blocks until the batch executes. Submit a + sequence of commands and then read the results afterwards to keep each + pipeline as deep as possible. + +Both methods are available on `Client`, `ClusterClient`, and `Ring`. + +## Blocking usage + +Call `AutoPipeline()` to get an `AutoPipeliner`, then call command methods on it +just as you would on a normal client. Each call blocks until it executes, but +concurrent callers' commands are batched together automatically: + +```go +rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"}) +defer rdb.Close() +ctx := context.Background() + +// Blocking: a drop-in for a normal client, batched under the hood. +ap, err := rdb.AutoPipeline() +if err != nil { // only returned for invalid AutoPipelineOptions + log.Fatal(err) +} +defer ap.Close() + +var wg sync.WaitGroup +for i := 0; i < 1000; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + key := fmt.Sprintf("key:%d", i) + if err := ap.Set(ctx, key, i, 0).Err(); err != nil { // blocks until executed + log.Printf("set %s: %v", key, err) + } + }(i) +} +wg.Wait() +``` + +## Asynchronous usage + +For maximum throughput, use asynchronous execution. Command calls return +immediately, so you can submit a sequence of commands and read their results +afterwards: + +```go +ctx := context.Background() + +ap, err := rdb.AsyncAutoPipeline() // ordered by default +if err != nil { + log.Fatal(err) +} +defer ap.Close() + +cmds := make([]*redis.StatusCmd, 0, 200) +for i := 0; i < 200; i++ { + // Returns immediately without executing. + cmds = append(cmds, ap.Set(ctx, fmt.Sprintf("key:%d", i), i, 0)) +} +for _, cmd := range cmds { + if err := cmd.Err(); err != nil { // blocks until the batch executes + log.Printf("set: %v", err) + } +} +``` + +## Configuration + +`AutoPipeline()` and `AsyncAutoPipeline()` take no arguments. They use the +`AutoPipelineOptions` set on the client's options, if any, and otherwise use +reasonable default values. To pass options for a single +autopipeliner, use `AutoPipelineWithOptions()` or +`AsyncAutoPipelineWithOptions()` instead: + +```go +// On the client, used by both methods. +rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + AutoPipelineOptions: &redis.AutoPipelineOptions{MaxFlushDelay: 100 * time.Microsecond}, +}) + +// Or for a single autopipeliner. +ap, err := rdb.AsyncAutoPipelineWithOptions(&redis.AutoPipelineOptions{ + MaxConcurrentBatches: 80, + Unordered: true, +}) +``` + +All four methods return `(*AutoPipeliner, error)`. The error is non-nil only +when the options are invalid (for example, setting `MaxConcurrentBatches` +greater than one without also setting `Unordered`). Invalid options never cause a +panic. + +The configuration options are: + +| Field | Description | +| :---- | :---------- | +| `MaxBatchSize` | Target number of commands the engine coalesces into a single pipeline before flushing. This is a soft threshold rather than a hard cap, so a busy queue can flush a larger batch. Defaults to 200. | +| `MaxBatchBytes` | Soft limit on the total size of arguments (in bytes) for a batch, so that large values flush as several bounded writes instead of one very large one. Defaults to 0, meaning no byte limit. | +| `MaxFlushDelay` | Maximum time the engine waits to accumulate more commands before flushing a batch. Larger values build deeper pipelines at the cost of latency. Defaults to 0, which adds no accumulation wait. | +| `AdaptiveDelay` | Scales `MaxFlushDelay` down as the queue fills, so a busy queue flushes sooner. Requires `MaxFlushDelay` to be greater than 0. Defaults to `false`. | +| `MaxConcurrentBatches` | Number of batches that may execute at once. Defaults to 1, which gives a single ordered stream. Values greater than 1 require `Unordered` set to `true` because concurrent batches do not preserve a single ordered stream. | +| `Unordered` | Allows commands to execute without preserving a single ordered stream, which enables higher concurrency. | +| `NumShards` | Number of independent command queues, or shards, that the engine flushes separately. Defaults to 0, meaning a single shard, which funnels every caller into one queue so batches stay deep. Cluster clients default to several slot-routed shards instead. With `AsyncAutoPipeline()`, values greater than 1 require `Unordered` to be set to `true`. | + +`MaxBatchSize` is the one default that differs between the two methods. If you +set no options at all, `AutoPipeline()` uses a built-in preset that targets 300 +commands instead of 200. As soon as you supply `AutoPipelineOptions`, either on +the client or to `AutoPipelineWithOptions()`, that preset no longer applies, and +a `MaxBatchSize` you leave unset means 200. + +Connection and buffer tuning is not part of `AutoPipelineOptions`. Batches use +the client's pipeline connections, which you size with the +`PipelineReadBufferSize`, `PipelineWriteBufferSize`, and `PipelinePoolSize` +fields of the client's options. + +Each client holds at most two autopipeliners: one for the blocking method and +one for the asynchronous method. Each of them is a +[*singleton*](https://en.wikipedia.org/wiki/Singleton_pattern) that the client +creates on first use and then shares with every later caller. + +Options therefore only take effect on the call that creates the singleton. If a +blocking autopipeliner already exists, a later `AutoPipelineWithOptions()` call +returns that same instance and ignores the options you passed, because +`AutoPipeline()` and `AutoPipelineWithOptions()` share one singleton between +them. `Close()` stops the singleton for every caller and the next call creates a +fresh one, so closing is also how you apply different options. Closing the +client is permanent: both methods then return `ErrClosed`. + +## Cluster usage + +`AutoPipeline()` and `AsyncAutoPipeline()` also work on `ClusterClient`. +Commands are routed to the correct shard by key, so the client installs +slot-based shard routing to keep each shard's batch on a single master node +(rather than splitting every batch across all nodes at flush time). This is why +cluster clients default to several shards instead of one. A single batch may +span many slots. Ordering is per key: same-key commands stay in order, while +sub-pipelines on different nodes run concurrently. + +Commands that must reach every node or shard, such as +[`FLUSHALL`]({{< relref "/commands/flushall" >}}), cannot be added to a pipeline, so +the cluster client rejects them with an error rather than let them spoil a +batch shared with other callers. Run them on the plain client instead. + +## Caveats and limitations + +- A command's context is not honored once it is queued, because batches + execute on the autopipeliner's own context. Use a plain client if you need + per-command deadlines. +- Blocking commands such as [`BLPOP`]({{< relref "/commands/blpop" >}}) and + [`WAIT`]({{< relref "/commands/wait" >}}) are never batched and run directly + on your context. +- The generic `Do`, `DoRaw`, and `DoRawWriteTo` methods run outside the + pipeline, on a normal connection, because an arbitrary command name can + carry connection state or block the connection. Prefer the typed methods + (`ap.Set()`, `ap.Get()`, and so on), which are always batched. +- On a dropped connection, a batch is retried as a whole, up to the client's + `MaxRetries`, so non-idempotent commands may execute twice. Set + `MaxRetries: -1`, or use a plain client, for commands that must never be + retransmitted. + +## More information + +See the [`go-redis`](https://github.com/redis/go-redis) repository for the +`example/autopipeline` usage tour and further API details. diff --git a/content/develop/clients/go/transpipe.md b/content/develop/clients/go/transpipe.md index 1155004a83..4cefc3c299 100644 --- a/content/develop/clients/go/transpipe.md +++ b/content/develop/clients/go/transpipe.md @@ -28,6 +28,10 @@ There are two types of batch that you can use: See the [Transactions]({{< relref "develop/using-commands/transactions" >}}) page for more information. +If you want the client to batch concurrent commands into pipelines for you +without writing any pipeline code, see +[Automatic pipelining]({{< relref "/develop/clients/go/autopipeline" >}}). + ## Execute a pipeline To execute commands in a pipeline, you first create a pipeline object