opensearch-go v5.1.1
v5.1.1 replaces v5.1.0, which was abandoned because its module checksums were not updated. The two releases contain the same code. Upgrade directly to v5.1.1 and skip v5.1.0.
This release rebuilds opensearchutil.BulkIndexer around the cluster's shard layout. With default settings it indexes about twice as fast as v5.0.3. It sends each shard's documents straight to the node that holds the shard's primary, sizes batches from the cluster's own feedback, keeps memory under a fixed limit, retries failed items, and reports structured errors and metrics. See the Bulk Indexer guide.
Highlights
About 2x bulk indexing throughput
In a benchmark of 10M documents, the v5.1.1 BulkIndexer indexed about twice as fast as v5.0.3 with default settings and no code changes.
| v5.1.1 compared with v5.0.3 | Change |
|---|---|
| Throughput | 1.97x |
| Time to index 10M documents | -49% |
| Bytes sent | same |
| Bulk requests | about the same |
| Failed items | none in either |
Both versions send the same bytes in about the same number of requests, so the gain comes from where and when the requests are sent, not from sending less.
The benchmark ran on a single local 3-node OpenSearch 3.7.0 cluster with a 6-shard index, synthetic documents of about 474 B, and 4 goroutines calling Add. It is a small, controlled test, not a production workload. The gain on your cluster depends on its size, shard layout, document shape and concurrent load.
Shard-aware buffering
The indexer keeps a buffer for each shard and sends a full buffer as one bulk request to the node that hosts the shard's primary, without a hop through another coordinator. A document with neither a routing value nor an ID goes to the nearest node with room. Documents for a shard keep the order they were added in, through retries, shard relocation and slow nodes, because each shard has one request in flight at a time. A missing or wrong shard map costs efficiency, never correctness, because the server regroups every bulk request by shard.
opensearchutil.ShardRouter supplies the shard map. ClientShardRouter takes it from the client's transport, StaticShardRouter serves a fixed map, ChainShardRouter combines routers, and NoShardRouter turns shard-aware buffering off. The transport adds LookupShard, ShardMap and WriteNodes, which report where a document or a write would go without sending a request.
Adaptive batch sizing
Two independent controllers size the requests, each driven by one signal that needs no coordination between clients (Sizing batches):
- The shard-buffer controller sets how many bytes a shard's buffer collects. A PID controller compares each shard's write latency with the other shards on the same node and gives a shard that is slower than its neighbors smaller buffers, so a hot or expensive shard cannot take the node's time from the rest. When shards are equally fast, every buffer stays at
FlushBytes. - The node-window controller sets how many bytes may be in flight to a node. A 429 halves it, and it grows back in small steps while the node accepts writes (additive increase, multiplicative decrease, as in TCP). A 413 teaches the indexer the largest request the cluster accepts.
The controllers differ because their signals do: latency varies by degree, so it suits a proportional PID response, while a 429 is binary and suits AIMD (Why two different controllers). Together they keep each node busy without pushing it into rejections, and a fleet of clients making the same adjustments settles near what the cluster can accept. BulkSizing.Shard (ShardPID or ShardFixed) and BulkSizing.Node (NodeAIMD or NodeFixed) turn either controller off, and OPENSEARCH_GO_BULK_SHARD, OPENSEARCH_GO_BULK_NODE and OPENSEARCH_GO_BULK_PID_KP, _KI and _KD override them from the environment.
Bounded memory
BulkIndexerConfig.MaxMemoryBytes limits what the indexer holds, from Add until the request carrying an item has finished. When the limit is reached, Add waits for requests to complete, so a slow cluster slows the caller instead of growing memory. The default is the largest of a quarter of GOMEMLIMIT, 4 GiB and 512 MiB when a Go memory limit is set, and 512 MiB otherwise. While other shards are waiting for room, each shard is held to an even share of the limit, so one slow shard makes only its own callers wait. Buffers come from a pool of 64 KiB chunks, and AddZeroCopy sends a document from the caller's memory without copying it.
Retries and structured errors
BulkIndexerConfig.RetryPolicy sends failed items again. Every failure is reported as a *BulkError that names the layer that failed (LayerItem, LayerRequest, LayerIndexer) and why (KindRejected, KindUnavailable, KindTransport, KindPermanent, KindCanceled), and matches sentinel errors with errors.Is. OnSuccess and OnError fire exactly once per item, when its outcome is final. Retrying requires idempotent writes, so with a policy set an item needs a DocumentID, or a BulkIndexerConfig.Fingerprinter to generate one.
Observability
BulkIndexerObserver reports each bulk request, request and item failure, and item outcome. osprom.NewBulkIndexerObserver and osotel.NewBulkIndexerObserver provide Prometheus and OpenTelemetry implementations, with no index names or document IDs in their labels. BulkIndexerStats adds load, backpressure, error, latency and size readings, and GetStats and GetNodeStats read them without allocating or locking, so a stream consumer can steer how much it reads by the indexer's load.
Committing stream positions
When Flush returns without a context error, every item it covered has had OnSuccess or OnError called, so a caller can commit a stream offset from what the callbacks recorded. BulkIndexerItem.Metadata carries caller data, such as that offset, through to the callbacks. opensearchutil.FrontierTracker reports, per stream, the lowest offset not yet complete, below which every record is done, and never passes a gap.
Other changes
- Write each item's action line with a pooled encoder that allocates nothing. In the round-trip benchmark an item takes about 30% less time, with half the allocations and half the memory.
- Decode an
Executeresponse from the bytes the transport already buffered, and send an in-memory request body with aContent-Length, replaying it on retry without a second copy. NewBulkIndexerreturns an error wrappingErrInvalidConfigfor a negative byte setting.AddandFlushreturnErrBulkIndexerClosedafterCloseinstead of panicking.
Fixes
- Send
PUTagain for operations where the spec allows bothPOSTandPUT, as v4 did. This restoresIndexwith a document ID on OpenSearch Serverless, which returned 404 (#1212, #1218). - Health-check the node discovered in a single-node cluster, so requests are routed to it instead of the seed URL.
- Close the body of a request that the transport fails before sending, so pooled buffers are released.
- Fail every item of a bulk request whose response has fewer results than the request had actions. Those items used to receive no callback.
- Report a buffer's error to
OnUnhandledErrorwhen theFlushwaiting for it returns early. The error used to be lost.
Breaking changes
If you use opensearchutil.BulkIndexer, read UPGRADING_V5.md before upgrading.
- The
BulkIndexerinterface gainsCancel,GetStatsandGetNodeStats. Types that implement it, usually test fakes, must add these methods. Callers ofNewBulkIndexerare unaffected. BulkIndexer.Closewaits until every item has hadOnSuccessorOnErrorcalled, including items in retry backoff, and escalates toCancelif its context ends.BulkIndexerItem.Bodyis read from offset 0 on every attempt, whatever its position when the item was added.BulkIndexerConfig.NumWorkerscaps the bulk requests outstanding at once and defaults to no cap, instead of one worker per CPU. Per-node concurrency follows each node's write thread pool.FlushBytesis now the largest request body for a shard, and callbacks can run concurrently unlessNumWorkersis 1. A streaming caller should lowerFlushInterval, which now sets the latency of a document in a sparse stream.
Deprecated
BulkIndexerItem.OnFailure, replaced byOnError, andBulkIndexerConfig.OnError, replaced byOnUnhandledError. Both still work as in v5.0.
Dependencies
osotel,osprom,compress,log-slogandlog-zerologrequire the v5.1 root module, so every nested module builds against the root it ships with.- Bump
github.com/klauspost/compressincompressto v1.20.3, and several indirect dependencies.
Full Changelog: v5.0.3...v5.1.1
See CHANGELOG.md for the full list.
Contributors
Thanks to everyone who contributed to this release: @eivindam, @sean- and @ryanyuan.