Skip to content

Commit 0a67033

Browse files
fix: BulkIndexer NumAdded overcount on cancelled Add() (#784)
fix(opensearchutil): correct BulkIndexerStats.NumAdded overcount on cancelled context (#784) NumAdded was incremented unconditionally in Add(), even when the caller's context was already cancelled and the item never reached the queue. Move the increment after the successful queue send and introduce BulkAddFailCount on the <-ctx.Done() branch so callers can distinguish queued items from items the indexer rejected. Migrate every bulkIndexerStats counter to sync/atomic.Uint64 typed values so future direct access fails to compile rather than only surfacing under `-race`. Add a unit test that verifies, with an already-cancelled context, every Add() call ends up in exactly one of NumAdded or BulkAddFailCount. Fixes #783 Co-authored-by: evgenigourvitch <evgeni.evgeni@gmail.com> Signed-off-by: evgenigourvitch <evgeni.evgeni@gmail.com> Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 755636d commit 0a67033

3 files changed

Lines changed: 72 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
208208

209209
### Fixed
210210

211+
- Fix `BulkIndexerStats.NumAdded` overcounting items rejected by `Add()` when the caller's context is cancelled before the item could be enqueued: increment `NumAdded` only after the queue accepts the item, and add a new `BulkAddFailCount` counter for items dropped on the `<-ctx.Done()` branch. Migrate `bulkIndexerStats` fields to `sync/atomic.Uint64` typed values so future direct access is a compile-time error rather than a `-race`-only finding ([#783](https://github.com/opensearch-project/opensearch-go/issues/783))
211212
- Add typed response-format defaults for `v5preview/opensearchapi/` cat, list, ppl, and sql operations: when the caller leaves `Format` unset, the SDK now emits the value the typed Resp struct expects (`json` for cat/list/explain, `jdbc` for ppl/sql query) instead of letting the server fall back to a default the JSON decoder cannot handle.
212213
- Replace `WaitForAllNodesReady` inline `require.Eventually` loop with a layered readiness FSM (`internal/test/readiness`) that observes per-node progression through `LayerTCP -> LayerHTTP -> LayerClusterJoin -> LayerStatsReady`, records transitions including regressions, and emits a structured per-node diagnostic with the full last cat-nodes response on timeout. Per-layer budgets are tuned for CI pessimism (cold JVM startup is the long pole); total budget for `TargetClusterReady` is 6.5 minutes. ([#650](https://github.com/opensearch-project/opensearch-go/issues/650))
213214
- Fix bulk indexer HTML-escaping `_id` and `routing` values containing `<`, `>`, or `&` characters, causing OpenSearch to store escaped values (e.g., `\u003croot_account\u003e` stored instead of `<root_account>`), leading to duplicate documents, unreachable data on read-by-ID paths, and potential shard routing mismatches. Present since the `json.Marshal` migration in 2021 (commit `3da59092`). Replace `json.Marshal` with `json.NewEncoder` + `SetEscapeHTML(false)` in `opensearchutil.worker.writeMeta` and `opensearchutil.JSONReader`; replace per-worker `aux []byte` with `sync.Pool`-backed `*bytes.Buffer`; add table-driven test coverage for `writeMeta` edge cases and refactor remaining `TestBulkIndexer` subtests to table-driven `require`-based style ([#824](https://github.com/opensearch-project/opensearch-go/pull/824))

opensearchutil/bulk_indexer.go

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,15 @@ type BulkIndexerConfig struct {
105105

106106
// BulkIndexerStats represents the indexer statistics.
107107
type BulkIndexerStats struct {
108-
NumAdded uint64
109-
NumFlushed uint64
110-
NumFailed uint64
111-
NumIndexed uint64
112-
NumCreated uint64
113-
NumUpdated uint64
114-
NumDeleted uint64
115-
NumRequests uint64
108+
NumAdded uint64
109+
BulkAddFailCount uint64 // Items rejected by Add() because the caller's context was cancelled before the item could be enqueued.
110+
NumFlushed uint64
111+
NumFailed uint64
112+
NumIndexed uint64
113+
NumCreated uint64
114+
NumUpdated uint64
115+
NumDeleted uint64
116+
NumRequests uint64
116117
}
117118

118119
// BulkIndexerItem represents an indexer item.
@@ -169,14 +170,15 @@ type bulkIndexer struct {
169170
}
170171

171172
type bulkIndexerStats struct {
172-
numAdded uint64
173-
numFlushed uint64
174-
numFailed uint64
175-
numIndexed uint64
176-
numCreated uint64
177-
numUpdated uint64
178-
numDeleted uint64
179-
numRequests uint64
173+
numAdded atomic.Uint64
174+
bulkAddFailCount atomic.Uint64
175+
numFlushed atomic.Uint64
176+
numFailed atomic.Uint64
177+
numIndexed atomic.Uint64
178+
numCreated atomic.Uint64
179+
numUpdated atomic.Uint64
180+
numDeleted atomic.Uint64
181+
numRequests atomic.Uint64
180182
}
181183

182184
// NewBulkIndexer creates a new bulk indexer.
@@ -232,15 +234,15 @@ func NewBulkIndexer(cfg BulkIndexerConfig) (BulkIndexer, error) {
232234
//
233235
// Adding an item after a call to Close() will panic.
234236
func (bi *bulkIndexer) Add(ctx context.Context, item BulkIndexerItem) error {
235-
atomic.AddUint64(&bi.stats.numAdded, 1)
236-
237237
select {
238238
case <-ctx.Done():
239+
bi.stats.bulkAddFailCount.Add(1)
239240
if bi.config.OnError != nil {
240241
bi.config.OnError(ctx, ctx.Err())
241242
}
242243
return ctx.Err()
243244
case bi.queue <- item:
245+
bi.stats.numAdded.Add(1)
244246
}
245247

246248
return nil
@@ -283,14 +285,15 @@ func (bi *bulkIndexer) Close(ctx context.Context) error {
283285
// Stats returns indexer statistics.
284286
func (bi *bulkIndexer) Stats() BulkIndexerStats {
285287
return BulkIndexerStats{
286-
NumAdded: atomic.LoadUint64(&bi.stats.numAdded),
287-
NumFlushed: atomic.LoadUint64(&bi.stats.numFlushed),
288-
NumFailed: atomic.LoadUint64(&bi.stats.numFailed),
289-
NumIndexed: atomic.LoadUint64(&bi.stats.numIndexed),
290-
NumCreated: atomic.LoadUint64(&bi.stats.numCreated),
291-
NumUpdated: atomic.LoadUint64(&bi.stats.numUpdated),
292-
NumDeleted: atomic.LoadUint64(&bi.stats.numDeleted),
293-
NumRequests: atomic.LoadUint64(&bi.stats.numRequests),
288+
NumAdded: bi.stats.numAdded.Load(),
289+
BulkAddFailCount: bi.stats.bulkAddFailCount.Load(),
290+
NumFlushed: bi.stats.numFlushed.Load(),
291+
NumFailed: bi.stats.numFailed.Load(),
292+
NumIndexed: bi.stats.numIndexed.Load(),
293+
NumCreated: bi.stats.numCreated.Load(),
294+
NumUpdated: bi.stats.numUpdated.Load(),
295+
NumDeleted: bi.stats.numDeleted.Load(),
296+
NumRequests: bi.stats.numRequests.Load(),
294297
}
295298
}
296299

@@ -388,7 +391,7 @@ func (w *worker) run(ctx context.Context) {
388391
item.OnFailure(ctx, item, opensearchapi.BulkRespItem{}, err)
389392
}
390393

391-
atomic.AddUint64(&w.bi.stats.numFailed, 1)
394+
w.bi.stats.numFailed.Add(1)
392395
w.mu.Unlock()
393396

394397
continue
@@ -398,7 +401,7 @@ func (w *worker) run(ctx context.Context) {
398401
if item.OnFailure != nil {
399402
item.OnFailure(ctx, item, opensearchapi.BulkRespItem{}, err)
400403
}
401-
atomic.AddUint64(&w.bi.stats.numFailed, 1)
404+
w.bi.stats.numFailed.Add(1)
402405
w.mu.Unlock()
403406

404407
continue
@@ -528,7 +531,7 @@ func (w *worker) flush(ctx context.Context) error {
528531
w.bi.config.DebugLogger.Printf("[worker-%03d] Flush: %s\n", w.id, w.buf.String())
529532
}
530533

531-
atomic.AddUint64(&w.bi.stats.numRequests, 1)
534+
w.bi.stats.numRequests.Add(1)
532535
req := opensearchapi.BulkReq{
533536
Index: w.bi.config.Index,
534537
Body: w.buf,
@@ -576,22 +579,22 @@ func (w *worker) flush(ctx context.Context) error {
576579
info = v
577580
}
578581
if info.Error != nil || info.Status >= http.StatusMultipleChoices {
579-
atomic.AddUint64(&w.bi.stats.numFailed, 1)
582+
w.bi.stats.numFailed.Add(1)
580583
if item.OnFailure != nil {
581584
item.OnFailure(ctx, item, info, nil)
582585
}
583586
} else {
584-
atomic.AddUint64(&w.bi.stats.numFlushed, 1)
587+
w.bi.stats.numFlushed.Add(1)
585588

586589
switch op {
587590
case "index":
588-
atomic.AddUint64(&w.bi.stats.numIndexed, 1)
591+
w.bi.stats.numIndexed.Add(1)
589592
case "create":
590-
atomic.AddUint64(&w.bi.stats.numCreated, 1)
593+
w.bi.stats.numCreated.Add(1)
591594
case "delete":
592-
atomic.AddUint64(&w.bi.stats.numDeleted, 1)
595+
w.bi.stats.numDeleted.Add(1)
593596
case "update":
594-
atomic.AddUint64(&w.bi.stats.numUpdated, 1)
597+
w.bi.stats.numUpdated.Add(1)
595598
}
596599

597600
if item.OnSuccess != nil {
@@ -604,7 +607,7 @@ func (w *worker) flush(ctx context.Context) error {
604607
}
605608

606609
func (w *worker) handleBulkError(ctx context.Context, err error) error {
607-
atomic.AddUint64(&w.bi.stats.numFailed, uint64(len(w.items)))
610+
w.bi.stats.numFailed.Add(uint64(len(w.items)))
608611

609612
// info (the response item) will be empty since the bulk request failed
610613
var info opensearchapi.BulkRespItem

opensearchutil/bulk_indexer_internal_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,38 @@ func TestBulkIndexerContext(t *testing.T) {
499499
require.True(t, gotDeadline, "expected at least one context.DeadlineExceeded in: %q", errs)
500500
},
501501
},
502+
{
503+
name: "Add does not increment NumAdded when context is already cancelled",
504+
run: func(t *testing.T) {
505+
client, _ := opensearchapi.NewClient(opensearchapi.Config{Client: opensearch.Config{Transport: &mockTransport{}}})
506+
bi, _ := NewBulkIndexer(BulkIndexerConfig{NumWorkers: 1, Client: client})
507+
508+
ctx, cancel := context.WithCancel(context.Background())
509+
cancel()
510+
511+
// select{} chooses randomly between the two ready cases when the
512+
// queue still has room, so we cannot assert every Add fails. We can
513+
// assert the bookkeeping invariant: each Add ends up in exactly one
514+
// of NumAdded or BulkAddFailCount, never both, never neither.
515+
const numAttempts = 50
516+
var nilReturns, errReturns uint64
517+
for range numAttempts {
518+
if err := bi.Add(ctx, BulkIndexerItem{Action: "index", DocumentID: "cancelled"}); err == nil {
519+
nilReturns++
520+
} else {
521+
require.ErrorIs(t, err, context.Canceled)
522+
errReturns++
523+
}
524+
}
525+
require.NoError(t, bi.Close(context.Background()))
526+
527+
stats := bi.Stats()
528+
require.Equal(t, nilReturns, stats.NumAdded, "NumAdded must equal the number of Add() calls that returned nil")
529+
require.Equal(t, errReturns, stats.BulkAddFailCount, "BulkAddFailCount must equal the number of Add() calls that returned ctx.Err()")
530+
require.Equal(t, uint64(numAttempts), stats.NumAdded+stats.BulkAddFailCount, "every Add() must be accounted for exactly once")
531+
require.Greater(t, errReturns, uint64(0), "at least one Add() should fail when context is already cancelled")
532+
},
533+
},
502534
{
503535
name: "Close returns error on cancelled context",
504536
run: func(t *testing.T) {

0 commit comments

Comments
 (0)