Files
nats-server/server/jetstream_batching_test.go
2025-12-05 11:03:31 +00:00

2751 lines
82 KiB
Go

// Copyright 2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !skip_js_tests
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"math/big"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/klauspost/compress/s2"
"github.com/nats-io/nats.go"
)
func TestJetStreamAtomicBatchPublish(t *testing.T) {
test := func(
t *testing.T,
storage StorageType,
retention RetentionPolicy,
replicas int,
) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
var pubAck JSPubAckResponse
cfg := &StreamConfig{
Name: "TEST",
Subjects: []string{"foo.*"},
Storage: storage,
Retention: retention,
Replicas: replicas,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
m := nats.NewMsg("foo.0")
m.Data = []byte("foo.0")
m.Header.Set("Nats-Batch-Id", "uuid")
// Publish with atomic publish disabled.
rmsg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_NotNil(t, pubAck.Error)
require_Error(t, pubAck.Error, NewJSAtomicPublishDisabledError())
// Enable atomic publish.
cfg.AllowAtomicPublish = true
_, err = jsStreamUpdate(t, nc, cfg)
require_NoError(t, err)
// Publish without batch sequence errors.
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Error(t, pubAck.Error, NewJSAtomicPublishMissingSeqError())
// Publish a batch, misses start.
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "2")
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Error(t, pubAck.Error, NewJSAtomicPublishIncompleteBatchError())
// Publish a "batch" which immediately commits.
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Equal(t, pubAck.Sequence, 1)
require_Equal(t, pubAck.BatchId, "uuid")
require_Equal(t, pubAck.BatchSize, 1)
// Reset commit.
m.Header.Del("Nats-Batch-Commit")
// Publish a "batch" which has gaps.
require_NoError(t, nc.PublishMsg(m))
m.Header.Set("Nats-Batch-Sequence", "3")
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Error(t, pubAck.Error, NewJSAtomicPublishIncompleteBatchError())
// Publish a batch of N messages.
m.Header.Del("Nats-Batch-Commit")
for seq, batch := uint64(1), uint64(5); seq <= batch; seq++ {
m.Subject = fmt.Sprintf("foo.%d", seq)
m.Data = []byte(m.Subject)
m.Header.Set("Nats-Batch-Sequence", strconv.FormatUint(seq, 10))
// If not commit.
if seq != batch {
require_NoError(t, nc.PublishMsg(m))
continue
}
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Equal(t, pubAck.Sequence, 6)
require_Equal(t, pubAck.BatchId, "uuid")
require_Equal(t, pubAck.BatchSize, 5)
}
// Validate stream contents.
if retention != InterestPolicy {
for i := 0; i < 6; i++ {
rsm, err := js.GetMsg("TEST", uint64(i+1))
require_NoError(t, err)
subj := fmt.Sprintf("foo.%d", i)
require_Equal(t, rsm.Subject, subj)
require_Equal(t, string(rsm.Data), subj)
}
}
}
for _, storage := range []StorageType{FileStorage, MemoryStorage} {
for _, retention := range []RetentionPolicy{LimitsPolicy, InterestPolicy, WorkQueuePolicy} {
for _, replicas := range []int{1, 3} {
t.Run(fmt.Sprintf("%s/%s/R%d", storage, retention, replicas), func(t *testing.T) {
test(t, storage, retention, replicas)
})
}
}
}
}
func TestJetStreamAtomicBatchPublishEmptyAck(t *testing.T) {
test := func(
t *testing.T,
storage StorageType,
retention RetentionPolicy,
replicas int,
) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc := clientConnectToServer(t, c.randomServer())
defer nc.Close()
var pubAck JSPubAckResponse
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo.*"},
Storage: storage,
Retention: retention,
Replicas: replicas,
AllowAtomicPublish: true,
})
require_NoError(t, err)
// Publish a batch of N messages.
for seq, batch := uint64(1), uint64(5); seq <= batch; seq++ {
m := nats.NewMsg(fmt.Sprintf("foo.%d", seq))
m.Data = []byte(m.Subject)
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", strconv.FormatUint(seq, 10))
commit := seq == batch
if commit {
m.Header.Set("Nats-Batch-Commit", "1")
}
rmsg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
// If not commit we should receive an empty ack for flow control.
if !commit {
require_Len(t, len(rmsg.Data), 0)
continue
}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Equal(t, pubAck.Sequence, 5)
require_Equal(t, pubAck.BatchId, "uuid")
require_Equal(t, pubAck.BatchSize, 5)
}
}
for _, storage := range []StorageType{FileStorage, MemoryStorage} {
for _, retention := range []RetentionPolicy{LimitsPolicy, InterestPolicy, WorkQueuePolicy} {
for _, replicas := range []int{1, 3} {
t.Run(fmt.Sprintf("%s/%s/R%d", storage, retention, replicas), func(t *testing.T) {
test(t, storage, retention, replicas)
})
}
}
}
}
func TestJetStreamAtomicBatchPublishLimits(t *testing.T) {
streamMaxBatchInflightPerStream = 1
streamMaxBatchInflightTotal = 1
streamMaxBatchSize = 2
streamMaxBatchTimeout = 500 * time.Millisecond
defer func() {
streamMaxBatchInflightPerStream = streamDefaultMaxBatchInflightPerStream
streamMaxBatchInflightTotal = streamDefaultMaxBatchInflightTotal
streamMaxBatchSize = streamDefaultMaxBatchSize
streamMaxBatchTimeout = streamDefaultMaxBatchTimeout
}()
test := func(t *testing.T, replicas int) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc := clientConnectToServer(t, c.randomServer())
defer nc.Close()
var pubAck JSPubAckResponse
cfg := &StreamConfig{
Name: "FOO",
Subjects: []string{"foo"},
Storage: FileStorage,
Retention: LimitsPolicy,
Replicas: replicas,
AllowAtomicPublish: true,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
// For testing total server-wide maximum inflight batches.
cfg = &StreamConfig{
Name: "BAR",
Subjects: []string{"bar"},
Storage: FileStorage,
Retention: LimitsPolicy,
Replicas: replicas,
AllowAtomicPublish: true,
}
_, err = jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
// A batch ID must not exceed the maximum length.
for _, length := range []int{64, 65} {
longBatchId := strings.Repeat("A", length)
m := nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", longBatchId)
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
if length <= 64 {
require_True(t, pubAck.Error == nil)
} else {
require_NotNil(t, pubAck.Error)
require_Error(t, pubAck.Error, NewJSAtomicPublishInvalidBatchIDError())
}
}
// One batch is inflight.
m := nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
require_NoError(t, nc.PublishMsg(m))
// Another batch moves over the threshold and batch is denied.
m = nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "exceeds_threshold")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Error(t, pubAck.Error, NewJSAtomicPublishIncompleteBatchError())
// Another batch on a different stream moves over the server-wide threshold and batch is denied.
m = nats.NewMsg("bar")
m.Header.Set("Nats-Batch-Id", "bar")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Error(t, pubAck.Error, NewJSAtomicPublishIncompleteBatchError())
// The first batch should now time out.
sl := c.streamLeader(globalAccountName, "FOO")
mset, err := sl.globalAccount().lookupStream("FOO")
require_NoError(t, err)
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
mset.mu.RLock()
batches := mset.batches
mset.mu.RUnlock()
if batches == nil {
return errors.New("batches not found")
}
batches.mu.Lock()
groups := len(batches.group)
batches.mu.Unlock()
if groups != 0 {
return fmt.Errorf("expected 0 groups, got %d", groups)
}
return nil
})
// Publishing to the batch should also error since it timed out.
m = nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "2")
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Error(t, pubAck.Error, NewJSAtomicPublishIncompleteBatchError())
// Batch should be rejected if going over the max batch size.
for _, size := range []int{streamMaxBatchSize, streamMaxBatchSize + 1} {
for i := range size {
seq := i + 1
m = nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "exceeds_threshold")
m.Header.Set("Nats-Batch-Sequence", strconv.Itoa(seq))
commit := seq == size
if !commit {
require_NoError(t, nc.PublishMsg(m))
continue
}
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
pubAck = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
if size <= streamMaxBatchSize {
require_True(t, pubAck.Error == nil)
require_Equal(t, pubAck.BatchSize, size)
} else {
require_Error(t, pubAck.Error, NewJSAtomicPublishTooLargeBatchError(streamMaxBatchSize))
}
}
}
}
t.Run("R1", func(t *testing.T) { test(t, 1) })
t.Run("R3", func(t *testing.T) { test(t, 3) })
}
func TestJetStreamAtomicBatchPublishDedupeNotAllowed(t *testing.T) {
test := func(
t *testing.T,
nc *nats.Conn,
js nats.JetStreamContext,
storage StorageType,
retention RetentionPolicy,
replicas int,
) {
cfg := &StreamConfig{
Name: "TEST",
Retention: retention,
Subjects: []string{"foo"},
Replicas: replicas,
Storage: storage,
AllowAtomicPublish: true,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
_, err = js.Publish("foo", nil, nats.MsgId("pre-existing"))
require_NoError(t, err)
var pubAck JSPubAckResponse
m := nats.NewMsg("foo")
m.Header.Set("Nats-Msg-Id", "pre-existing")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_NotNil(t, pubAck.Error)
require_Error(t, pubAck.Error, NewJSAtomicPublishContainsDuplicateMessageError())
m = nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Msg-Id", "msgId1")
require_NoError(t, nc.PublishMsg(m))
pubAck = JSPubAckResponse{}
m.Header.Set("Nats-Batch-Sequence", "2")
m.Header.Set("Nats-Msg-Id", "msgId2")
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_True(t, pubAck.Error == nil)
require_Equal(t, pubAck.BatchSize, 2)
require_Equal(t, pubAck.Sequence, 3)
require_Equal(t, pubAck.BatchId, "uuid")
}
for _, storage := range []StorageType{FileStorage, MemoryStorage} {
for _, retention := range []RetentionPolicy{LimitsPolicy, InterestPolicy, WorkQueuePolicy} {
for _, replicas := range []int{1, 3} {
t.Run(fmt.Sprintf("%s/%s/R%d", storage, retention, replicas), func(t *testing.T) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
test(t, nc, js, storage, retention, replicas)
})
}
}
}
}
func TestJetStreamAtomicBatchPublishSourceAndMirror(t *testing.T) {
test := func(t *testing.T, replicas int) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
AllowAtomicPublish: true,
Replicas: replicas,
})
require_NoError(t, err)
for seq := uint64(1); seq <= 3; seq++ {
m := nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", strconv.FormatUint(seq, 10))
commit := seq == 3
if !commit {
require_NoError(t, nc.PublishMsg(m))
continue
}
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
var pubAck JSPubAckResponse
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_Equal(t, pubAck.Sequence, 3)
require_Equal(t, pubAck.BatchId, "uuid")
require_Equal(t, pubAck.BatchSize, 3)
}
require_NoError(t, js.DeleteMsg("TEST", 2))
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
return checkState(t, c, globalAccountName, "TEST")
})
// Mirror can source batched messages but can't do atomic batching itself.
_, err = jsStreamCreate(t, nc, &StreamConfig{
Name: "M-no-batch",
Storage: FileStorage,
Mirror: &StreamSource{Name: "TEST"},
Replicas: replicas,
AllowAtomicPublish: true,
})
require_Error(t, err, NewJSMirrorWithAtomicPublishError())
_, err = js.AddStream(&nats.StreamConfig{
Name: "M",
Mirror: &nats.StreamSource{Name: "TEST"},
Replicas: replicas,
})
require_NoError(t, err)
_, err = js.AddStream(&nats.StreamConfig{
Name: "S",
Sources: []*nats.StreamSource{{Name: "TEST"}},
Replicas: replicas,
})
require_NoError(t, err)
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
for _, name := range []string{"M", "S"} {
if si, err := js.StreamInfo(name); err != nil {
return err
} else if si.State.Msgs != 2 {
return fmt.Errorf("expected 2 messages for stream %q, got %d", name, si.State.Msgs)
}
}
return nil
})
// Ensure the batching headers were removed when ingested into the source/mirror.
rsm, err := js.GetMsg("M", 1)
require_NoError(t, err)
require_Len(t, len(rsm.Header), 0)
rsm, err = js.GetMsg("M", 3)
require_NoError(t, err)
require_Len(t, len(rsm.Header), 0)
rsm, err = js.GetMsg("S", 1)
require_NoError(t, err)
require_Len(t, len(rsm.Header), 1)
require_Equal(t, rsm.Header.Get(JSStreamSource), "TEST 1 > > foo")
rsm, err = js.GetMsg("S", 2)
require_NoError(t, err)
require_Len(t, len(rsm.Header), 1)
require_Equal(t, rsm.Header.Get(JSStreamSource), "TEST 3 > > foo")
}
t.Run("R1", func(t *testing.T) { test(t, 1) })
t.Run("R3", func(t *testing.T) { test(t, 3) })
}
func TestJetStreamAtomicBatchPublishCleanup(t *testing.T) {
const (
Disable = iota
StepDown
Delete
Commit
)
test := func(t *testing.T, mode int) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
cfg := &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
AllowAtomicPublish: false,
Replicas: 3,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
mset.mu.RLock()
batches := mset.batches
batch := mset.batchApply
mset.mu.RUnlock()
require_True(t, batches == nil)
require_True(t, batch == nil)
// Enabling doesn't need to populate the batching state.
cfg.AllowAtomicPublish = true
_, err = jsStreamUpdate(t, nc, cfg)
require_NoError(t, err)
mset.mu.RLock()
batches = mset.batches
batch = mset.batchApply
mset.mu.RUnlock()
require_True(t, batches == nil)
require_True(t, batch == nil)
// Publish a partial batch that needs to be cleaned up.
m := nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
require_NoError(t, nc.PublishMsg(m))
// Publish another batch that commits, state should be cleaned up by default.
m.Header.Set("Nats-Batch-Id", "commit")
m.Header.Set("Nats-Batch-Commit", "1")
_, err = js.PublishMsg(m)
require_NoError(t, err)
mset.mu.RLock()
batches = mset.batches
batch = mset.batchApply
mset.mu.RUnlock()
require_NotNil(t, batches)
require_NotNil(t, batch)
batches.mu.Lock()
groups := len(batches.group)
b := batches.group["uuid"]
batches.mu.Unlock()
require_Len(t, groups, 1)
require_NotNil(t, b)
store := b.store
require_Equal(t, store.State().Msgs, 1)
clfs := mset.getCLFS()
// Should fully clean up the in-progress batch.
switch mode {
case Disable:
cfg.AllowAtomicPublish = false
_, err = jsStreamUpdate(t, nc, cfg)
require_NoError(t, err)
case StepDown:
require_NoError(t, sl.JetStreamStepdownStream(globalAccountName, "TEST"))
case Delete:
require_NoError(t, mset.delete())
case Commit:
m = nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "2")
m.Header.Set("Nats-Batch-Commit", "1")
_, err = js.PublishMsg(m)
require_NoError(t, err)
}
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
mset.mu.RLock()
batches = mset.batches
mset.mu.RUnlock()
if batches != nil {
if mode != Commit {
return fmt.Errorf("expected no batches")
}
batches.mu.Lock()
groups = len(batches.group)
batches.mu.Unlock()
if groups > 0 {
return fmt.Errorf("expected 0 groups, got %d", groups)
}
}
if msgs := store.State().Msgs; msgs != 0 {
return fmt.Errorf("expected 0 messages, got %d", msgs)
}
return nil
})
// Should clean up the batch apply state.
if mode == Disable || mode == Delete {
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
mset.mu.RLock()
batch = mset.batchApply
mset.mu.RUnlock()
nclfs := mset.getCLFS()
if batch != nil {
return fmt.Errorf("expected no batch apply")
}
if clfs != nclfs {
return fmt.Errorf("expected no change in CLFS")
}
return nil
})
}
}
t.Run("Disable", func(t *testing.T) { test(t, Disable) })
t.Run("StepDown", func(t *testing.T) { test(t, StepDown) })
t.Run("Delete", func(t *testing.T) { test(t, Delete) })
t.Run("Commit", func(t *testing.T) { test(t, Commit) })
}
func TestJetStreamAtomicBatchPublishConfigOpts(t *testing.T) {
// Defaults.
require_Equal(t, streamMaxBatchInflightPerStream, 50)
require_Equal(t, streamMaxBatchInflightTotal, 1000)
require_Equal(t, streamMaxBatchSize, 1000)
require_Equal(t, streamMaxBatchTimeout, 10*time.Second)
batchingConf := `
listen: 127.0.0.1:-1
jetstream {
store_dir: %q
limits {
batch {
max_inflight_per_stream: %d
max_inflight_total: %d
max_msgs: %d
timeout: %s
}
}
}`
cf := createConfFile(t, []byte(fmt.Sprintf(batchingConf, t.TempDir(), 10, 20, 100, "5s")))
s, _ := RunServerWithConfig(cf)
defer s.Shutdown()
opts := s.getOpts()
require_Equal(t, opts.JetStreamLimits.MaxBatchInflightPerStream, 10)
require_Equal(t, opts.JetStreamLimits.MaxBatchInflightTotal, 20)
require_Equal(t, opts.JetStreamLimits.MaxBatchSize, 100)
require_Equal(t, opts.JetStreamLimits.MaxBatchTimeout, 5*time.Second)
// Reloading is not supported, that would potentially mean dropping random batches when lowering limits.
changeCurrentConfigContentWithNewContent(t, cf, []byte(fmt.Sprintf(batchingConf, t.TempDir(), 20, 40, 200, "10s")))
require_Error(t, s.Reload(), fmt.Errorf("config reload not supported for JetStreamLimits"))
}
func TestJetStreamAtomicBatchPublishDenyHeaders(t *testing.T) {
test := func(t *testing.T, replicas int) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
cfg := &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
AllowAtomicPublish: true,
Replicas: replicas,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
// We might support these headers later on, but for now error.
for key, value := range map[string]string{
"Nats-Expected-Last-Msg-Id": "msgId",
} {
t.Run(key, func(t *testing.T) {
m := nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Batch-Commit", "1")
m.Header.Set(key, value)
_, err = js.PublishMsg(m)
require_Error(t, err, NewJSAtomicPublishUnsupportedHeaderBatchError(key))
})
}
}
t.Run("R1", func(t *testing.T) { test(t, 1) })
t.Run("R3", func(t *testing.T) { test(t, 3) })
}
func TestJetStreamAtomicBatchPublishStageAndCommit(t *testing.T) {
type BatchItem struct {
subject string
header nats.Header
msg []byte
err error
}
type BatchTest struct {
title string
allowRollup bool
denyPurge bool
allowTTL bool
allowMsgCounter bool
allowMsgSchedules bool
discardNew bool
discardNewPerSubj bool
init func(mset *stream)
batch []BatchItem
validate func(mset *stream, commit bool)
}
tests := []BatchTest{
{
title: "dedupe-distinct",
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMsgId: {"foo"}}},
{subject: "bar", header: nats.Header{JSMsgId: {"bar"}}},
},
validate: func(mset *stream, commit bool) {
require_Equal(t, mset.checkMsgId("foo") != nil, commit)
require_Equal(t, mset.checkMsgId("bar") != nil, commit)
},
},
{
title: "dedupe",
init: func(mset *stream) {
mset.storeMsgId(&ddentry{id: "foo", seq: 0, ts: time.Now().UnixNano()})
},
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMsgId: {"foo"}}, err: errMsgIdDuplicate},
},
validate: func(mset *stream, commit bool) {
require_NotNil(t, mset.checkMsgId("foo"))
},
},
{
title: "dedupe-staged",
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMsgId: {"foo"}}},
{subject: "foo", header: nats.Header{JSMsgId: {"foo"}}, err: errMsgIdDuplicate},
},
validate: func(mset *stream, commit bool) {
require_Equal(t, mset.checkMsgId("foo") != nil, commit)
},
},
{
title: "counter-single",
allowMsgCounter: true,
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMessageIncr: {"1"}}},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_True(t, mset.clusteredCounterTotal["foo"] == nil)
} else {
counter := mset.clusteredCounterTotal["foo"]
require_NotNil(t, counter)
require_Equal(t, counter.ops, 1)
require_Equal(t, counter.total.String(), "1")
}
},
},
{
title: "counter-multiple",
allowMsgCounter: true,
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMessageIncr: {"1"}}},
{subject: "foo", header: nats.Header{JSMessageIncr: {"2"}}},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_True(t, mset.clusteredCounterTotal["foo"] == nil)
} else {
counter := mset.clusteredCounterTotal["foo"]
require_NotNil(t, counter)
require_Equal(t, counter.ops, 2)
require_Equal(t, counter.total.String(), "3")
}
},
},
{
title: "counter-pre-init",
allowMsgCounter: true,
init: func(mset *stream) {
mset.clusteredCounterTotal = map[string]*msgCounterRunningTotal{
"foo": {total: big.NewInt(1), ops: 1},
}
},
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMessageIncr: {"2"}}},
},
validate: func(mset *stream, commit bool) {
counter := mset.clusteredCounterTotal["foo"]
require_NotNil(t, counter)
if !commit {
require_Equal(t, counter.ops, 1)
require_Equal(t, counter.total.String(), "1")
} else {
require_Equal(t, counter.ops, 2)
require_Equal(t, counter.total.String(), "3")
}
},
},
{
title: "msg-schedules-disabled",
allowMsgSchedules: false,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"}},
err: NewJSMessageSchedulesDisabledError(),
},
{
subject: "foo",
header: nats.Header{JSSchedulePattern: {"disabled"}},
err: NewJSMessageSchedulesDisabledError(),
},
},
},
{
title: "msg-schedules-ttl-disabled",
allowMsgSchedules: true,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{
JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"},
JSScheduleTTL: {"1s"},
},
err: errMsgTTLDisabled,
},
},
},
{
title: "msg-schedules-ttl-invalid",
allowMsgSchedules: true,
allowTTL: true,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{
JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"},
JSScheduleTTL: {"invalid"},
},
err: NewJSMessageSchedulesTTLInvalidError(),
},
},
},
{
title: "msg-schedules-invalid-schedule",
allowMsgSchedules: true,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{JSSchedulePattern: {"invalid"}},
err: NewJSMessageSchedulesPatternInvalidError(),
},
},
},
{
title: "msg-schedules-target-mismatch",
allowMsgSchedules: true,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{
JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"},
JSScheduleTarget: {"not.matching"},
},
err: NewJSMessageSchedulesTargetInvalidError(),
},
},
},
{
title: "msg-schedules-target-must-be-literal",
allowMsgSchedules: true,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{
JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"},
JSScheduleTarget: {"foo.*"},
},
err: NewJSMessageSchedulesTargetInvalidError(),
},
},
},
{
title: "msg-schedules-target-must-be-unique",
allowMsgSchedules: true,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{
JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"},
JSScheduleTarget: {"foo"},
},
err: NewJSMessageSchedulesTargetInvalidError(),
},
},
},
{
title: "msg-schedules-rollup-disabled",
allowMsgSchedules: true,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{
JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"},
JSScheduleTarget: {"bar"},
JSMsgRollup: {JSMsgRollupSubject},
},
err: errors.New("rollup not permitted"),
},
},
},
{
title: "msg-schedules",
allowMsgSchedules: true,
allowRollup: true,
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{
JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"},
JSScheduleTarget: {"baz"},
},
},
{
subject: "bar",
header: nats.Header{
JSSchedulePattern: {"@at 1970-01-01T00:00:00Z"},
JSScheduleTarget: {"baz"},
JSMsgRollup: {JSMsgRollupSubject},
},
},
},
},
{
title: "discard-new",
discardNew: true,
batch: []BatchItem{
{subject: "foo1"},
{subject: "foo2"},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_Len(t, len(mset.inflight), 0)
} else {
require_Len(t, len(mset.inflight), 2)
require_Equal(t, *mset.inflight["foo1"], inflightSubjectRunningTotal{bytes: 20, ops: 1})
require_Equal(t, *mset.inflight["foo2"], inflightSubjectRunningTotal{bytes: 20, ops: 1})
}
},
},
{
title: "discard-new-max-msgs",
discardNew: true,
batch: []BatchItem{
{subject: "foo"},
{subject: "foo"},
{subject: "foo", err: ErrMaxMsgs},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_Len(t, len(mset.inflight), 0)
} else {
require_Len(t, len(mset.inflight), 1)
require_Equal(t, *mset.inflight["foo"], inflightSubjectRunningTotal{bytes: 19 * 3, ops: 3})
}
},
},
{
title: "discard-new-max-bytes",
discardNew: true,
batch: []BatchItem{
{subject: "foo1", msg: bytes.Repeat([]byte("A"), 10)},
{subject: "foo2", msg: bytes.Repeat([]byte("A"), 11), err: ErrMaxBytes},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_Len(t, len(mset.inflight), 0)
} else {
require_Len(t, len(mset.inflight), 2)
require_Equal(t, *mset.inflight["foo1"], inflightSubjectRunningTotal{bytes: 30, ops: 1})
require_Equal(t, *mset.inflight["foo2"], inflightSubjectRunningTotal{bytes: 31, ops: 1})
}
},
},
{
title: "discard-new-max-msgs-per-subj",
discardNew: true,
discardNewPerSubj: true,
batch: []BatchItem{
{subject: "foo"},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_Len(t, len(mset.inflight), 0)
} else {
require_Len(t, len(mset.inflight), 1)
require_Equal(t, *mset.inflight["foo"], inflightSubjectRunningTotal{bytes: 19, ops: 1})
}
},
},
{
title: "discard-new-max-msgs-per-subj-duplicate",
discardNew: true,
discardNewPerSubj: true,
batch: []BatchItem{
{subject: "foo"},
{subject: "foo", err: ErrMaxMsgsPerSubject},
},
validate: func(mset *stream, commit bool) {
require_Len(t, len(mset.inflight), 0)
},
},
{
title: "discard-new-max-msgs-per-subj-inflight",
discardNew: true,
discardNewPerSubj: true,
init: func(mset *stream) {
mset.inflight = map[string]*inflightSubjectRunningTotal{"foo": {bytes: 123, ops: 1}}
},
batch: []BatchItem{
{subject: "foo", err: ErrMaxMsgsPerSubject},
},
validate: func(mset *stream, commit bool) {
require_Len(t, len(mset.inflight), 1)
require_Equal(t, *mset.inflight["foo"], inflightSubjectRunningTotal{bytes: 123, ops: 1})
},
},
{
title: "discard-new-max-msgs-per-subj-pre-existing",
discardNew: true,
discardNewPerSubj: true,
init: func(mset *stream) {
_, _, err := mset.store.StoreMsg("foo", nil, nil, 0)
require_NoError(t, err)
},
batch: []BatchItem{
{subject: "foo", err: ErrMaxMsgsPerSubject},
},
validate: func(mset *stream, commit bool) {
require_Len(t, len(mset.inflight), 0)
},
},
{
title: "expect-last-seq",
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSExpectedLastSeq: {"0"}}},
{subject: "bar"},
},
},
{
title: "expect-last-seq-not-first",
batch: []BatchItem{
{subject: "foo"},
{subject: "bar", header: nats.Header{JSExpectedLastSeq: {"0"}}, err: errors.New("last sequence mismatch")},
},
},
{
title: "expect-last-seq-invalid-first",
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSExpectedLastSeq: {"1"}}, err: errors.New("last sequence mismatch: 1 vs 0")},
},
},
{
title: "expect-last-seq-invalid",
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSExpectedLastSeq: {"0"}}},
{subject: "bar", header: nats.Header{JSExpectedLastSeq: {"0"}}, err: errors.New("last sequence mismatch: 0 vs 1")},
},
},
{
title: "expect-per-subj-simple",
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSExpectedLastSubjSeq: {"0"}}},
{subject: "bar", header: nats.Header{JSExpectedLastSubjSeq: {"0"}}},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_Len(t, len(mset.expectedPerSubjectSequence), 0)
require_Len(t, len(mset.expectedPerSubjectInProcess), 0)
} else {
require_Len(t, len(mset.expectedPerSubjectSequence), 2)
require_Len(t, len(mset.expectedPerSubjectInProcess), 2)
require_Equal(t, mset.expectedPerSubjectSequence[0], "foo")
require_Equal(t, mset.expectedPerSubjectSequence[1], "bar")
}
},
},
{
title: "expect-per-subj-redundant-in-batch",
batch: []BatchItem{
{
subject: "foo",
header: nats.Header{JSExpectedLastSubjSeq: {"0"}},
},
// Would normally fail the batch, recognize in-process.
{
subject: "foo",
header: nats.Header{JSExpectedLastSubjSeq: {"1"}},
err: errors.New("last sequence by subject mismatch: 1 vs 0"),
},
// Redundant expected check results in an error. The subject 'foo' is also updated in the batch.
{
subject: "foo",
header: nats.Header{JSExpectedLastSubjSeq: {"0"}},
err: errors.New("last sequence by subject mismatch"),
},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_Len(t, len(mset.expectedPerSubjectSequence), 0)
require_Len(t, len(mset.expectedPerSubjectInProcess), 0)
} else {
require_Len(t, len(mset.expectedPerSubjectSequence), 1)
require_Len(t, len(mset.expectedPerSubjectInProcess), 1)
require_Equal(t, mset.expectedPerSubjectSequence[0], "foo")
}
},
},
{
title: "expect-per-subj-dupe-in-change",
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSExpectedLastSubjSeq: {"0"}, JSExpectedLastSubjSeqSubj: {"baz"}}},
{subject: "bar", header: nats.Header{JSExpectedLastSubjSeq: {"0"}, JSExpectedLastSubjSeqSubj: {"baz"}}},
},
validate: func(mset *stream, commit bool) {
if !commit {
require_Len(t, len(mset.expectedPerSubjectSequence), 0)
require_Len(t, len(mset.expectedPerSubjectInProcess), 0)
} else {
require_Len(t, len(mset.expectedPerSubjectSequence), 1)
require_Len(t, len(mset.expectedPerSubjectInProcess), 1)
require_Equal(t, mset.expectedPerSubjectSequence[1], "baz")
}
},
},
{
title: "expect-per-subj-not-first",
batch: []BatchItem{
{subject: "foo"},
// Mismatch because only the first 'foo' can have the expected check.
{
subject: "foo",
header: nats.Header{JSExpectedLastSubjSeq: {"0"}},
err: errors.New("last sequence by subject mismatch"),
},
// Mismatch because only the first 'foo' can have the expected check.
{
subject: "bar",
header: nats.Header{JSExpectedLastSubjSeq: {"0"}, JSExpectedLastSubjSeqSubj: {"foo"}},
err: errors.New("last sequence by subject mismatch"),
},
},
},
{
title: "expect-per-subj-in-process",
init: func(mset *stream) {
mset.expectedPerSubjectInProcess = map[string]struct{}{"foo": {}}
},
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSExpectedLastSubjSeq: {"10"}}, err: errors.New("last sequence by subject mismatch")},
},
validate: func(mset *stream, commit bool) {
require_Len(t, len(mset.expectedPerSubjectSequence), 0)
require_Len(t, len(mset.expectedPerSubjectInProcess), 1)
},
},
{
title: "expect-per-subj-inflight",
init: func(mset *stream) {
mset.inflight = map[string]*inflightSubjectRunningTotal{"bar": {bytes: 33, ops: 1}}
},
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSExpectedLastSubjSeq: {"10"}, JSExpectedLastSubjSeqSubj: {"bar"}}, err: errors.New("last sequence by subject mismatch")},
},
validate: func(mset *stream, commit bool) {
require_Len(t, len(mset.expectedPerSubjectSequence), 0)
require_Len(t, len(mset.expectedPerSubjectInProcess), 0)
require_Len(t, len(mset.inflight), 1)
},
},
{
title: "rollup-deny-purge",
allowRollup: true,
denyPurge: true,
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMsgRollup: {JSMsgRollupSubject}}, err: errors.New("rollup not permitted")},
},
},
{
title: "rollup-invalid",
allowRollup: true,
denyPurge: false,
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMsgRollup: {"invalid"}}, err: fmt.Errorf("rollup value invalid: %q", "invalid")},
},
},
{
title: "rollup-all-first",
allowRollup: true,
denyPurge: false,
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMsgRollup: {JSMsgRollupAll}}},
},
},
{
title: "rollup-all-not-first",
allowRollup: true,
denyPurge: false,
batch: []BatchItem{
{subject: "foo"},
{subject: "bar", header: nats.Header{JSMsgRollup: {JSMsgRollupAll}}, err: errors.New("batch rollup all invalid")},
},
},
{
title: "rollup-sub-unique",
allowRollup: true,
denyPurge: false,
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMsgRollup: {JSMsgRollupSubject}}},
{subject: "bar", header: nats.Header{JSMsgRollup: {JSMsgRollupSubject}}},
},
},
{
title: "rollup-sub-overlap",
allowRollup: true,
denyPurge: false,
batch: []BatchItem{
{subject: "foo", header: nats.Header{JSMsgRollup: {JSMsgRollupSubject}}},
{subject: "foo", header: nats.Header{JSMsgRollup: {JSMsgRollupSubject}}, err: errors.New("batch rollup sub invalid")},
},
},
}
for _, test := range tests {
t.Run(test.title, func(t *testing.T) {
s := RunBasicJetStreamServer(t)
defer s.Shutdown()
nc := clientConnectToServer(t, s)
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Storage: MemoryStorage,
AllowAtomicPublish: true,
Subjects: []string{"foo", "bar", "baz"},
})
require_NoError(t, err)
mset, err := s.globalAccount().lookupStream("TEST")
require_NoError(t, err)
if test.init != nil {
test.init(mset)
}
var (
discard DiscardPolicy
discardNewPer bool
maxMsgs int64 = -1
maxMsgsPer int64 = -1
maxBytes int64 = -1
)
if test.discardNew {
discard, maxMsgs, maxBytes = DiscardNew, 2, 60
}
if test.discardNewPerSubj {
require_True(t, test.discardNew)
discardNewPer, maxMsgs, maxBytes, maxMsgsPer = true, -1, -1, 1
}
diff := &batchStagedDiff{}
mset.clMu.Lock()
defer mset.clMu.Unlock()
var hadError bool
for _, m := range test.batch {
var hdr []byte
for key, values := range m.header {
for _, value := range values {
hdr = genHeader(hdr, key, value)
}
}
_, _, _, _, err = checkMsgHeadersPreClusteredProposal(diff, mset, m.subject, hdr, m.msg, false, "TEST", nil, test.allowRollup, test.denyPurge, test.allowTTL, test.allowMsgCounter, test.allowMsgSchedules, discard, discardNewPer, -1, maxMsgs, maxMsgsPer, maxBytes)
if m.err != nil {
require_Error(t, err, m.err)
} else if err != nil {
require_NoError(t, err)
}
if err != nil {
hadError = true
}
if test.validate != nil {
test.validate(mset, false)
}
mset.clseq++
}
if test.validate != nil && !hadError {
diff.commit(mset)
test.validate(mset, true)
}
})
}
}
func TestJetStreamAtomicBatchPublishHighLevelRollback(t *testing.T) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Replicas: 3,
Storage: FileStorage,
Retention: InterestPolicy,
MaxMsgs: 1,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
requireEmpty := func() {
mset.ddMu.Lock()
ddarr, ddmap := len(mset.ddarr), len(mset.ddmap)
mset.ddMu.Unlock()
require_Len(t, ddarr, 0)
require_Len(t, ddmap, 0)
mset.clMu.Lock()
inflight, subjSeq, subjInProcess := len(mset.inflight), len(mset.expectedPerSubjectSequence), len(mset.expectedPerSubjectInProcess)
mset.clMu.Unlock()
require_Len(t, inflight, 0)
require_Len(t, subjSeq, 0)
require_Len(t, subjInProcess, 0)
}
requireEmpty()
m := nats.NewMsg("foo")
m.Header.Set("Nats-Msg-Id", "dedupe")
m.Header.Set("Nats-Expected-Last-Subject-Sequence", "1")
_, err = js.PublishMsg(m)
require_Error(t, err, NewJSStreamWrongLastSequenceError(0))
requireEmpty()
}
func TestJetStreamAtomicBatchPublishExpectedPerSubject(t *testing.T) {
type TestKind int
const (
OnlyFirst TestKind = iota
Redundant
NotFirst
)
test := func(t *testing.T, kind TestKind) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
_, err = js.Publish("foo", nil)
require_NoError(t, err)
m := nats.NewMsg("foo")
if kind != NotFirst {
m.Header.Set("Nats-Expected-Last-Subject-Sequence", "1")
}
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
require_NoError(t, nc.PublishMsg(m))
var pubAck JSPubAckResponse
// Redundant expected headers are okay, as long as they reflect the state prior to the batch.
m = nats.NewMsg("foo")
if kind == Redundant || kind == NotFirst {
m.Header.Set("Nats-Expected-Last-Subject-Sequence", "1")
}
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "2")
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
if kind == Redundant || kind == NotFirst {
require_NotNil(t, pubAck.Error)
require_Error(t, pubAck.Error, NewJSStreamWrongLastSequenceConstantError())
return
}
require_True(t, pubAck.Error == nil)
require_Equal(t, pubAck.Sequence, 3)
require_Equal(t, pubAck.BatchSize, 2)
// The first message still contains the expected headers.
rsm, err := js.GetMsg("TEST", 2)
require_NoError(t, err)
require_Equal(t, rsm.Header.Get("Nats-Batch-Id"), "uuid")
require_Equal(t, rsm.Header.Get("Nats-Batch-Sequence"), "1")
require_Equal(t, rsm.Header.Get("Nats-Expected-Last-Subject-Sequence"), "1")
// The second message doesn't have the expected headers, as the condition was already checked
// and seems inconsistent when getting the message afterward.
rsm, err = js.GetMsg("TEST", 3)
require_NoError(t, err)
require_Equal(t, rsm.Header.Get("Nats-Batch-Id"), "uuid")
require_Equal(t, rsm.Header.Get("Nats-Batch-Sequence"), "2")
require_Equal(t, rsm.Header.Get("Nats-Expected-Last-Subject-Sequence"), _EMPTY_)
}
t.Run("single", func(t *testing.T) { test(t, OnlyFirst) })
t.Run("redundant", func(t *testing.T) { test(t, Redundant) })
t.Run("not-first", func(t *testing.T) { test(t, NotFirst) })
}
func TestJetStreamAtomicBatchPublishSingleServerRecovery(t *testing.T) {
s := RunBasicJetStreamServer(t)
defer s.Shutdown()
nc := clientConnectToServer(t, s)
defer nc.Close()
cfg := &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Retention: LimitsPolicy,
Replicas: 1,
AllowAtomicPublish: true,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
// Manually construct and store a batch, so it doesn't immediately commit.
mset, err := s.globalAccount().lookupStream("TEST")
require_NoError(t, err)
mset.mu.Lock()
batches := &batching{}
mset.batches = batches
mset.mu.Unlock()
batches.mu.Lock()
b, err := batches.newBatchGroup(mset, "uuid")
if err != nil {
batches.mu.Unlock()
require_NoError(t, err)
}
hdr1 := genHeader(nil, "Nats-Batch-Id", "uuid")
hdr1 = genHeader(hdr1, "Nats-Batch-Sequence", "1")
_, _, err = b.store.StoreMsg("foo", hdr1, nil, 0)
if err != nil {
batches.mu.Unlock()
require_NoError(t, err)
}
hdr2 := genHeader(nil, "Nats-Batch-Id", "uuid")
hdr2 = genHeader(hdr2, "Nats-Batch-Sequence", "2")
hdr2 = genHeader(hdr2, "Nats-Batch-Commit", "1")
_, _, err = b.store.StoreMsg("foo", hdr2, nil, 0)
if err != nil {
batches.mu.Unlock()
require_NoError(t, err)
}
commitReady := b.readyForCommit()
batches.mu.Unlock()
require_True(t, commitReady)
// Simulate the first message of the batch is committed.
err = mset.processJetStreamMsg("foo", _EMPTY_, hdr1, nil, 0, 0, nil, false, true)
require_NoError(t, err)
// Simulate a hard kill, upon recovery the rest of the batch should be applied.
port := s.opts.Port
sd := s.StoreDir()
nc.Close()
s.Shutdown()
s = RunJetStreamServerOnPort(port, sd)
defer s.Shutdown()
mset, err = s.globalAccount().lookupStream("TEST")
require_NoError(t, err)
state := mset.state()
require_Equal(t, state.Msgs, 2)
require_Equal(t, state.FirstSeq, 1)
require_Equal(t, state.LastSeq, 2)
}
func TestJetStreamAtomicBatchPublishEncode(t *testing.T) {
test := func(t *testing.T, commit bool, compress bool) {
ts := time.Now().UnixNano()
hdr := genHeader(nil, "Nats-Batch-Id", "uuid")
hdr = genHeader(hdr, "Nats-Batch-Sequence", "1")
msg := []byte("A")
if compress {
msg = bytes.Repeat(msg, compressThreshold)
}
esm := encodeStreamMsgAllowCompressAndBatch("foo", "reply", hdr, msg, 1, ts, false, "uuid", 1, commit)
buf, op := esm[1:], entryOp(esm[0])
if commit {
require_Equal(t, op, batchCommitMsgOp)
} else {
require_Equal(t, op, batchMsgOp)
}
batchId, batchSeq, op, mbuf, err := decodeBatchMsg(buf)
require_NoError(t, err)
require_Equal(t, batchId, "uuid")
require_Equal(t, batchSeq, 1)
if compress {
require_Equal(t, op, compressedStreamMsgOp)
mbuf, err = s2.Decode(nil, mbuf)
require_NoError(t, err)
} else {
require_Equal(t, op, streamMsgOp)
}
subject, reply, dhdr, dmsg, lseq, dts, sourced, err := decodeStreamMsg(mbuf)
require_NoError(t, err)
require_Equal(t, subject, "foo")
require_Equal(t, reply, "reply")
require_True(t, bytes.Equal(dhdr, hdr))
require_True(t, bytes.Equal(dmsg, msg))
require_Equal(t, lseq, 1)
require_Equal(t, dts, ts)
require_False(t, sourced)
}
t.Run("normal", func(t *testing.T) { test(t, false, false) })
t.Run("normal-compress", func(t *testing.T) { test(t, false, true) })
t.Run("commit", func(t *testing.T) { test(t, true, false) })
t.Run("commit-compress", func(t *testing.T) { test(t, true, true) })
}
// Test a batch within a single proposal, optionally combined with messages unrelated
// to the batch but within the same proposal.
func TestJetStreamAtomicBatchPublishProposeOne(t *testing.T) {
test := func(t *testing.T, combined bool) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
pubAck, err := js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 1)
var entries []*Entry
mset.clMu.Lock()
if combined {
esm := encodeStreamMsg("foo", _EMPTY_, nil, nil, mset.clseq, 0, false)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
}
msg := []byte("hello")
hdr := genHeader(nil, "Nats-Batch-Id", "uuid")
hdr = setHeader("Nats-Batch-Sequence", "1", hdr)
esm := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, msg, mset.clseq, 0, false, "uuid", 1, false)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
hdr = setHeader("Nats-Batch-Sequence", "2", hdr)
hdr = setHeader("Nats-Batch-Commit", "1", hdr)
esm = encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, msg, mset.clseq, 0, false, "uuid", 2, true)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
if combined {
esm = encodeStreamMsg("foo", _EMPTY_, nil, nil, mset.clseq, 0, false)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
}
mset.clMu.Unlock()
n := mset.raftNode().(*raft)
n.sendAppendEntry(entries)
pubAck, err = js.Publish("foo", nil)
require_NoError(t, err)
if combined {
require_Equal(t, pubAck.Sequence, 6)
} else {
require_Equal(t, pubAck.Sequence, 4)
}
}
t.Run("single", func(t *testing.T) { test(t, false) })
t.Run("combined", func(t *testing.T) { test(t, true) })
}
// Test a batch spanning multiple proposals, optionally combined with messages unrelated
// to the batch but within the same first/last proposal.
func TestJetStreamAtomicBatchPublishProposeMultiple(t *testing.T) {
test := func(t *testing.T, partial bool, combined bool) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
pubAck, err := js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 1)
var entries []*Entry
mset.clMu.Lock()
hdr := genHeader(nil, "Nats-Batch-Id", "uuid")
hdr = genHeader(hdr, "Nats-Batch-Sequence", "1")
msg := []byte("hello")
if combined {
esm := encodeStreamMsg("foo", _EMPTY_, nil, nil, mset.clseq, 0, false)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
}
esm := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, msg, mset.clseq, 0, false, "uuid", 1, false)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
mset.clMu.Unlock()
n := mset.raftNode().(*raft)
n.sendAppendEntry(entries)
mset.clMu.Lock()
hdr = setHeader("Nats-Batch-Sequence", "2", hdr)
esm = encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, msg, mset.clseq, 0, false, "uuid", 2, false)
mset.clseq++
mset.clMu.Unlock()
n.sendAppendEntry([]*Entry{newEntry(EntryNormal, esm)})
entries = nil
mset.clMu.Lock()
if !partial {
hdr = setHeader("Nats-Batch-Sequence", "3", hdr)
hdr = genHeader(hdr, "Nats-Batch-Commit", "1")
esm = encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, msg, mset.clseq, 0, false, "uuid", 3, true)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
}
if combined {
esm = encodeStreamMsg("foo", _EMPTY_, nil, nil, mset.clseq, 0, false)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
}
mset.clMu.Unlock()
n.sendAppendEntry(entries)
pubAck, err = js.Publish("foo", nil)
require_NoError(t, err)
expectedSeq := uint64(2)
if !partial {
expectedSeq += 3
}
if combined {
expectedSeq += 2
}
require_Equal(t, pubAck.Sequence, expectedSeq)
}
t.Run("partial", func(t *testing.T) { test(t, true, false) })
t.Run("partial-combined", func(t *testing.T) { test(t, true, true) })
t.Run("full", func(t *testing.T) { test(t, false, false) })
t.Run("full-combined", func(t *testing.T) { test(t, false, true) })
}
// Test a batch that was only partially proposed.
func TestJetStreamAtomicBatchPublishProposeOnePartialBatch(t *testing.T) {
maxEntries := 3
for i := range maxEntries + 1 {
t.Run(fmt.Sprintf("I-%d", i), func(t *testing.T) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
pubAck, err := js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 1)
var entries []*Entry
mset.clMu.Lock()
msg := []byte("hello")
hdr := genHeader(nil, "Nats-Batch-Id", "uuid")
for j := range 3 {
// Skip if indices equal.
if i == j {
continue
}
bseq := uint64(j + 1)
hdr = setHeader("Nats-Batch-Sequence", strconv.FormatUint(bseq, 10), hdr)
commit := bseq == uint64(maxEntries)
if commit {
hdr = setHeader("Nats-Batch-Commit", "1", hdr)
}
esm := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, msg, mset.clseq, 0, false, "uuid", bseq, commit)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
}
mset.clMu.Unlock()
n := mset.raftNode().(*raft)
n.sendAppendEntry(entries)
pubAck, err = js.Publish("foo", nil)
require_NoError(t, err)
expectedSeq := uint64(2)
if i >= maxEntries {
expectedSeq += uint64(maxEntries)
}
require_Equal(t, pubAck.Sequence, expectedSeq)
})
}
}
// Test multiple sequential batches, the first batch is partially proposed.
func TestJetStreamAtomicBatchPublishProposeMultiplePartialBatches(t *testing.T) {
test := func(t *testing.T, batchSize int, retry bool) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
pubAck, err := js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 1)
batchId1, batchId2 := "ID_1", "ID_2"
if retry {
batchId1, batchId2 = "uuid", "uuid"
}
var entries []*Entry
mset.clMu.Lock()
msg := []byte("hello")
hdr := genHeader(nil, "Nats-Batch-Id", batchId1)
hdr = setHeader("Nats-Batch-Sequence", "1", hdr)
esm := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, msg, mset.clseq, 0, false, batchId1, 1, false)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
for j := range batchSize {
bseq := uint64(j + 1)
hdr = genHeader(nil, "Nats-Batch-Id", batchId2)
hdr = setHeader("Nats-Batch-Sequence", strconv.FormatUint(bseq, 10), hdr)
commit := bseq == uint64(batchSize)
if commit {
hdr = setHeader("Nats-Batch-Commit", "1", hdr)
}
esm = encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, msg, mset.clseq, 0, false, batchId2, bseq, commit)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
}
mset.clMu.Unlock()
n := mset.raftNode().(*raft)
n.sendAppendEntry(entries)
pubAck, err = js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, uint64(2+batchSize))
// Validate the stream only committed the full batch.
rsm, err := js.GetMsg("TEST", 1)
require_NoError(t, err)
require_True(t, rsm.Header.Get("Nats-Batch-Id") == _EMPTY_)
for j := range batchSize {
rsm, err = js.GetMsg("TEST", uint64(2+j))
require_NoError(t, err)
require_Equal(t, rsm.Header.Get("Nats-Batch-Id"), batchId2)
require_Equal(t, rsm.Header.Get("Nats-Batch-Sequence"), strconv.Itoa(j+1))
}
rsm, err = js.GetMsg("TEST", uint64(2+batchSize))
require_NoError(t, err)
require_True(t, rsm.Header.Get("Nats-Batch-Id") == _EMPTY_)
mset.clMu.Lock()
clfs := mset.clfs
mset.clMu.Unlock()
require_Equal(t, clfs, 1)
}
for _, retry := range []bool{false, true} {
for i := range 2 {
batchSize := i + 1
title := fmt.Sprintf("B-%d", batchSize)
if retry {
title += "/Retry"
} else {
title += "/NoRetry"
}
t.Run(title, func(t *testing.T) {
test(t, batchSize, retry)
})
}
}
}
// Test a continuous flow of batches spanning multiple append entries can still move applied up.
// Also, test a server can become leader if the previous leader left it with a partial batch.
func TestJetStreamAtomicBatchPublishContinuousBatchesStillMoveAppliedUp(t *testing.T) {
test := func(t *testing.T, largePayload bool) {
var payload []byte
if largePayload {
// Create a large payload that can be easily compressed.
payload = make([]byte, 2*compressThreshold)
}
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
pubAck, err := js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 1)
n := mset.raftNode().(*raft)
index, commit, applied := n.Progress()
// The first batch spans two append entries, but commits.
mset.clMu.Lock()
hdr := genHeader(nil, "Nats-Batch-Id", "ID_1")
hdr = setHeader("Nats-Batch-Sequence", "1", hdr)
esm := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, payload, mset.clseq, 0, false, "ID_1", 1, false)
mset.clseq++
mset.clMu.Unlock()
n.sendAppendEntry([]*Entry{newEntry(EntryNormal, esm)})
var entries []*Entry
mset.clMu.Lock()
hdr = genHeader(nil, "Nats-Batch-Id", "ID_1")
hdr = setHeader("Nats-Batch-Sequence", "2", hdr)
esm = encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, payload, mset.clseq, 0, false, "ID_1", 2, false)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
hdr = genHeader(nil, "Nats-Batch-Id", "ID_1")
hdr = setHeader("Nats-Batch-Sequence", "3", hdr)
hdr = setHeader("Nats-Batch-Commit", "1", hdr)
esm = encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, payload, mset.clseq, 0, false, "ID_1", 3, true)
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
// The second batch doesn't commit.
hdr = genHeader(nil, "Nats-Batch-Id", "ID_2")
hdr = setHeader("Nats-Batch-Sequence", "1", hdr)
esm = encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, payload, mset.clseq, 0, false, "ID_2", 1, false)
mset.clseq++
entries = append(entries, newEntry(EntryNormal, esm))
mset.clMu.Unlock()
n.sendAppendEntry(entries)
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
n.RLock()
nindex, ncommit, processed, napplied := n.pindex, n.commit, n.processed, n.applied
n.RUnlock()
if nindex == index {
return errors.New("index not updated")
} else if ncommit == commit {
return errors.New("commit not updated")
} else if napplied == applied {
return errors.New("applied not updated")
} else if napplied == ncommit {
return errors.New("applied should not be able to equal commit yet")
} else if processed != ncommit {
return errors.New("must have processed all commits")
}
return checkState(t, c, globalAccountName, "TEST")
})
// Followers are now stranded with a partial batch, one needs to become leader
// and have the batch be rejected since it was partially proposed.
sl.Shutdown()
c.waitOnStreamLeader(globalAccountName, "TEST")
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
return checkState(t, c, globalAccountName, "TEST")
})
// Confirm the last batch gets rejected, and we are still able to publish with quorum.
pubAck, err = js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 5)
c.restartServer(sl)
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
return checkState(t, c, globalAccountName, "TEST")
})
// Publish again, now with all servers online.
pubAck, err = js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 6)
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
return checkState(t, c, globalAccountName, "TEST")
})
}
t.Run("Normal", func(t *testing.T) { test(t, false) })
t.Run("Compressed", func(t *testing.T) { test(t, true) })
}
// Test a batch that's committed but only partially applied, and the server gets hard killed.
// Upon recovery the server should skip the messages that were already applied but store the
// messages that weren't applied yet.
func TestJetStreamAtomicBatchPublishPartiallyAppliedBatchOnRecovery(t *testing.T) {
test := func(t *testing.T, largePayload bool) {
var payload []byte
expectedOp := streamMsgOp
if largePayload {
// Create a large payload that can be easily compressed.
payload = make([]byte, 2*compressThreshold)
expectedOp = compressedStreamMsgOp
}
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
pubAck, err := js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 1)
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
return checkState(t, c, globalAccountName, "TEST")
})
// Pause applies on all followers.
for _, s := range c.servers {
if s == sl {
continue
}
fmset, err := s.globalAccount().lookupStream("TEST")
require_NoError(t, err)
require_NoError(t, fmset.raftNode().PauseApply())
}
// Publish a batch.
var entries []*Entry
mset.clMu.Lock()
for seq := uint64(1); seq <= 4; seq++ {
batchCommit := seq == 4
hdr := genHeader(nil, "Nats-Batch-Id", "ID")
hdr = genHeader(hdr, "Nats-Batch-Sequence", strconv.FormatUint(seq, 10))
esm := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, hdr, payload, mset.clseq, 0, false, "ID", seq, batchCommit)
if _, _, bop, _, err := decodeBatchMsg(esm[1:]); err != nil || bop != expectedOp {
mset.clMu.Unlock()
t.Fatalf("Unexpected batch op %v: %v", bop, err)
}
entries = append(entries, newEntry(EntryNormal, esm))
mset.clseq++
}
mset.clMu.Unlock()
// Wait for the leader to have applied the batch, so the next
// publish informs the followers that it can be committed.
n := mset.raftNode().(*raft)
_, _, applied := n.Progress()
n.sendAppendEntry(entries)
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
_, _, napplied := n.Progress()
if applied == napplied {
return errors.New("batch hasn't been applied yet")
}
return nil
})
// Publish so the commit moves up on all servers.
pubAck, err = js.Publish("foo", nil)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 6)
// Simulate this follower only partially applied the batch and then got hard killed.
rs := c.randomNonStreamLeader(globalAccountName, "TEST")
mset, err = rs.globalAccount().lookupStream("TEST")
require_NoError(t, err)
ts := time.Now().UnixNano()
hdr := genHeader(nil, "Nats-Batch-Id", "ID")
hdr = genHeader(hdr, "Nats-Batch-Sequence", "1")
require_NoError(t, mset.store.StoreRawMsg("foo", hdr, nil, 2, ts, 0))
hdr = genHeader(nil, "Nats-Batch-Id", "ID")
hdr = genHeader(hdr, "Nats-Batch-Sequence", "2")
require_NoError(t, mset.store.StoreRawMsg("foo", hdr, nil, 3, ts, 0))
// Unpause applies on the remaining follower.
for _, s := range c.servers {
if s == sl || s == rs {
continue
}
fmset, err := s.globalAccount().lookupStream("TEST")
require_NoError(t, err)
fmset.raftNode().ResumeApply()
}
// Restart the "hard killed" follower, should apply the remainder of the batch upon recovery.
rs.Shutdown()
c.restartServer(rs)
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
return checkState(t, c, globalAccountName, "TEST")
})
}
t.Run("Normal", func(t *testing.T) { test(t, false) })
t.Run("Compressed", func(t *testing.T) { test(t, true) })
}
func TestJetStreamRollupIsolatedRead(t *testing.T) {
const (
DirectGet = iota
DirectBatchGet
DirectMultiGet
Consumer
)
test := func(t *testing.T, mode int) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc, js := jsClientConnect(t, c.randomServer())
defer nc.Close()
_, err := js.AddStream(&nats.StreamConfig{
Name: "TEST",
Subjects: []string{"foo.*"},
AllowRollup: true,
Replicas: 3,
})
require_NoError(t, err)
rs := c.randomNonStreamLeader(globalAccountName, "TEST")
mset, err := rs.globalAccount().lookupStream("TEST")
require_NoError(t, err)
// Reconnect to the selected server.
nc.Close()
nc, js = jsClientConnect(t, rs)
defer nc.Close()
m := nats.NewMsg("foo.0")
pubAck, err := js.PublishMsg(m)
require_NoError(t, err)
require_Equal(t, pubAck.Sequence, 1)
// mset.processJetStreamMsg will first store the new message, then update dedupe state, and then do the rollup.
// We block the replica such that it can store the new message but can't do the rollup yet.
// A read should wait for the rollup to complete.
mset.ddMu.Lock()
m.Subject = "foo.1"
m.Header.Set(JSMsgRollup, JSMsgRollupAll)
m.Header.Set(JSMsgId, "msgId")
_, _ = js.PublishMsg(m)
err = checkForErr(2*time.Second, 200*time.Millisecond, func() error {
var state StreamState
mset.store.FastState(&state)
if state.LastSeq != 2 {
return fmt.Errorf("expected last seq 2, got: %d", state.LastSeq)
}
return nil
})
if err != nil {
mset.ddMu.Unlock()
require_NoError(t, err)
}
// We're now subscribing and going to do a request, while the write is still halfway.
sub, err := nc.SubscribeSync("reply")
if err != nil {
mset.ddMu.Unlock()
require_NoError(t, err)
}
defer sub.Drain()
if err = nc.Flush(); err != nil {
mset.ddMu.Unlock()
require_NoError(t, err)
}
// Run two goroutines, one will unblock the first write after a short sleep,
// the second will do the read/consumer request.
var (
ready sync.WaitGroup
run sync.WaitGroup
cleanup sync.WaitGroup
)
ready.Add(2)
run.Add(1)
cleanup.Add(1)
go func() {
ready.Done()
defer cleanup.Done()
run.Wait()
time.Sleep(250 * time.Millisecond)
mset.ddMu.Unlock()
}()
go func() {
ready.Done()
run.Wait()
switch mode {
case DirectGet:
mset.getDirectRequest(&JSApiMsgGetRequest{LastFor: "foo.0"}, "reply")
case DirectBatchGet:
mset.getDirectRequest(&JSApiMsgGetRequest{NextFor: "foo.*", Batch: 2}, "reply")
case DirectMultiGet:
mset.getDirectRequest(&JSApiMsgGetRequest{MultiLastFor: []string{"foo.*"}, Batch: 2}, "reply")
case Consumer:
mset.addConsumer(&ConsumerConfig{DeliverSubject: "reply", Direct: true})
}
}()
// Wait for both to be ready, then run both of them.
ready.Wait()
run.Done()
msg, err := sub.NextMsg(time.Second)
// Make sure cleanup has happened before validating.
cleanup.Wait()
require_NoError(t, err)
if mode == DirectGet {
require_Equal(t, msg.Header.Get("Status"), "404")
} else if mode != Consumer {
require_Equal(t, msg.Header.Get(JSNumPending), "0")
require_Equal(t, msg.Header.Get(JSSequence), "2")
} else {
// $JS.ACK.<stream>.<consumer>.<delivered>.<sseq>.<cseq>.<tm>.<pending>
require_True(t, strings.HasPrefix(msg.Reply, jsAckPre))
tokens := strings.Split(msg.Reply, ".")
require_Len(t, len(tokens), 9)
require_Equal(t, tokens[8], "0") // pending
require_Equal(t, tokens[5], "2") // sseq
}
}
t.Run("DirectGet", func(t *testing.T) { test(t, DirectGet) })
t.Run("DirectBatchGet", func(t *testing.T) { test(t, DirectBatchGet) })
t.Run("DirectMultiGet", func(t *testing.T) { test(t, DirectMultiGet) })
t.Run("Consumer", func(t *testing.T) { test(t, Consumer) })
}
func TestJetStreamAtomicBatchPublishAdvisories(t *testing.T) {
templ := `
listen: 127.0.0.1:-1
server_name: %s
jetstream: {
max_mem_store: 2GB
max_file_store: 2GB
store_dir: '%s'
limits {
batch {
timeout: 2s
}
}
}
leaf {
listen: 127.0.0.1:-1
}
cluster {
name: %s
listen: 127.0.0.1:%d
routes = [%s]
}
# For access to system account.
accounts { $SYS { users = [ { user: "admin", pass: "s3cr3t!" } ] } }
`
test := func(t *testing.T, replicas int) {
c := createJetStreamClusterWithTemplate(t, templ, "R3S", 3)
defer c.shutdown()
nc := clientConnectToServer(t, c.randomServer())
defer nc.Close()
cfg := &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Replicas: replicas,
Storage: FileStorage,
AllowAtomicPublish: true,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
sub, err := nc.SubscribeSync(fmt.Sprintf("%s.*", JSAdvisoryStreamBatchAbandonedPre))
require_NoError(t, err)
defer sub.Drain()
require_NoError(t, nc.Flush())
// Should receive an advisory if gaps are detected.
m := nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
require_NoError(t, nc.PublishMsg(m))
m.Header.Set("Nats-Batch-Sequence", "3")
require_NoError(t, nc.PublishMsg(m))
msg, err := sub.NextMsg(time.Second)
require_NoError(t, err)
var advisory JSStreamBatchAbandonedAdvisory
require_NoError(t, json.Unmarshal(msg.Data, &advisory))
require_Equal(t, advisory.BatchId, "uuid")
require_Equal(t, advisory.Reason, BatchIncomplete)
// Should receive an advisory if the batch is too large.
count := 1002
for seq := 1; seq <= count; seq++ {
m = nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", strconv.Itoa(seq))
if seq != count {
require_NoError(t, nc.PublishMsg(m))
continue
}
var pubAck JSPubAckResponse
m.Header.Set("Nats-Batch-Commit", "1")
rmsg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
require_NoError(t, json.Unmarshal(rmsg.Data, &pubAck))
require_NotNil(t, pubAck.Error)
require_Error(t, pubAck.Error, NewJSAtomicPublishTooLargeBatchError(streamDefaultMaxBatchSize))
}
msg, err = sub.NextMsg(time.Second)
require_NoError(t, err)
advisory = JSStreamBatchAbandonedAdvisory{}
require_NoError(t, json.Unmarshal(msg.Data, &advisory))
require_Equal(t, advisory.BatchId, "uuid")
require_Equal(t, advisory.Reason, BatchLarge)
// Should receive an advisory if the batch times out and is abandoned.
m = nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
require_NoError(t, nc.PublishMsg(m))
msg, err = sub.NextMsg(3 * time.Second)
require_NoError(t, err)
advisory = JSStreamBatchAbandonedAdvisory{}
require_NoError(t, json.Unmarshal(msg.Data, &advisory))
require_Equal(t, advisory.BatchId, "uuid")
require_Equal(t, advisory.Reason, BatchTimeout)
}
for _, replicas := range []int{1, 3} {
t.Run(fmt.Sprintf("R%d", replicas), func(t *testing.T) {
test(t, replicas)
})
}
}
func TestJetStreamAtomicBatchPublishExpectedSeq(t *testing.T) {
s := RunBasicJetStreamServer(t)
defer s.Shutdown()
nc, js := jsClientConnect(t, s)
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
AllowAtomicPublish: true,
})
require_NoError(t, err)
mset, err := s.globalAccount().lookupStream("TEST")
require_NoError(t, err)
clseqAndClfs := func() (uint64, uint64) {
mset.clMu.Lock()
defer mset.clMu.Unlock()
return mset.clseq, mset.clfs
}
clseq, clfs := clseqAndClfs()
require_Equal(t, clseq, 0)
require_Equal(t, clfs, 0)
_, err = js.Publish("foo", []byte("hello"))
require_NoError(t, err)
clseq, clfs = clseqAndClfs()
require_Equal(t, clseq, 0)
require_Equal(t, clfs, 0)
// test expect last seq for standalone publish
_, err = js.Publish("foo", []byte("hello"), nats.ExpectLastSequence(1))
require_NoError(t, err)
clseq, clfs = clseqAndClfs()
require_Equal(t, clseq, 0)
require_Equal(t, clfs, 0)
// now do a single msg batch with expect last seq
m := nats.NewMsg("foo")
m.Header.Set("Nats-Expected-Last-Sequence", "2")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Batch-Commit", "1")
resp, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
var pubAck JSPubAckResponse
require_NoError(t, json.Unmarshal(resp.Data, &pubAck))
if pubAck.Error != nil {
t.Fatalf("Commit error: %v", pubAck.Error)
}
require_Equal(t, pubAck.Sequence, 3)
require_Equal(t, pubAck.BatchSize, 1)
clseq, clfs = clseqAndClfs()
require_Equal(t, clseq, 0)
require_Equal(t, clfs, 0)
}
// Test two different batches within the same append entry.
func TestJetStreamAtomicBatchPublishPartialBatchInSharedAppendEntry(t *testing.T) {
test := func(t *testing.T, commit bool) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc := clientConnectToServer(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
js := sl.getJetStream()
esm1 := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, nil, []byte("b1"), 0, 0, false, "b1", 1, false)
esm2 := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, nil, []byte("b2"), 1, 0, false, "b2", 1, commit)
ce := newCommittedEntry(2, []*Entry{
newEntry(EntryNormal, esm1),
newEntry(EntryNormal, esm2),
})
_, err = js.applyStreamEntries(mset, ce, false)
require_NoError(t, err)
mset.mu.RLock()
batch := mset.batchApply
mset.mu.RUnlock()
batch.mu.Lock()
entryStart, maxApplied := batch.entryStart, batch.maxApplied
batch.mu.Unlock()
if !commit {
require_Equal(t, entryStart, 1)
require_Equal(t, maxApplied, 1)
return
}
require_Equal(t, entryStart, 0)
require_Equal(t, maxApplied, 0)
require_Equal(t, mset.lastSeq(), 1)
var smv StoreMsg
sm, err := mset.store.LoadMsg(1, &smv)
require_NoError(t, err)
require_Equal(t, string(sm.buf), "b2")
}
t.Run("NoCommit", func(t *testing.T) { test(t, false) })
t.Run("Commit", func(t *testing.T) { test(t, true) })
}
func TestJetStreamAtomicBatchPublishRejectPartialBatchOnLeaderChange(t *testing.T) {
c := createJetStreamClusterExplicit(t, "R3S", 3)
defer c.shutdown()
nc := clientConnectToServer(t, c.randomServer())
defer nc.Close()
_, err := jsStreamCreate(t, nc, &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 3,
AllowAtomicPublish: true,
})
require_NoError(t, err)
sl := c.streamLeader(globalAccountName, "TEST")
mset, err := sl.globalAccount().lookupStream("TEST")
require_NoError(t, err)
// Propose a partial batch that doesn't commit.
var entries []*Entry
for i := range uint64(10) {
esm := encodeStreamMsgAllowCompressAndBatch("foo", _EMPTY_, nil, nil, i, 0, false, "b", i+1, false)
entries = append(entries, newEntry(EntryNormal, esm))
}
n := mset.raftNode()
require_NoError(t, n.ProposeMulti(entries))
// Wait for all servers to have received and populated the partial batch.
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
for _, s := range c.servers {
mset2, err := s.globalAccount().lookupStream("TEST")
if err != nil {
return err
}
mset2.mu.RLock()
batch := mset2.batchApply
mset2.mu.RUnlock()
if batch == nil {
return fmt.Errorf("expected batch to be set")
}
batch.mu.Lock()
count := batch.count
batch.mu.Unlock()
if count != 10 {
return fmt.Errorf("expected batch count to be 10, got: %d", count)
}
}
return nil
})
// Trigger a leader change and ensure the CLFS gets accounted for.
require_NoError(t, n.StepDown())
checkFor(t, 2*time.Second, 200*time.Millisecond, func() error {
for _, s := range c.servers {
mset, err = s.globalAccount().lookupStream("TEST")
if err != nil {
return err
}
if clfs := mset.getCLFS(); clfs != 10 {
return fmt.Errorf("expected clfs to be 10, got: %d", clfs)
}
}
return nil
})
}
func TestJetStreamAtomicBatchPublishPersistModeAsync(t *testing.T) {
s := RunBasicJetStreamServer(t)
defer s.Shutdown()
nc := clientConnectToServer(t, s)
defer nc.Close()
cfg := &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: FileStorage,
Replicas: 1,
AllowAtomicPublish: true,
PersistMode: AsyncPersistMode,
}
_, err := jsStreamCreate(t, nc, cfg)
require_Error(t, err, NewJSStreamInvalidConfigError(fmt.Errorf("async persist mode is not supported with atomic batch publish")))
}
func TestJetStreamAtomicBatchPublishExpectedLastSubjectSequence(t *testing.T) {
s := RunBasicJetStreamServer(t)
defer s.Shutdown()
nc, js := jsClientConnect(t, s)
defer nc.Close()
cfg := &StreamConfig{
Name: "TEST",
Subjects: []string{"foo.*"},
Storage: FileStorage,
Replicas: 1,
AllowAtomicPublish: true,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
_, err = js.Publish("foo.A", nil)
require_NoError(t, err)
_, err = js.Publish("foo.B", nil)
require_NoError(t, err)
m := nats.NewMsg("foo.A")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Expected-Last-Sequence", "2")
m.Header.Set("Nats-Expected-Last-Subject-Sequence", "1")
m.Header.Set("Nats-Expected-Last-Subject-Sequence-Subject", "foo.A")
msg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
require_Len(t, len(msg.Data), 0) // Empty ack.
m = nats.NewMsg("foo.B")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "2")
m.Header.Set("Nats-Batch-Commit", "1")
m.Header.Set("Nats-Expected-Last-Subject-Sequence", "2")
m.Header.Set("Nats-Expected-Last-Subject-Sequence-Subject", "foo.B")
msg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
var resp JSPubAckResponse
require_NoError(t, json.Unmarshal(msg.Data, &resp))
require_True(t, resp.Error == nil)
require_Equal(t, resp.PubAck.Sequence, 4)
require_Equal(t, resp.PubAck.BatchId, "uuid")
require_Equal(t, resp.PubAck.BatchSize, 2)
}
func TestJetStreamAtomicBatchPublishCommitUnsupported(t *testing.T) {
s := RunBasicJetStreamServer(t)
defer s.Shutdown()
nc := clientConnectToServer(t, s)
defer nc.Close()
cfg := &StreamConfig{
Name: "TEST",
Subjects: []string{"foo"},
Storage: MemoryStorage,
Replicas: 1,
AllowAtomicPublish: true,
}
_, err := jsStreamCreate(t, nc, cfg)
require_NoError(t, err)
mset, err := s.globalAccount().lookupStream("TEST")
require_NoError(t, err)
var resp JSPubAckResponse
for _, unsupportedCommit := range []string{"", "unsupported", "0"} {
m := nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
_, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
m.Header.Set("Nats-Batch-Sequence", "2")
m.Header.Set("Nats-Batch-Commit", unsupportedCommit)
msg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
resp = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(msg.Data, &resp))
require_True(t, resp.Error != nil)
require_Error(t, resp.Error, NewJSAtomicPublishInvalidBatchCommitError())
// Confirm no batches are left.
mset.mu.RLock()
batches := mset.batches
mset.mu.RUnlock()
batches.mu.Lock()
groups := len(batches.group)
batches.mu.Unlock()
require_Len(t, groups, 0)
}
// The required API level should allow the batch to be rejected.
m := nats.NewMsg("foo")
m.Header.Set("Nats-Batch-Id", "uuid")
m.Header.Set("Nats-Batch-Sequence", "1")
m.Header.Set("Nats-Batch-Commit", "1")
m.Header.Set("Nats-Required-Api-Level", strconv.Itoa(math.MaxInt))
msg, err := nc.RequestMsg(m, time.Second)
require_NoError(t, err)
resp = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(msg.Data, &resp))
require_True(t, resp.Error != nil)
require_Error(t, resp.Error, NewJSRequiredApiLevelError())
// If required API level check passes, the header should be stripped.
m.Header.Set("Nats-Required-Api-Level", "0")
msg, err = nc.RequestMsg(m, time.Second)
require_NoError(t, err)
resp = JSPubAckResponse{}
require_NoError(t, json.Unmarshal(msg.Data, &resp))
require_True(t, resp.Error == nil)
require_Equal(t, resp.PubAck.Sequence, 1)
sm, err := mset.getMsg(1)
require_NoError(t, err)
require_Len(t, len(sliceHeader(JSRequiredApiLevel, sm.Header)), 0)
}