feat: per-wrapper partial-failure error mask + v5preview parity - #844
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #844 +/- ##
==========================================
- Coverage 52.52% 47.20% -5.32%
==========================================
Files 990 1000 +10
Lines 59059 69758 +10699
==========================================
+ Hits 31020 32929 +1909
- Misses 24935 33631 +8696
- Partials 3104 3198 +94
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Jakob3xD
left a comment
There was a problem hiding this comment.
Code review
Thanks for this — the underlying feature (typed partial-failure errors + a version/env-gated on-off migration) is the right direction, and the errmask bitfield itself is cleanly written. The feedback below splits into one design question and a set of verified correctness/doc issues. Inline comments carry findings #2, #3, #4, #7; the rest are below.
Design question: is a 15-bit global ErrorMask the right granularity?
Two things should stay regardless of the answer:
- Partial failures as typed errors (
PartialBulkError,PartialSearchError,ShardFailureError) + the helpers (errors.As,ToleratePartialFailures,RequireSuccessRate). - The coarse on/off migration gate (env var + v4→v5→v6 default flip).
The concern is only the per-category global mask layer in between:
-
Wrong axis. Tolerance for partial failure is almost always per call-site, not per client (the mask is client-global — verified: no per-request override; every dispatch reads
c.errors.Has(...)). The same client does a best-effort analytics search (shard failures tolerable) and a critical bulk index (item failures not). A global mask can't express that without two clients. And the per-call mechanism already exists: always return the typed error, let the caller decide witherrors.As/ToleratePartialFailures/RequireSuccessRate. The mask is a second, coarser mechanism for the same goal. -
It re-introduces the footgun the feature exists to kill. The stated goal was "partial failures become impossible to silently ignore." A global mask that converts them back to
(resp, nil)— set once at construction or via env var — does exactly that, invisibly, across every call site.ToleratePartialFailures(err)at the call site keeps the suppression visible in code review. -
It leaks the spec's internal taxonomy into permanently-stable public API. 15 wire-format wrapper bits each have to stay stable forever and be individually documented/tested.
-
It necessitates a parallel codegen path (
errwrap.OperationWrappers+ per-bit emission running a second spec walk to decide which bit to emit) — and findings #5/#6 below live in exactly that path.
If a one-time org-wide policy is genuinely needed, a small semantic enum (None / TolerateShardFailures / TolerateAll) would serve it without the four problems above. And making detection a PartialFailure() error method keyed off the already-correctly-generated response struct (instead of a parallel error-wrap walk) would remove the bit-selection class of bug entirely.
Cross-cutting issues (not anchored to a single line)
1. Docs reference an env var that doesn't exist (high confidence). CHANGELOG.md, USER_GUIDE.md, UPGRADING.md, and guides/error_handling.md document OPENSEARCH_GO_PARTIAL_QUERY_ERRORS (and describe it as a boolean). The shipped code defines only OPENSEARCH_GO_ERROR_MASK (a token list, see internal/envvars/envvars.go). A user following the docs gets no effect.
5. v5preview MSearch never emits a partial-failure check (parity gap). v5preview/opensearchapi/msearch_gen.go emits no errmask/partial check at all, while v4 api_msearch.go does. Root cause appears to be in the generator: elementTypeHasShards resolves the Responses element type (MsearchMultiSearchResultResponsesItem), which is a TypeUnion — its shape is in Branches, not Fields, so responseHasField(..., "Shards", ...) returns false and the check is dropped. MSearch shard failures are never surfaced in v5preview regardless of mask. This defeats the "v5preview parity" goal for MSearch.
6. v5preview Create is missing its WriteShards check (parity gap). v5preview/opensearchapi/create_gen.go Create returns (&data, nil) with no shard-failure check, while index_gen.go has if !c.errors.Has(errmask.WriteShards) && data.Shards.Failed > 0 { ... } and v4 api_document.go Create has the check. Likely root cause: cmd/osgen/api_extract.go hardcodes the "200" response, but create returns only 201 — so CreateResp is built without _shards and the check is suppressed.
Additional, lower-confidence observations
PartialSearchError.Failurestype drift in docs:UPGRADING.md/guides/error_handling.mddocument[]ResponseShardsFailure, butv5preview/opensearchapi/errors.gouses[]ShardSearchFailure. The two packages diverge; the docs describe them as identical.DiscoverNodes()semantics changed (now blocks on an in-flight cycle and returns its error, vs. the previous immediatenilno-op). Likely intentional, but not noted inUPGRADING.md.Routeinterface breaking change:OpID() OperationIDwas added to the exportedopensearchtransport.Routeinterface — a source break for any external implementer (semver-relevant on a v4 module).v5preview/opensearchapi/errors.gohas no test coverage for the new helpers/types (the v4 side does).
🤖 Generated with Claude Code
If this review was useful, react with 👍. Otherwise react with 👎.
6d0f330 to
8918b12
Compare
|
Thanks for the review. On the design question: per-call vs per-client maskYou're right that tolerance is mostly a per-call-site concern, and The alternative ("always return typed errors; let the caller decide") is what v5+ defaults to ( To address the "second mechanism for the same goal" concern, this PR also reworks the surface so per-call inspection happens through methods anchored on the typed Cross-cuttingComment 1 (env var name in docs): Fixed. Comment 5 (v5preview MSearch never emits): Fixed. The walker now resolves the Comment 6 (v5preview Create missing WriteShards): Fixed. Additional observations
v5preview errors.go test coverage: Added |
Jakob3xD
left a comment
There was a problem hiding this comment.
Code review (follow-up)
Re-reviewed at head 3bf7aa9. First: thanks for the thorough rework — I verified every prior finding against the current tree and all are addressed:
- Env-var name (
OPENSEARCH_GO_ERROR_MASK) and theerrmasktoken list ininternal/envvars/envvars.goare now correct. - The
Config.Errorszero-value trap is resolved via the pointer (*errmask.ErrorMask, nil = unset) — exactly the disambiguation needed. - v5preview MSearch shard-path and
CreateWriteShardFailures()parity now generate. - The
WriteShardsdoc no longer over-claims Bulk replica handling. - The design concern is resolved: the mask is now scoped to the v4→v5→v6 lifecycle gate, and per-call inspection runs through the typed
*Respmethods (PartialFailures(mask)+ per-wrapper methods). That's the right split.
The rework introduced new code; the items below are found in that new surface.
Found 1 high-confidence bug + 3 medium + several latent.
1. (high) The union decoder routes every error sub-response to the success branch, so MultiSearchItemFailures() / MGet per-doc error detection silently never fires.
The generated union UnmarshalJSON is try-each "first branch that unmarshals without error wins", with the success branch tried first and no discriminator/required-field guard. For MSearch, MSearchMultiSearchItem has no error field and a Status *float64, so an error sub-response {"error":{…},"status":400} unmarshals into it without error (the error key is ignored, 400 fits *float64). The ErrorRespBase branch is therefore unreachable for wire-decoded responses, and MultiSearchItemFailures() (which dispatches on ...ErrorRespBaseType) always returns nil. Net effect: MSearch() / MSearchTemplate() return a nil error even when sub-queries fail, and the MultiSearchItems wrapper is dead end-to-end. The unit test passes only because it builds the union via New…FromErrorRespBase, bypassing UnmarshalJSON. The same pattern breaks per-doc error detection in MGet (GetResult tried before MGetMultiGetError).
Fix: decode the union by discriminator (e.g. presence of the "error" key, or try the error branch first / guard on a required field) rather than first-successful-unmarshal.
Root cause (template):
opensearch-go/cmd/osgen/emit/frag_union.go
Lines 164 to 177 in 3bf7aa9
Generated symptoms:
opensearch-go/v5preview/opensearchapi/msearch_gen.go
Lines 283 to 305 in 3bf7aa9
opensearch-go/v5preview/opensearchapi/msearch_gen.go
Lines 346 to 372 in 3bf7aa9
(same union reused in msearch_template_gen.go; same class in mget_gen.go)
2. (medium) NewFromClient / NewFromClientWithErrors bypass the v5preview default-router injection, so a client built via these constructors is behaviorally inconsistent with NewClient (no default router) despite the package's "routing on by default" intent.
opensearch-go/v5preview/opensearchapi/api.go
Lines 159 to 168 in 3bf7aa9
3. (medium) v5preview RequireSuccessRate has no *MultiSearchItemError case, so for an MSearch whose only failures are item-level it skips the threshold and returns the error unchanged (diverges from the v4 helper). Currently masked by bug #1, but becomes live once #1 is fixed.
opensearch-go/v5preview/opensearchapi/errors.go
Lines 297 to 330 in 3bf7aa9
4. (latent) collapsePerOpErrors(errs, nil) is emitted for every single-wrapper op; if a future spec change adds a second wrapper to such an op without a PerOpErrType, the len(errs) >= 2 path calls the nil wrap and panics. A defensive if wrap != nil guard would close this.
opensearch-go/opensearchapi/errors.go
Lines 193 to 210 in 3bf7aa9
5. (latent) PluginMethodName does not apply the idiomatic-abbreviation rewriting that now drives type names (*Req/*Resp). A future plugin op whose suffix contains msearch/mget/termvectors/forcemerge would get e.g. type MSearchFooReq but method MsearchFoo — a mismatch. Unreachable today, but the divergence is live.
opensearch-go/cmd/osgen/emit/frag_plugin.go
Lines 412 to 420 in 3bf7aa9
6. (latent) errwrap fallback catalog for msearch lists only MultiSearchItems, missing SearchShards. Harmless for the core path (spec x-error-responses supplies both), but a plugin/unannotated msearch-like op falling back to errwrap.For("msearch") would silently lose the shard wrapper.
opensearch-go/cmd/osgen/errwrap/errwrap.go
Lines 68 to 72 in 3bf7aa9
7. (latent) v4 MSearchResp.SearchShardFailures() accumulates Shards.Total from every sub-response unconditionally (the v5 generated version restricts to the success branch). A server response carrying both error and a non-zero _shards.total would over-count the denominator; current OpenSearch doesn't emit that shape.
opensearch-go/opensearchapi/partial_failure_methods.go
Lines 165 to 180 in 3bf7aa9
8. (nit) Stale doc comments in cmd/osgen/api_extract.go still say "the 200 response body" after the change to walk all 2xx; and the outer-loop continue on a non-JSON 2xx would abandon a path's remaining 2xx codes (no current spec path hits this).
opensearch-go/cmd/osgen/api_extract.go
Lines 73 to 81 in 3bf7aa9
🤖 Generated with Claude Code
If this review was useful, react with 👍. Otherwise react with 👎.
af807f8 to
4c00e5d
Compare
|
Thanks for the re-review, and for verifying the prior round against the tree. All eight items are addressed; details per finding below. 1. (high) Union decoder routed error sub-responses to the success branchFixed at the generator template, so it covers
So While wiring this up I also found and fixed a latent bug in the probe itself: 2. (medium)
|
Jakob3xD
left a comment
There was a problem hiding this comment.
Code review (full pass)
I re-verified the prior two rounds against the current head (94d7dfa): all 8 findings from the last review are addressed, and the design rework (per-*Resp methods + PartialFailures(mask) aggregator, mask reduced to the lifecycle gate) is in place. Thanks for that.
This is a fresh full-PR pass; the items below are new and were cross-checked against the existing comment threads (none are already resolved). Most are posted as inline comments — F1 (internal errmask → the mask is unusable by external consumers) and F3 (RequireSuccessRate short-circuit) are the two worth prioritizing. The remaining low/latent items that don't map to a changed line:
-
F7 —
perOpErrorTypeNamereturnsMSearchErrors/MSearchTemplateErrorsunconditionally for those groups. Correct today (both wrappers are jointly applicable), but if a group ever transitions to a single wrapper while the dispatch still references the per-op type, it's a compile break. The coupling betweenemittableWrappersand the hardcodedperOpErrorTypeNameswitch is implicit and untested. (cmd/osgen/emit/frag_dispatch.go,perOpErrorTypeName.) -
F9 —
classifyRefBranchomitsVersionAdded. Inline union branches readx-version-added, but$refbranches always getVersionAdded: "", sosortBranchesNewestFirstorders them last regardless of actual version. No current spec union has versioned$refbranches, so no impact today. (cmd/osgen/resp_union.go,classifyRefBranch.) -
F10 — GoName collision resolver leaves a stale
seenGoNameentry. After renaming the underscore-prefixed variant,seenGoName[gn]still points at the old name; a three-way_field/fieldcollision in one schema could emit a duplicate Go field name. No such triple in the current spec. (cmd/osgen/resp_walk.go, ~seenGoNameloop.) -
F11 —
envvars_test.go"unset" case sets an empty value (t.Setenv(key, "")) instead of truly unsetting, so theLookupEnv ok==falsepath is never exercised (duplicates the "empty string" row). (internal/envvars/envvars_test.go.) -
F2 (doc nit) —
opensearchapi/errors.goL259 doc example readsc.Msearch(ctx, req); should bec.MSearchafter the naming regen.
🤖 Generated with Claude Code
If this review was useful, react with 👍. Otherwise react with 👎.
…v opt-out v5preview/opensearchapi.NewClient and NewDefaultClient now inject opensearchtransport.NewDefaultRouter when config.Client.Router is nil, opting every v5preview client into intelligent request routing (role-aware dispatch, RTT-based scoring, congestion-window AIMD, shard-cost weighting) by default. The OPENSEARCH_GO_ROUTER environment variable acts as a symmetric override: setting it to a falsy value (false/0) suppresses the injection so Router stays nil, matching v4's "no router" behavior without code changes. Unset, truthy, and unparseable values all proceed with injection. Caller-provided Routers are preserved unchanged. The only behavioral divergence from v4's NewClient is the env-unset row: v4 leaves Router alone, v5preview injects the default. Truthy and falsy values keep their existing semantics. internal/envvars: add Falsy(name) helper. Truthy returns false for both "unset" and "explicitly opted out"; Falsy distinguishes them so the v5preview rule can be expressed as `!envvars.Falsy(envvars.Router)` without re-implementing the parse logic. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…ic Go names The codegen rewrites non-idiomatic substrings produced by pascal-casing spec names into idiomatic Go forms: Msearch -> MSearch (multi-search initialism) Mget -> MGet (multi-get initialism) Mtermvectors -> MTermVectors (initialism + compound noun) Termvectors -> TermVectors (compound noun split) Forcemerge -> ForceMerge (compound verb split) Response -> Resp (idiomatic short form) Each rule matches at PascalCase boundaries; lowercase-suffix variants (Responses, Responsible, ...) are preserved. The Response -> Resp rule additionally requires the trailing character to be uppercase, so standalone names like SearchResponse (spec wrapper) don't collide with the operation-level <Op>Resp response-body name. frag_dispatch.go: the BulkItems wrapper template now resolves its walked element type from the IR (via bulkInnerItemType, which walks Items[] -> outer wrapper -> first pointer field -> inner type) instead of hardcoding "BulkRespItem". Future spec or naming changes propagate automatically. Renames applied to hand-written types for v4/v5 consistency: BulkResponseItem -> BulkRespItem, ErrorResponseBase -> ErrorRespBase, MsearchErrors -> MSearchErrors, MsearchTemplateErrors -> MSearchTemplateErrors. v4's hand-written errors.go and dispatch sites updated to match. Refs opensearch-project#816 Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Mechanical output of cmd/osgen against the patched spec, picking up the idiomatic abbreviations from the parent commit. Touches every generated type that contained Msearch, Mget, Mtermvectors, Termvectors, Forcemerge, or a compound *Response* substring. Refs opensearch-project#816 Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…for aggregations
Two classes of try-each discriminated union are replaced with single-pass
strategies. On mget this cuts decode allocations ~2.7x (1000 docs: ~43k -> ~16k
allocs/op) and time ~1.7x (4.2ms -> 2.5ms); the remaining cost is the GetResult
decode itself plus interface boxing, not the union machinery.
- Case A (merged): object unions with one permissive "primary" branch plus
discriminated branch(es) -- mget, msearch, indices-open. The primary is
embedded and the common case decodes in a single json.Unmarshal; each
discriminated branch is detected by the presence of its distinguishing key and
decoded only when matched. This drops the build.HasJSONKeys map probe (which
was ~61% of the old allocations) and the per-item raw copy.
- Case B (lazy As<T>()): aggregation/suggest result unions carry no wire
discriminator (avg/sum/min/max all serialize as {"value":N}, and bucket types
collide), so they cannot be auto-selected. UnmarshalJSON only retains the raw
bytes; generated As<ConcreteType>() accessors decode on demand into the type
the caller requested.
- Unions fitting neither (e.g. reindex bodies, plugin-defined task status) keep
the existing try-each decoder; the classifier logs once per union name when it
declines to convert a wrapper-shaped union.
- All union UnmarshalJSON now aliases the owned response buffer (u.raw = data)
rather than copying it; RawJSON() documents the borrowed-buffer contract
(valid while the response is reachable, copy to retain).
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Regenerated output of the osgen union changes: merged single-pass decoders for mget/msearch/indices-open success|error items, As<T>() accessors for aggregation and suggest result unions, and buffer-aliasing UnmarshalJSON across all unions. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Add integration tests that drive real requests against the cluster and assert the decoded shape of each union branch, covering the merged single-pass decode and lazy As<T>() paths end-to-end per server version: - aggregation: lazy As<T>() accessors (terms, date_histogram, stats, avg, sum, min, max, value_count, cardinality) - mget: merged success|error decode (GetResult found/not-found vs MGetMultiGetError) - msearch: merged success|error decode (MSearchMultiSearchItem vs ErrorRespBase), the first-byte-switch SearchHitsMetadataTotal union, and the MultiSearchItemError partial-failure surface Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
- github.com/aws/aws-sdk-go-v2/config from 1.32.18 to 1.32.20 - github.com/aws/aws-sdk-go-v2/credentials from 1.19.17 to 1.19.19 - github.com/getkin/kin-openapi from v0.139.0 to v0.140.0 Fixes: opensearch-project#852, opensearch-project#850 Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
v5preview/opensearchapi/README.md covers Partial Failure Errors (Config.Errors, errmask, OPENSEARCH_GO_ERROR_MASK, typed errors, opensearchapi.Errors helper, per-Resp helpers, helper functions, operation constants) and Default Router Injection (truth table for OPENSEARCH_GO_ROUTER, opt-out semantics). v5preview/opensearchapi/MIGRATING.md captures the v4 -> v5preview surface delta: import path, Indices -> Index on multi-index Req types, DocumentID -> ID on IndexReq, optional Params becomes *Params, optional bool query params become *bool, partial-failure type renames, errmask default flip, default Router injection. Framed as a one-time cost of adopting the spec-generated client. guides/error_handling.md presents Bulk, Search, and Write partial- failure examples as paired v4/v5preview blocks. New Per-Resp helper subsection covers BulkItemFailures, SearchShardFailures, WriteShardFailures, MultiSearchItemFailures, PartialFailures(mask). Error Type Reference table includes MultiSearchItemError, MSearchErrors, MSearchTemplateErrors and accurately describes the v4 vs v5preview internal-field-type divergence. guides/bulk.md notes the v4/v5preview field-name and BulkResp.Items shape divergences up front, with paired error-iteration examples for v4's []map[string]BulkRespItem and v5preview's []BulkItem. UPGRADING.md keeps the version-history essentials for the >=5.0 partial-failure model and v5preview Router injection and forward-links to the new package docs. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Follow-up items for a separate cleanup PRThe library code looks good — this list is non-blocking and intended to be addressed in a separate PR. Findings are grouped doc vs codegen; severities are rough (all > 10). Permalinks pinned to Docs (user-facing — examples that don't compile / mislead)
Code / codegen (low / latent — most not triggered by the current spec)
🤖 Generated with Claude Code |
…pling
perOpErrorTypeName's hardcoded switch and errwrap.OperationWrappers'
wrapper-count map are coupled by an unstated invariant: a group has a
per-op aggregator type iff its catalog entry declares 2+ wrappers. Today
both sides match, but nothing checks them, so a future catalog edit can
desync the two without any signal -- the dispatch keeps referencing a
per-op type that's no longer reachable, or worse, emits an empty type
name when a 2+-wrapper group lacks a switch arm.
Add a coupling test that asserts both directions:
- every group naming a per-op aggregator type has 2+ wrappers in
OperationWrappers
- every catalog entry with 2+ wrappers has a non-empty per-op
aggregator type
Iterates the catalog directly rather than a duplicate list of switch
arms, so a new switch arm or catalog entry is exercised automatically.
Failure messages are actionable: they name the offending group, the
current state, and the remediation (add wrappers, remove switch arm,
or add a hand-written aggregator).
Ref: opensearch-project#844 (review round 3, F7)
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…up table Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is a static codegen lookup intentionally kept package-level next to its doc comment and the funcs that consult it. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…up table Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is a static codegen lookup intentionally kept package-level next to its doc comment and the funcs that consult it. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…pling
perOpErrorTypeName's hardcoded switch and errwrap.OperationWrappers'
wrapper-count map are coupled by an unstated invariant: a group has a
per-op aggregator type iff its catalog entry declares 2+ wrappers. Today
both sides match, but nothing checks them, so a future catalog edit can
desync the two without any signal -- the dispatch keeps referencing a
per-op type that's no longer reachable, or worse, emits an empty type
name when a 2+-wrapper group lacks a switch arm.
Add a coupling test that asserts both directions:
- every group naming a per-op aggregator type has 2+ wrappers in
OperationWrappers
- every catalog entry with 2+ wrappers has a non-empty per-op
aggregator type
Iterates the catalog directly rather than a duplicate list of switch
arms, so a new switch arm or catalog entry is exercised automatically.
Failure messages are actionable: they name the offending group, the
current state, and the remediation (add wrappers, remove switch arm,
or add a hand-written aggregator).
Ref: opensearch-project#844 (review round 3, F7)
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…pling
perOpErrorTypeName's hardcoded switch and errwrap.OperationWrappers'
wrapper-count map are coupled by an unstated invariant: a group has a
per-op aggregator type iff its catalog entry declares 2+ wrappers. Today
both sides match, but nothing checks them, so a future catalog edit can
desync the two without any signal -- the dispatch keeps referencing a
per-op type that's no longer reachable, or worse, emits an empty type
name when a 2+-wrapper group lacks a switch arm.
Add a coupling test that asserts both directions:
- every group naming a per-op aggregator type has 2+ wrappers in
OperationWrappers
- every catalog entry with 2+ wrappers has a non-empty per-op
aggregator type
Iterates the catalog directly rather than a duplicate list of switch
arms, so a new switch arm or catalog entry is exercised automatically.
Failure messages are actionable: they name the offending group, the
current state, and the remediation (add wrappers, remove switch arm,
or add a hand-written aggregator).
Ref: opensearch-project#844 (review round 3, F7)
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…up table Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is a static codegen lookup intentionally kept package-level next to its doc comment and the funcs that consult it. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…pling (#857) perOpErrorTypeName's hardcoded switch and errwrap.OperationWrappers' wrapper-count map are coupled by an unstated invariant: a group has a per-op aggregator type iff its catalog entry declares 2+ wrappers. Today both sides match, but nothing checks them, so a future catalog edit can desync the two without any signal -- the dispatch keeps referencing a per-op type that's no longer reachable, or worse, emits an empty type name when a 2+-wrapper group lacks a switch arm. Add a coupling test that asserts both directions: - every group naming a per-op aggregator type has 2+ wrappers in OperationWrappers - every catalog entry with 2+ wrappers has a non-empty per-op aggregator type Iterates the catalog directly rather than a duplicate list of switch arms, so a new switch arm or catalog entry is exercised automatically. Failure messages are actionable: they name the offending group, the current state, and the remediation (add wrappers, remove switch arm, or add a hand-written aggregator). Ref: #844 (review round 3, F7) Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…up table Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is a static codegen lookup intentionally kept package-level next to its doc comment and the funcs that consult it. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…up table Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is a static codegen lookup intentionally kept package-level next to its doc comment and the funcs that consult it. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…up table Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is a static codegen lookup intentionally kept package-level next to its doc comment and the funcs that consult it. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…up table Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is a static codegen lookup intentionally kept package-level next to its doc comment and the funcs that consult it. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
…up table Pre-existing lint warning introduced in f95f305 (opensearch-project#844); the table is a static codegen lookup intentionally kept package-level next to its doc comment and the funcs that consult it. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* fix(opensearchtransport): honor per-request header override over global headers
setReqGlobalHeader compared the existing header *value* against the
header *name* (req.Header.Get(k) != k), which is effectively always
true, so global headers were appended even when the caller had already
set the same key on the request. Compare for presence instead so a
per-request header suppresses the matching global default.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* fix(opensearchtransport): prevent gzip buffer-pool nil poisoning on compress error
When io.Copy or writer.Close failed, compress() returned (nil, err) but
Perform had already armed `defer collectBuffer(buf)` with that nil,
which Put a typed-nil *bytes.Buffer into the sync.Pool. The next
compress() call would Get() the nil and panic on buf.Reset().
Return the buffer on the error path so the deferred collectBuffer
recycles it, and nil-guard collectBuffer for belt-and-suspenders.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* fix(opensearchtransport): surface body-read errors during response buffering
The post-RoundTrip buffering block in Perform declared a fresh `err`
via :=, shadowing the outer return error. On a truncated body or
context cancellation mid-read, the read error was silently dropped
and res.Body was left as the (now closed) original, so callers
received a 2xx Response with an unreadable body and err == nil.
Always replace res.Body with the bytes that were read (so callers see
a partial body rather than a closed reader), and propagate the read
error when no earlier error is pending.
Adjust Client.Do to handle the new (resp != nil, err != nil) case from
Perform: branch on resp == nil for the hard-failure path and wrap the
error in ErrReadBody when a response is available, preserving the
documented contract that TestClientInterfe asserts.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* fix: close error-response body in ParseError and v5preview do()
With Config.DisableResponseBuffering=true, Perform returns the live
http.Response.Body. ParseError() drained it via io.ReadAll but never
called Close(), so the connection was not returned to http.Transport's
idle pool until GC ran the finalizer. Under sustained 4xx/5xx load this
exhausts FDs.
Close the body in ParseError after reading (a no-op NopCloser in the
buffered case), and drain+close in the v5preview do() branch where
dataPointer == nil and ParseError is bypassed entirely. The legacy
opensearchapi and plugins/{ism,security} do() helpers already route the
non-nil-body case through ParseError, so they're covered by the same
change.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* fix(transport): classify Perform body-read errors via sentinel, keep v5preview error body readable
Address review feedback on #859:
- Add opensearchtransport.ErrResponseBodyRead sentinel; Perform wraps
response-buffering read failures with it. Client.Do now classifies via
errors.Is so an unrelated transport error returned alongside a response
(e.g. context cancellation during retry backoff after a retryable status)
is no longer mislabeled ErrReadBody. errors.Is(err, context.Canceled)
still holds, but the misleading "failed to read body" prefix is gone.
- v5preview do() no-decode error path reads the body to EOF then re-wraps
it in a NopCloser instead of discarding it, keeping resp.Body readable and
consistent with the ParseError path while still freeing the connection
under DisableResponseBuffering.
- Document the (resp != nil, err != nil) invariant on the Interface contract
and Perform godoc: callers must treat resp == nil, not err != nil, as the
signal for a hard transport failure.
- Scope the CHANGELOG body-close claim to non-buffered mode (in the default
buffered mode Perform already drains and closes the body).
- Add unit tests for the gzip nil-poisoning fix, header-override suppression
(asserting len == 1, which the prior value-only test could not detect),
body-read error surfacing, the Do classification split, and the v5preview
body-readability regression.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* chore(osgen): silence gochecknoglobals on decodeEquivalentGroups lookup table
Pre-existing lint warning introduced in f95f305 (#844); the table is
a static codegen lookup intentionally kept package-level next to its
doc comment and the funcs that consult it.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* fix(transport,signer): drain bodies on raw RoundTrip paths and close request body on signer read error
Follow-up to the review on #859, addressing the pre-existing instances of the
same "close/drain the body" bug class that Jakob's audit surfaced. These are on
direct RoundTrip paths that lack Perform's response-buffering safety net, so
closing a partially-read body genuinely defeats HTTP keep-alive.
- cluster_health.go stats poller and discovery.go's /_cat/shards,
/_cluster/state/metadata, and /_nodes paths now drain to EOF before close via
a deferred drain-then-close. This covers both the non-200 early returns and
the json.Decode success paths, which stop at the end of the JSON value without
consuming trailing bytes. The drain/close is inlined at each defer (rather
than extracted to a helper) so bodyclose can verify the close statically.
- opensearch.Response.String() is now non-consuming: it restores Body with an
in-memory reader after rendering, so logging a response no longer empties a
body other code expects to read. Receiver changed to a pointer so the restore
is visible to callers (Response is always used as *Response).
- The AWS v1 and v2 signers now close the request body on the read-error path
in hexEncodedSha256OfRequest.
- Add tests: Response.String non-consuming, and both signers closing the
request body when the read fails.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* refactor(transport): split Perform into Stream + buffering wrapper; drop DisableResponseBuffering
Add (*Client).Stream(req) holding the full request/retry/RoundTrip loop
with seed fallback, returning the raw RoundTrip body. The caller owns
reading and closing res.Body; the (res != nil, err != nil) invariant and
the req.URL.Host rewrite side effect are preserved.
(*Client).Perform becomes a thin buffering wrapper over Stream: io.ReadAll
+ bytes.Reader + NopCloser, with body-read failures surfaced via
ErrResponseBodyRead. Marked Deprecated for removal in v5.
Add (*opensearch.Client).Stream as a passthrough so raw consumers do not
need to type-assert c.Transport. The transport's Stream is exposed via
the new opensearch.Streamer interface; callers reach it through
ErrTransportMissingMethodStream when the configured transport does not
satisfy it. Mark (*opensearch.Client).Perform as deprecated.
Remove the untagged DisableResponseBuffering field from opensearch.Config
and opensearchtransport.Config, the disableResponseBuffering struct field,
and the constructor assignment. Perform now always buffers; Stream never
does. Add a TODO on opensearchtransport.Interface noting v5 should add
Stream and remove Perform.
Replace the two DisableResponseBuffering test sites with table-driven
TestPerformStreamBuffering and TestStreamNilBody covering both entry
points, body lifecycle, and the req.URL.Host rewrite. Preserve
TestPerformSurfacesBodyReadError and TestDoPerformErrorClassification.
Migrate opensearchutil/bulk_indexer_integration_test.go's raw Perform
polling loop to the typed client.Cluster.Health call so the new Perform
deprecation does not trigger staticcheck SA1019.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* build(lint): run golangci-lint across all build-tag combinations
golangci-lint compiles one point in the build-tag space per run, so a single
invocation can never lint every file. Two mutually-exclusive boolean axes
partition the tree:
- integration vs !integration: every unit *_test.go is `!integration`; every
integration test is `integration`. Setting `integration` drops all unit
test files from the type-check; omitting it drops all integration ones.
- multinode vs !multinode (within integration): the single-node integration
files are `!multinode`; the multinode ones require `multinode`.
make lint.local and the CI workflow previously passed a single tag set that
included `integration` and `multinode`, so they silently skipped every unit
test file AND every single-node integration file -- those were never linted.
Feature tags (core, plugins, plugin_security, plugin_index_management) are
pure-OR alternatives that union harmlessly into every run, so complete coverage
needs one run per (integration, multinode) combination. Introduce
GOLANGCI_LINT_TAG_SETS enumerating the three runs, loop over it in lint.local
and the Docker `linters` target, and add a matrix to the CI workflow.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* test(lint): resolve issues surfaced by linting all build-tag combinations
Running golangci-lint across every (integration, multinode) build-tag
combination (see the preceding build(lint) commit) compiles source files
that the previous single-tag-set invocation never type-checked -- every
!integration unit test and every !multinode single-node integration test.
That exposed pre-existing lint findings in files that had never been linted.
This commit clears them; it is a test/quality cleanup with no runtime
behavior change.
- testifylint: replace assert.* error assertions with require.* (require-error)
across opensearchapi, opensearchtransport, opensearchutil, plugins, signer,
and root tests; switch require.IsType on error values to ErrorAs; convert
exact float comparisons to require.InDelta (float-compare).
- staticcheck: pass t.Context() instead of a nil context in
opensearch_integration_test.go (SA1012); drop a dead append (SA4010).
- gci: fix the import grouping in opensearch_integration_test.go.
- thelper: add t.Helper() to table-driven check closures in error_test.go
and api_indices_response_test.go.
- tparallel: call t.Parallel() in the TestConnectionPoolPromotion subtests.
- gosec: validate PPROF_ADDR and stop echoing the env value into a log call
in connection_benchmark_test.go (G706).
- gocritic: avoid append-to-different-slice aliasing in discovery_internal_test.go.
- unparam: drop the always-nil roles arg path and unused name param in test
helpers, and assert WriteTo's byte count so its result is used; add a
RolePolicy test that exercises a dead, role-bearing connection.
- goconst: replace repeated action/policy-name string literals with constants
in opensearchutil/bulk_indexer.go and opensearchtransport policy files.
- lll: wrap over-long JSON fixtures and string literals.
No behavior change; test-only.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* refactor(osgen): emit net/http method constants in generated Req tests
The Req-test fragment template hard-coded the HTTP method as a quoted
string literal (e.g. "POST"), so every generated *_gen_test.go pinned
the wantMethod field to a magic string. The dispatch and plugin
fragments already route methods through HTTPMethodConst to produce
http.MethodPost et al.; the test fragment was the lone holdout.
Wire the existing HTTPMethodConst helper into the reqTest template via
a methodExpr func and add net/http to the fragment's import set so the
emitted test files reference http.MethodPost instead of "POST". Unknown
methods still fall through to a quoted literal, preserving today's
behavior for non-standard verbs.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* chore(v5preview): regenerate Req tests to use net/http method constants
Regenerated output of the osgen test fragment change: every wantMethod
literal in v5preview/opensearchapi/*_gen_test.go switches from a quoted
string ("GET", "POST", ...) to the corresponding net/http constant
(http.MethodGet, http.MethodPost, ...), and net/http is added to each
file's import set.
No behavioral change — the emitted constants resolve to the same
strings at runtime; this commit is purely the result of re-running
osgen after the template update.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* remove unused file
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* fix(osgen): make errwrap catalogs functions to drop mutable globals
errwrap.Wrappers and errwrap.OperationWrappers were exported mutable package
vars carrying //nolint:gochecknoglobals directives. Convert both to functions
that return a fresh slice/map. This removes the gochecknoglobals findings
honestly (no suppression), and makes the read-only catalogs immutable from a
caller's perspective -- callers can no longer mutate the shared backing
slice/map. The only consumers are in-package (For, sortedCanonical); update
them to call the functions.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* chore(osgen): drop dead body-drain in plugin do() no-decode path
opensearch.Do routes through the buffered (*opensearchtransport.Client).Perform,
so resp.Body in the plugin do[T] helpers is already an io.NopCloser over
a bytes.Reader -- the connection has been drained and returned to the
pool. The no-decode error-path drain added in PR #859 was only meaningful
when DisableResponseBuffering=true, which has been removed.
Strip the drain from the cmd/osgen plugin client template and the four
hand-written copies (opensearchapi, v5preview/opensearchapi,
plugins/security, plugins/ism). The helper now reads:
if resp.IsError() {
if dataPointer != nil {
return resp, opensearch.ParseError(resp)
}
return resp, fmt.Errorf("status: %s", resp.Status())
}
with a doc comment noting resp.Body has already been buffered and closed
by Perform.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* chore(v5preview): regenerate plugin client_gen.go
Output of `make regen` after the cmd/osgen template change in the
preceding commit. No hand edits.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* docs(buffering): rewrite guide for Do[T] vs Stream
Rewrite guides/response_buffering.md around the two entry points:
opensearch.Do[T] (typed, buffered, default; SDK owns the body) versus
opensearchtransport.Client.Stream (raw, unbuffered, caller owns the body).
Document the reason there is intentionally no typed streaming helper, and
update the proxy example to use client.Stream(req).
CHANGELOG: replace the DisableResponseBuffering "Added" entry with the
Stream/Client.Stream "Added" entry; add a Deprecated entry for Perform on
both opensearchtransport.Client and opensearch.Client (removal in v5);
drop the stale DisableResponseBuffering reference from the #859 Fixed
entry.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* test(benchmarks): fix goroutine leak and stateful body in transport benches
Three correctness/quality fixes on the transport-adjacent benchmarks
surfaced while running locally with PGO collection. No production code
changes.
opensearch_benchmark_test.go (BenchmarkClient/Create client with defaults):
Each iteration constructed a fresh opensearch.NewClient, which spawns
the transport's per-client cluster-health and node-stats goroutines.
Nothing closed them, so goroutines leaked linearly with b.N and at
high iteration counts the Go runtime starved the bench loop itself.
Close the underlying *opensearchtransport.Client in each iteration so
the background goroutines exit.
opensearchtransport/opensearchtransport_benchmark_test.go (BenchmarkTransport):
- The pre-existing FakeTransport stored a single *http.Response with a
strings.Reader body and returned the same pointer from every
RoundTrip. strings.Reader is stateful: after the first Perform
drained it the next iteration saw EOF, so the bench was measuring
the EOF-handling path rather than steady-state Perform. Build a
fresh response (with a fresh body) per RoundTrip.
- Hoist opensearchtransport.New out of the per-iteration loop. Real
callers build one transport per process; constructing one per
iteration both inflates the measurement and (on this branch) leaks
health-check goroutines. Disable the load-shedding poller via
NodeStatsInterval = -1 so its tick rate doesn't bleed into the
measurement.
opensearchtransport/logger_benchmark_test.go (BenchmarkTransportLogger):
Same construction-per-iteration anti-pattern across all four
Text/Text-Body/JSON/JSON-Body sub-benches. Collapse the four
copy-pasted bodies into a single closure, hoist New out of the loop,
add b.Cleanup to close the transport, and disable the load-shedding
poller. The Text-Body case had a separate bug: it called
res.Body.Close() before io.ReadAll(res.Body), so the read always
returned 0 bytes against a closed body and the len < 13 branch was
silently flagged. Read first, then close.
Drive-by: switch error format verbs from %s to %q in the touched
Fatalf/Errorf sites for safer rendering of error chains.
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
* feat(github/workflows): pin remaining actions to SHA (#882)
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
---------
Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
Co-authored-by: Jakob <jakob.hahn@hetzner.com>
Summary
Replace
Config.ReturnQueryErrors(a single boolean) with a 15-biterrmask.ErrorMaskbitfield, where each bit corresponds to one wrapper schema in the proposedx-error-responsesOpenAPI spec extension. Generators read those annotations, sov5preview/opensearchapigets the same partial-failure handling that v4 has had as hand-written code. The same branch also reworks discriminated-union decoding (single-pass merge for success|error unions, lazyAs<T>()for discriminator-less aggregation/suggest unions) and makes node discovery blocking.Closes #816 (final piece of the gh-816 burn-down stack: #804 / #815 / #801 already merged).
Why
OpenSearch returns HTTP 200 for partial successes -- bulk item failures, search-shard failures, single-doc replica failures, MSearch sub-response errors -- so callers must remember a second check after
err == nil. v4'sReturnQueryErrorsbool flipped all partial-failure conversions on or off as one switch; users wanting shard-level errors but tolerating bulk item failures (or vice versa) had no way to express it.The bitfield gives one bit per wire shape. Setting a bit masks (suppresses) that category; the unset state reports it.
errmask.Emptyreports everything;errmask.Allmasks everything.Lifecycle
Config.Errors == nildefaulterrmask.AllConfig.Errors: errmask.New().errmask.EmptyConfig.ErrorsorOPENSEARCH_GO_ERROR_MASK.Config.ErrorsandOPENSEARCH_GO_ERROR_MASKremoved. Behavior is unconditionallyerrmask.Empty(report everything).Usability
The caller surface is a typed error return + a typed response. Both are non-nil on partial failure: the response is fully populated even when the error fires.
Config.Errorsis a*errmask.ErrorMask; because the named values are constants (not addressable), build the pointer witherrmask.New(...).For ops that can fire multiple wrapper categories on a single response (MSearch, MSearchTemplate), the package-level
opensearchapi.Errorshelper flattens single- and multi-error returns so oneswitchhandles both:opensearchapi.Errors(nil)returnsnil; a non-partial err returns a single-element slice. It only flattens recognized partial-failure wrappers, so a joined non-partial error keeps its top-level identity.What changed
New: public
errmaskpackagegithub.com/opensearch-project/opensearch-go/v4/errmask, importable by external consumers.ErrorMaskwith one bit per wrapper schema:BulkItems,SearchShards,WriteShards,BroadcastShards,NodeFailures,BulkByScrollFailures,TaskFailures,MultiSearchItems,MultiDocItems,SnapshotCreateShardFailures,SnapshotGetShardFailures,SimulateDocFailures,RankEvalFailures,IngestionShardFailures,PitNodeFailures.errmask.Empty(zero value),errmask.All,errmask.None/errmask.Unknownaliases forEmpty, andNew(bits ...ErrorMask) *ErrorMaskto build theConfig.Errorspointer (New()= report everything).Has, canonicalString(), andParse(s, base)accepting comma-separated+/-snake_case tokens (e.g.+all,-bulk_items). Unknown tokens are dropped (forward-compatible) and reported via the debug logger.v4
opensearchapi/Config.Errors *errmask.ErrorMask(pointer disambiguates "use the version's default" from "explicitEmpty").Config.ReturnQueryErrorsis removed.OPENSEARCH_GO_ERROR_MASK.MultiSearchItemError,MSearchErrors,MSearchTemplateErrors(Go 1.20+ multi-error viaUnwrap() []error).RequireSuccessRateevaluates every category a multi-error carries (not just the first match).BulkItemFailures,SearchShardFailures,WriteShardFailures,MultiSearchItemFailures) +PartialFailures(mask).opensearchapi.Errors(err) []error.v5preview/opensearchapi
errors.gomirrors v4 using the spec-driven types:BulkRespItem,ShardSearchFailure,ErrorRespBase(embedded inMultiSearchItemFailure).api.gowiresConfig.ErrorsandclientInit(rootClient, mask).PartialFailures(mask); dispatch is trivial, with the per-op container referenced only when 2+ wrapper categories can fire.Discriminated-union decoding (osgen)
BulkByScrollTaskStatus | ErrorCause).As<T>()for discriminator-less, caller-keyed map-valued unions (aggregation/suggest result families): raw bytes retained, decoded on demand into the requested type.UnmarshalJSONresets prior branch state so reused decode targets are safe.Transport
DiscoverNodesis now blocking (concurrent callers wait on the in-flight cycle); addedDiscoverNodesOnStart *bool. A waiter no longer inherits the runner'scontext.Canceledwhen its own context is healthy.Spec (
opensearch-openapi.yaml)components.schemasas_common.errors___<WrapperName>; 115 operations annotated withx-error-responses.cmd/osgenspec.go: readsx-error-responsesfrom the OpenAPI extension intoapiOperation.ErrorWrappers.errwrap: hardcoded fallback catalog for plugin operations not yet annotated upstream.ir.Operation.ErrorWrapperscarries the resolved wrapper list.emit/frag_clients.go: generatedClientstruct haserrors errmask.ErrorMask;clientInittakes the mask.emit/frag_dispatch.go: dispatch template simplified to calldata.PartialFailures(...)-- per-wrapper logic moved out of the template.emit/frag_partial_failure.go(new): emits per-Resp helper methods +PartialFailures(mask)aggregator. Each wrapper has aRenderMethodfunction plus anAppliespredicate that walks the typed response (including embedded structs via the type registry) to confirm the field path the emission references; spec annotations whose typed Resp lacks the matching field are silently skipped (e.g. v5preview'sCreateResphas no_shards, msearch's union response item resolves throughunionFromResponsesto find the success/error branches).errmask.<wrapper>for the bit,singularize(wrapper) + "Failures"for the method. No hand-maintained name maps.Generated v5preview/opensearchapi
clients_gen.gocarries the newerrors errmask.ErrorMaskfield andclientInitsignature.<Wrapper>Failures()helpers +PartialFailures(mask)aggregator, and the dispatch handler delegates to them.Migration
Existing v4 callers see no behavior change: zero-config
Client{}continues to mask all partial-failure categories (preserving pre-bitfield behavior). Callers who were settingReturnQueryErrors: true(added earlier in this PR's stack -- never shipped to a release) need to switch toErrors: &errmask.Empty.Env-var override:
Follow-ups (not in this PR)
x-error-responsesupstream so the local patch inopensearch-openapi.yamlcan be replaced by a re-bundle.RenderMethodemission yet (NodeFailures,BulkByScrollFailures,TaskFailures,MultiDocItems,SnapshotCreateShardFailures,SnapshotGetShardFailures,SimulateDocFailures,RankEvalFailures,IngestionShardFailures,PitNodeFailures)._shardsonCreateRespso theAppliesguard can stop skipping that op.NewClient(separate commit).Related
opensearch-project/opensearch-api-specification#1137(x-error-responsesextension proposal)