Skip to content

Commit 73325d4

Browse files
committed
Preserve in-flight shards in shardMap across /_cat/shards relocation window
/_cat/shards parsing skips RELOCATING and INITIALIZING rows (see discovery.go:getShardPlacement), so a shard mid-relocation is absent from placement.ShardToNodes for the duration of the relocation window. The previous unconditional store would clobber a complete cached map with this partial response, leaving Shards[N] == nil for the in-flight shard and disabling shard-exact routing for it. Carry forward entries the new response is missing: a shard in RELOCATING state still serves reads from the source until the destination flips to STARTED, so the prior entry remains a valid routing target for the relocation window. New data wins where present; entries beyond NumberOfPrimaryShards are dropped so a shrunk index doesn't retain unreachable high-numbered shards. Use CompareAndSwap to bound the visible Load+merge+Store window: if a concurrent writer (e.g. an external DiscoverNodes racing the discoveryRefreshLoop's cat-only refresh) replaced the map, we drop our update rather than clobber theirs - both racers derived from near-simultaneous /_cat/shards calls so retrying buys nothing. Signed-off-by: Sean Chittenden <sean.chittenden@crowdstrike.com>
1 parent 79e82a0 commit 73325d4

2 files changed

Lines changed: 204 additions & 16 deletions

File tree

opensearchtransport/index_routing_cache.go

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package opensearchtransport
88

99
import (
10+
"maps"
1011
"math"
1112
"sync"
1213
"sync/atomic"
@@ -310,22 +311,7 @@ func (c *indexSlotCache) updateFromDiscovery(shardPlacement map[string]*indexSha
310311
nodes := placement.Nodes
311312
slot.shardNodeCount.Store(int32(len(nodes))) //nolint:gosec // Node count bounded by cluster size.
312313
slot.shardNodeNames.Store(&nodes)
313-
314-
// Update per-shard-number map for murmur3 routing.
315-
// Only store a new map when placement data is complete.
316-
// When ShardToNodes is empty (e.g., transient /_cat/shards
317-
// response during shard relocation), preserve the existing
318-
// map rather than niling it -- a stale shard map still
319-
// routes correctly; a nil one disables shard-exact routing
320-
// entirely until the next successful discovery cycle.
321-
if len(placement.ShardToNodes) > 0 {
322-
sm := &indexShardMap{
323-
NumberOfPrimaryShards: placement.NumberOfPrimaryShards,
324-
RoutingNumShards: placement.RoutingNumShards,
325-
Shards: placement.ShardToNodes,
326-
}
327-
slot.shardMap.Store(sm)
328-
}
314+
slot.mergeShardMap(placement)
329315
} else {
330316
// Index not in shard data -- may have been deleted or
331317
// the /_cat/shards response was truncated/stale. Clear
@@ -379,6 +365,59 @@ func (c *indexSlotCache) updateFromDiscovery(shardPlacement map[string]*indexSha
379365
}
380366
}
381367

368+
// mergeShardMap updates the cached per-shard placement map from a fresh
369+
// /_cat/shards-derived placement.
370+
//
371+
// /_cat/shards rows for RELOCATING (source) and INITIALIZING (destination)
372+
// shards are skipped during parsing, so a shard in flight between two nodes
373+
// is absent from placement.ShardToNodes for the duration of the relocation.
374+
// Replacing the cached map with a partial response would leave Shards[N] == nil
375+
// for the in-flight shard and disable shard-exact routing for it.
376+
//
377+
// Carry forward entries the new response is missing: a shard in RELOCATING
378+
// state still serves reads from the source node until the destination flips
379+
// to STARTED, so the prior entry remains a valid routing target for the
380+
// relocation window. New data wins where present; entries beyond the current
381+
// primary count are dropped so a shrunk index doesn't retain unreachable
382+
// high-numbered shards.
383+
//
384+
// Empty placements are ignored to preserve the previous map across transient
385+
// /_cat/shards failures (a stale map still routes correctly; a nil one
386+
// disables shard-exact routing entirely).
387+
//
388+
// The Load+CAS pattern bounds the visible window for the merge: if another
389+
// writer (e.g. an external DiscoverNodes racing the discoveryRefreshLoop's
390+
// cat-only refresh) replaced the map between this Load and the CAS, the swap
391+
// fails and we drop our update. Both racers derived their placements from
392+
// near-simultaneous /_cat/shards calls, so the winner's data is at least as
393+
// fresh as ours; retrying would just produce equivalent work.
394+
func (slot *indexSlot) mergeShardMap(placement *indexShardPlacement) {
395+
if len(placement.ShardToNodes) == 0 {
396+
return
397+
}
398+
399+
old := slot.shardMap.Load()
400+
merged := placement.ShardToNodes
401+
if old != nil && len(old.Shards) > len(merged) {
402+
merged = make(map[int]*shardNodes, len(old.Shards))
403+
maps.Copy(merged, old.Shards)
404+
maps.Copy(merged, placement.ShardToNodes)
405+
if placement.NumberOfPrimaryShards > 0 {
406+
for k := range merged {
407+
if k >= placement.NumberOfPrimaryShards {
408+
delete(merged, k)
409+
}
410+
}
411+
}
412+
}
413+
414+
slot.shardMap.CompareAndSwap(old, &indexShardMap{
415+
NumberOfPrimaryShards: placement.NumberOfPrimaryShards,
416+
RoutingNumShards: placement.RoutingNumShards,
417+
Shards: merged,
418+
})
419+
}
420+
382421
// shardNodeNameSet returns the set of node names hosting shards for an index,
383422
// or nil if unknown. Used by rendezvousTopK for shard-aware partitioning.
384423
func (slot *indexSlot) shardNodeNameSet() map[string]struct{} {

opensearchtransport/index_routing_cache_internal_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,155 @@ func TestIndexSlotCacheUpdateFromDiscovery(t *testing.T) {
295295
})
296296
}
297297

298+
// TestIndexSlotCacheShardMapMerge covers the merge semantics of updateFromDiscovery
299+
// for the per-shard placement map: a new partial /_cat/shards response (e.g. during
300+
// a relocation window where source/destination rows are skipped) must not clobber
301+
// shards from a previously-complete cached map, while a genuine shrink in primary
302+
// count must drop unreachable old entries.
303+
func TestIndexSlotCacheShardMapMerge(t *testing.T) {
304+
t.Parallel()
305+
306+
tests := []struct {
307+
name string
308+
prior *indexShardMap // nil means no prior map
309+
incoming *indexShardPlacement
310+
wantCount int
311+
wantPrims int
312+
wantShard map[int]*shardNodes // expected entries; nil means "must not be present"
313+
}{
314+
{
315+
name: "no prior map: store as-is",
316+
prior: nil,
317+
incoming: &indexShardPlacement{
318+
NumberOfPrimaryShards: 2,
319+
RoutingNumShards: 256,
320+
ShardToNodes: map[int]*shardNodes{
321+
0: {Primary: "node0"},
322+
1: {Primary: "node1"},
323+
},
324+
},
325+
wantCount: 2,
326+
wantPrims: 2,
327+
wantShard: map[int]*shardNodes{
328+
0: {Primary: "node0"},
329+
1: {Primary: "node1"},
330+
},
331+
},
332+
{
333+
name: "complete new placement overwrites prior",
334+
prior: &indexShardMap{
335+
NumberOfPrimaryShards: 2,
336+
RoutingNumShards: 256,
337+
Shards: map[int]*shardNodes{
338+
0: {Primary: "old0"},
339+
1: {Primary: "old1"},
340+
},
341+
},
342+
incoming: &indexShardPlacement{
343+
NumberOfPrimaryShards: 2,
344+
RoutingNumShards: 256,
345+
ShardToNodes: map[int]*shardNodes{
346+
0: {Primary: "new0"},
347+
1: {Primary: "new1"},
348+
},
349+
},
350+
wantCount: 2,
351+
wantPrims: 2,
352+
wantShard: map[int]*shardNodes{
353+
0: {Primary: "new0"},
354+
1: {Primary: "new1"},
355+
},
356+
},
357+
{
358+
name: "relocation window: missing shard preserved from prior",
359+
prior: &indexShardMap{
360+
NumberOfPrimaryShards: 3,
361+
RoutingNumShards: 384,
362+
Shards: map[int]*shardNodes{
363+
0: {Primary: "node0", Replicas: []string{"node1"}},
364+
1: {Primary: "node1", Replicas: []string{"node2"}},
365+
2: {Primary: "node2", Replicas: []string{"node0"}},
366+
},
367+
},
368+
incoming: &indexShardPlacement{
369+
NumberOfPrimaryShards: 3,
370+
RoutingNumShards: 384,
371+
ShardToNodes: map[int]*shardNodes{
372+
// Shard 1 absent: source is RELOCATING, destination is INITIALIZING,
373+
// both filtered out by the /_cat/shards parser.
374+
0: {Primary: "node0", Replicas: []string{"node3"}}, // replica moved
375+
2: {Primary: "node2", Replicas: []string{"node0"}},
376+
},
377+
},
378+
wantCount: 3,
379+
wantPrims: 3,
380+
wantShard: map[int]*shardNodes{
381+
0: {Primary: "node0", Replicas: []string{"node3"}}, // new wins
382+
1: {Primary: "node1", Replicas: []string{"node2"}}, // carried forward
383+
2: {Primary: "node2", Replicas: []string{"node0"}}, // new wins
384+
},
385+
},
386+
{
387+
name: "shrunk index drops shards beyond new primary count",
388+
prior: &indexShardMap{
389+
NumberOfPrimaryShards: 4,
390+
RoutingNumShards: 512,
391+
Shards: map[int]*shardNodes{
392+
0: {Primary: "node0"},
393+
1: {Primary: "node1"},
394+
2: {Primary: "node2"},
395+
3: {Primary: "node3"},
396+
},
397+
},
398+
incoming: &indexShardPlacement{
399+
NumberOfPrimaryShards: 2,
400+
RoutingNumShards: 512,
401+
ShardToNodes: map[int]*shardNodes{
402+
0: {Primary: "node0"},
403+
1: {Primary: "node1"},
404+
},
405+
},
406+
wantCount: 2,
407+
wantPrims: 2,
408+
wantShard: map[int]*shardNodes{
409+
0: {Primary: "node0"},
410+
1: {Primary: "node1"},
411+
2: nil,
412+
3: nil,
413+
},
414+
},
415+
}
416+
417+
for _, tt := range tests {
418+
t.Run(tt.name, func(t *testing.T) {
419+
t.Parallel()
420+
c := newIndexSlotCache(indexSlotCacheConfig{})
421+
slot := c.getOrCreate("idx")
422+
if tt.prior != nil {
423+
slot.shardMap.Store(tt.prior)
424+
}
425+
426+
c.updateFromDiscovery(map[string]*indexShardPlacement{"idx": tt.incoming}, 10, time.Now())
427+
428+
sm := slot.shardMap.Load()
429+
require.NotNil(t, sm)
430+
require.Equal(t, tt.wantPrims, sm.NumberOfPrimaryShards)
431+
require.Len(t, sm.Shards, tt.wantCount)
432+
433+
for shard, want := range tt.wantShard {
434+
if want == nil {
435+
require.NotContains(t, sm.Shards, shard, "shard %d should be dropped", shard)
436+
continue
437+
}
438+
got, ok := sm.Shards[shard]
439+
require.True(t, ok, "shard %d missing from merged map", shard)
440+
require.Equal(t, want.Primary, got.Primary, "shard %d primary", shard)
441+
require.Equal(t, want.Replicas, got.Replicas, "shard %d replicas", shard)
442+
}
443+
})
444+
}
445+
}
446+
298447
func TestIndexSlotShardNodeNameSet(t *testing.T) {
299448
t.Parallel()
300449

0 commit comments

Comments
 (0)