Files
seaweedfs/weed/pb/filer_pb_tail_test.go
T
Chris LuandDevin dfd9ecab05 mount: resume metadata-log replay where a dropped stream stopped (#12079)
* pb: checkpoint metadata-log replay progress per settled entry

A chunk-mode subscriber's resume cursor only advanced after the whole
LogFileRefs batch drained, so a stream dropped mid-replay resubscribed
from the original stale position and re-read the same log files while
live traffic kept appending - under a flapping filer the replay window
never shrank, and each reconnect re-listed and re-fetched the same
backlog.

ReadLogFileRefs now reports every entry the merge has settled -
delivered or filter-skipped - and FollowMetadata checkpoints StartTsNs
on it, so a reconnect resumes where the previous stream stopped and the
backlog drains across restarts. The peer aggregator wires the same
progress into its resubscribe/offset position.

* pb: emit a replay checkpoint only after the merge passes its timestamp

Checkpointing an entry's timestamp the moment it settles could strand a
same-timestamp entry still queued on another filer's stream: resuming is
exclusive, so a reconnect would skip the unsettled half of the tie. The
merge now emits a timestamp only once a later entry proves every tied
entry was delivered (and once at clean EOF).

* pb+filer: never regress the replay cursor below the settled checkpoint

The drain's final assignment took the last settled entry's own timestamp,
which can lag the log position progressFn just checkpointed - a skewed or
legacy entry rewinds option.StartTsNs (and the peer's lastTsNs), so the next
drain or a resubscribe re-reads the settled prefix. Guard both assignments
so the cursor only moves forward.

* pb: cover the resubscribe path of replay checkpoints

A drain that aborts mid-replay leaves StartTsNs at the last checkpoint
the merge passed, and the next subscribe resumes there instead of
re-reading the whole ref set. The test drives the real
makeSubscribeMetadataFunc path: refs, a mid-batch apply failure, then a
resubscribe whose SinceNs must carry the checkpoint.

* pb: emit the tail replay checkpoint only for a settled timestamp

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 08:06:15 -07:00

607 lines
20 KiB
Go

package pb
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
// fakeSubscribeStream plays back a scripted sequence of responses, one per Recv.
type fakeSubscribeStream struct {
grpc.ClientStream
responses []*filer_pb.SubscribeMetadataResponse
delay time.Duration
idx int
}
func (s *fakeSubscribeStream) Recv() (*filer_pb.SubscribeMetadataResponse, error) {
if s.idx >= len(s.responses) {
return nil, io.EOF
}
if s.idx > 0 && s.delay > 0 {
// advance the wall clock so AddOffsetFunc's interval gate opens
time.Sleep(s.delay)
}
r := s.responses[s.idx]
s.idx++
return r, nil
}
// fakeFilerClient only needs SubscribeMetadata; the embedded nil interface
// covers the rest, which makeSubscribeMetadataFunc never calls.
type fakeFilerClient struct {
filer_pb.SeaweedFilerClient
stream *fakeSubscribeStream
}
func (c *fakeFilerClient) SubscribeMetadata(ctx context.Context, in *filer_pb.SubscribeMetadataRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.SubscribeMetadataResponse], error) {
return c.stream, nil
}
// TestFilerSyncOffsetStaysFreshOnFilteredMarker: on a read-only watched path with
// a busy source, the MaxUnsyncedEvents marker (empty EventNotification, fresh
// timestamp) must be treated as a freshness signal, not fed to the offset path
// where it would pin the gauge to the stale watermark. Wiring mirrors filer.sync.
func TestFilerSyncOffsetStaysFreshOnFilteredMarker(t *testing.T) {
const oldEventTs = int64(1_000_000_000) // t0: last real synced event (stale)
nowTs := time.Now().UnixNano() // current source time
markerTs := nowTs + int64(time.Second)
var watermark = oldEventTs // MetadataProcessor.processedTsWatermark
type gaugeWrite struct {
src string
ts int64
}
var timeline []gaugeWrite
var heartbeatCalls, markerToProcessFn int
// This consumer sets no resume callback, so markers keep moving StartTsNs
// and never reach processEventFn; the callback path is checked in
// TestFilerSyncMarkerReachesCallbackConsumer.
realProcessFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
if filer_pb.IsEmpty(resp) {
markerToProcessFn++
return nil
}
watermark = resp.TsNs
return nil
}
// offsetFunc publishes the watermark to the gauge (filer_sync.go).
processEventFn := AddOffsetFunc(realProcessFn, 0, func(counter, lastTsNs int64) error {
timeline = append(timeline, gaugeWrite{"offset", watermark})
return nil
})
option := &MetadataFollowOption{
ClientName: "syncFrom_A_To_B",
StartTsNs: oldEventTs,
EventErrorType: DontLogError,
OnIdleHeartbeat: func(tsNs int64) {
heartbeatCalls++
timeline = append(timeline, gaugeWrite{"heartbeat", tsNs})
},
}
stream := &fakeSubscribeStream{
delay: 2 * time.Millisecond,
responses: []*filer_pb.SubscribeMetadataResponse{
// real create on the watched path, long ago -> watermark = t0
{Directory: "/watched", TsNs: oldEventTs, EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file"},
}},
// genuine idle heartbeat
{TsNs: nowTs},
// MaxUnsyncedEvents marker: empty EventNotification, fresh timestamp
{TsNs: markerTs, EventNotification: &filer_pb.EventNotification{}},
},
}
if err := makeSubscribeMetadataFunc(option, processEventFn)(&fakeFilerClient{stream: stream}); err != nil {
t.Fatalf("follow: %v", err)
}
t.Logf("gauge timeline: %+v", timeline)
// the marker fires OnIdleHeartbeat instead of reaching processEventFn
if markerToProcessFn != 0 {
t.Errorf("empty marker must not reach processEventFn, got %d calls", markerToProcessFn)
}
if heartbeatCalls != 2 {
t.Errorf("expected OnIdleHeartbeat for both the heartbeat and the marker (2), got %d", heartbeatCalls)
}
if option.StartTsNs != markerTs {
t.Errorf("marker should advance StartTsNs to %d, got %d", markerTs, option.StartTsNs)
}
// gauge stays fresh: last write is the marker's timestamp, not the stale watermark
last := timeline[len(timeline)-1]
if last.src != "heartbeat" || last.ts != markerTs {
t.Fatalf("expected final gauge write fresh at %d, got %+v (spike is back if stale %d)", markerTs, last, oldEventTs)
}
}
// TestFilerSyncBatchedFreshnessSignalDoesNotCrash: while catching up after a
// peer outage the server folds a backlog into one batched response: the first
// event in the top-level fields, the rest in resp.Events. The drain can pull an
// idle heartbeat (nil EventNotification) into that tail. The batched tail must
// get the same freshness-signal handling as the envelope, else processEventFn
// (filer.sync's AddSyncJob) nil-derefs in IsEmpty. The processEventFn here
// mirrors that first call.
func TestFilerSyncBatchedFreshnessSignalDoesNotCrash(t *testing.T) {
const ts1 = int64(1_000_000_000) // envelope: real create
const ts2 = int64(1_000_000_001) // batched: real create
const markerTs = int64(1_000_000_002) // batched: MaxUnsyncedEvents marker (empty entry)
hbTs := time.Now().UnixNano() // batched: idle heartbeat (nil EventNotification), fresh
var realEvents []int64
var heartbeatTs []int64
// Mirrors AddSyncJob: IsEmpty nil-derefs on a nil EventNotification.
processEventFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
if filer_pb.IsEmpty(resp) {
return nil
}
realEvents = append(realEvents, resp.TsNs)
return nil
}
option := &MetadataFollowOption{
ClientName: "syncFrom_A_To_B",
StartTsNs: ts1,
EventErrorType: DontLogError,
OnIdleHeartbeat: func(tsNs int64) {
heartbeatTs = append(heartbeatTs, tsNs)
},
}
// One batched response: envelope plus a tail mixing a real event, a marker,
// and a fresh heartbeat, as the server emits while draining a backlog. The
// heartbeat is last and carries the largest timestamp on purpose.
stream := &fakeSubscribeStream{
responses: []*filer_pb.SubscribeMetadataResponse{{
Directory: "/watched",
TsNs: ts1,
EventNotification: &filer_pb.EventNotification{NewEntry: &filer_pb.Entry{Name: "a"}},
Events: []*filer_pb.SubscribeMetadataResponse{
{Directory: "/watched", TsNs: ts2, EventNotification: &filer_pb.EventNotification{NewEntry: &filer_pb.Entry{Name: "b"}}},
{TsNs: markerTs, EventNotification: &filer_pb.EventNotification{}}, // marker: empty entry
{TsNs: hbTs}, // idle heartbeat: nil EventNotification
},
}},
}
if err := makeSubscribeMetadataFunc(option, processEventFn)(&fakeFilerClient{stream: stream}); err != nil {
t.Fatalf("follow: %v", err)
}
// Both real events reached processEventFn; neither freshness signal did.
if len(realEvents) != 2 || realEvents[0] != ts1 || realEvents[1] != ts2 {
t.Errorf("expected real events [%d %d], got %v", ts1, ts2, realEvents)
}
// The batched marker and heartbeat both fired OnIdleHeartbeat.
if len(heartbeatTs) != 2 || heartbeatTs[0] != markerTs || heartbeatTs[1] != hbTs {
t.Errorf("expected heartbeats [%d %d], got %v", markerTs, hbTs, heartbeatTs)
}
// The marker advances the resume cursor; the heartbeat does not, even though
// it is last and carries the largest timestamp. StartTsNs ends at the marker.
if option.StartTsNs != markerTs {
t.Errorf("expected StartTsNs %d (marker), got %d (heartbeat must not advance the cursor)", markerTs, option.StartTsNs)
}
}
// TestFilerSyncResumeFromProcessedWatermarkOnReconnect verifies that when GetResumeTsNs is
// configured, reconnection uses the processed watermark instead of skipping ahead to the
// latest received timestamp.
func TestFilerSyncResumeFromProcessedWatermarkOnReconnect(t *testing.T) {
const initialTs = int64(100)
const watermarkTs = int64(200)
const latestStreamTs = int64(500)
var capturedSinceNs int64
recordingClient := &recordingFilerClient{
onSubscribe: func(req *filer_pb.SubscribeMetadataRequest) {
capturedSinceNs = req.SinceNs
},
stream: &fakeSubscribeStream{
responses: []*filer_pb.SubscribeMetadataResponse{
{
Directory: "/watched",
TsNs: latestStreamTs,
EventNotification: &filer_pb.EventNotification{NewEntry: &filer_pb.Entry{Name: "file"}},
},
},
},
}
option := &MetadataFollowOption{
ClientName: "syncFrom_A_To_B",
StartTsNs: initialTs,
GetResumeTsNs: func() int64 {
return watermarkTs
},
}
processFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
return nil
}
fn := makeSubscribeMetadataFunc(option, processFn)
if err := fn(recordingClient); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedSinceNs != watermarkTs {
t.Fatalf("expected subscribe SinceNs to be watermark %d, got %d", watermarkTs, capturedSinceNs)
}
// StartTsNs must not be mutated when GetResumeTsNs is set
if option.StartTsNs != initialTs {
t.Fatalf("expected option.StartTsNs to remain %d, got %d", initialTs, option.StartTsNs)
}
}
// TestFilerSyncDoesNotAdvanceStartTsNsOnProcessError verifies that a failing synchronous
// processEventFn does not advance option.StartTsNs past the failed event.
func TestFilerSyncDoesNotAdvanceStartTsNsOnProcessError(t *testing.T) {
const initialTs = int64(100)
const failedTs = int64(200)
option := &MetadataFollowOption{
ClientName: "syncFrom_A_To_B",
StartTsNs: initialTs,
EventErrorType: TrivialOnError,
}
stream := &fakeSubscribeStream{
responses: []*filer_pb.SubscribeMetadataResponse{
{
Directory: "/watched",
TsNs: failedTs,
EventNotification: &filer_pb.EventNotification{NewEntry: &filer_pb.Entry{Name: "bad"}},
},
},
}
processFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
return io.ErrUnexpectedEOF
}
fn := makeSubscribeMetadataFunc(option, processFn)
_ = fn(&fakeFilerClient{stream: stream})
if option.StartTsNs != initialTs {
t.Fatalf("expected StartTsNs to stay at %d on error, got %d", initialTs, option.StartTsNs)
}
}
type recordingFilerClient struct {
filer_pb.SeaweedFilerClient
onSubscribe func(req *filer_pb.SubscribeMetadataRequest)
stream *fakeSubscribeStream
}
func (c *recordingFilerClient) SubscribeMetadata(ctx context.Context, in *filer_pb.SubscribeMetadataRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.SubscribeMetadataResponse], error) {
if c.onSubscribe != nil {
c.onSubscribe(in)
}
return c.stream, nil
}
// RetryForeverOnError resolves a failure inside handleErr, so the cursor must
// still move past the recovered event instead of replaying it on reconnect.
func TestFilerSyncRecoveredEventAdvancesCursor(t *testing.T) {
var calls int
processFn := func(resp *filer_pb.SubscribeMetadataResponse) error {
calls++
if calls == 1 {
return io.ErrUnexpectedEOF
}
return nil
}
option := &MetadataFollowOption{
ClientName: "syncFrom_A_To_B",
StartTsNs: 100,
EventErrorType: RetryForeverOnError,
}
stream := &fakeSubscribeStream{
responses: []*filer_pb.SubscribeMetadataResponse{
{Directory: "/watched", TsNs: 300, EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file"},
}},
},
}
if err := makeSubscribeMetadataFunc(option, processFn)(&fakeFilerClient{stream: stream}); err != nil {
t.Fatalf("follow: %v", err)
}
if calls != 2 {
t.Fatalf("expected the event retried once (2 calls), got %d", calls)
}
if option.StartTsNs != 300 {
t.Fatalf("expected StartTsNs 300 after the retry succeeded, got %d", option.StartTsNs)
}
}
// A consumer with a resume callback keeps its cursor in its processed
// watermark, so a filtered-progress marker is handed to processEventFn (which
// can count it as processed) instead of mutating StartTsNs.
func TestFilerSyncMarkerReachesCallbackConsumer(t *testing.T) {
var markers, events int
option := &MetadataFollowOption{
ClientName: "syncFrom_A_To_B",
StartTsNs: 100,
GetResumeTsNs: func() int64 {
return 100
},
}
stream := &fakeSubscribeStream{
responses: []*filer_pb.SubscribeMetadataResponse{
{Directory: "/watched", TsNs: 300, EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file"},
}},
{TsNs: 500, EventNotification: &filer_pb.EventNotification{}},
},
}
fn := makeSubscribeMetadataFunc(option, func(resp *filer_pb.SubscribeMetadataResponse) error {
if filer_pb.IsEmpty(resp) {
markers++
} else {
events++
}
return nil
})
if err := fn(&fakeFilerClient{stream: stream}); err != nil {
t.Fatalf("follow: %v", err)
}
if markers != 1 || events != 1 {
t.Fatalf("expected 1 marker and 1 event at processEventFn, got %d and %d", markers, events)
}
if option.StartTsNs != 100 {
t.Fatalf("callback consumer must not mutate StartTsNs, got %d", option.StartTsNs)
}
}
func TestFilerSyncMarkerCallbackRetries(t *testing.T) {
var calls int
option := &MetadataFollowOption{
StartTsNs: 100,
EventErrorType: RetryForeverOnError,
GetResumeTsNs: func() int64 { return 100 },
}
stream := &fakeSubscribeStream{responses: []*filer_pb.SubscribeMetadataResponse{
{TsNs: 500, EventNotification: &filer_pb.EventNotification{}},
}}
fn := makeSubscribeMetadataFunc(option, func(resp *filer_pb.SubscribeMetadataResponse) error {
calls++
if calls == 1 {
return io.ErrUnexpectedEOF
}
return nil
})
if err := fn(&fakeFilerClient{stream: stream}); err != nil {
t.Fatalf("follow: %v", err)
}
if calls != 2 || option.StartTsNs != 100 {
t.Fatalf("calls = %d, cursor = %d; want 2 calls and unchanged cursor 100", calls, option.StartTsNs)
}
}
// Each subscribe call re-reads the callback, so a reconnect after the consumer
// made progress resumes from the newer watermark.
func TestFilerSyncReconnectReadsWatermarkEachSubscribe(t *testing.T) {
var watermark atomic.Int64
watermark.Store(100)
var sinceNs []int64
var mu sync.Mutex
stream := &fakeSubscribeStream{
responses: []*filer_pb.SubscribeMetadataResponse{
{Directory: "/watched", TsNs: 300, EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file"},
}},
},
}
client := &recordingFilerClient{
onSubscribe: func(req *filer_pb.SubscribeMetadataRequest) {
mu.Lock()
sinceNs = append(sinceNs, req.SinceNs)
mu.Unlock()
},
stream: stream,
}
option := &MetadataFollowOption{
ClientName: "syncFrom_A_To_B",
StartTsNs: 100,
GetResumeTsNs: func() int64 {
return watermark.Load()
},
}
fn := makeSubscribeMetadataFunc(option, func(resp *filer_pb.SubscribeMetadataResponse) error {
watermark.Store(resp.TsNs)
return nil
})
if err := fn(client); err != nil {
t.Fatalf("first subscribe: %v", err)
}
client.stream = &fakeSubscribeStream{}
if err := fn(client); err != nil {
t.Fatalf("resubscribe: %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(sinceNs) != 2 || sinceNs[0] != 100 || sinceNs[1] != 300 {
t.Fatalf("expected subscribes at 100 then 300, got %v", sinceNs)
}
}
// A drained ref batch must leave StartTsNs at the settled checkpoint even when
// the last entry's own timestamp lags its log position, so the next drain does
// not re-read the settled prefix.
func TestChunkDrainCursorKeepsProgressCheckpoint(t *testing.T) {
event := &filer_pb.SubscribeMetadataResponse{
Directory: "/watched",
TsNs: 100,
EventNotification: &filer_pb.EventNotification{
NewEntry: &filer_pb.Entry{Name: "file"},
},
}
eventData, err := proto.Marshal(event)
if err != nil {
t.Fatalf("marshal event: %v", err)
}
entryData, err := (&filer_pb.LogEntry{TsNs: 300, Data: eventData}).MarshalVT()
if err != nil {
t.Fatalf("marshal log entry: %v", err)
}
var buf bytes.Buffer
var sizeBuf [4]byte
util.Uint32toBytes(sizeBuf[:], uint32(len(entryData)))
buf.Write(sizeBuf[:])
buf.Write(entryData)
option := &MetadataFollowOption{
ClientName: "mount",
StartTsNs: 0,
PathPrefix: "/",
LogFileReaderFn: func(chunks []*filer_pb.FileChunk) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(buf.Bytes())), nil
},
}
stream := &fakeSubscribeStream{responses: []*filer_pb.SubscribeMetadataResponse{
{LogFileRefs: []*filer_pb.LogFileChunkRef{
{FilerId: "f0", Chunks: []*filer_pb.FileChunk{{FileId: "1,01"}}},
}},
}}
if err := makeSubscribeMetadataFunc(option, func(*filer_pb.SubscribeMetadataResponse) error {
return nil
})(&fakeFilerClient{stream: stream}); err != nil {
t.Fatalf("follow: %v", err)
}
if option.StartTsNs != 300 {
t.Fatalf("expected StartTsNs at the settled checkpoint 300, got %d", option.StartTsNs)
}
}
// blockingFilerClient returns a stream whose Recv parks until the subscribe
// context ends, standing in for a quiet source stream during a sink outage.
type blockingFilerClient struct {
filer_pb.SeaweedFilerClient
}
func (c *blockingFilerClient) SubscribeMetadata(ctx context.Context, in *filer_pb.SubscribeMetadataRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.SubscribeMetadataResponse], error) {
return &blockingSubscribeStream{ctx: ctx}, nil
}
type blockingSubscribeStream struct {
grpc.ClientStream
ctx context.Context
}
func (s *blockingSubscribeStream) Recv() (*filer_pb.SubscribeMetadataResponse, error) {
<-s.ctx.Done()
return nil, s.ctx.Err()
}
// A consumer that pins an event asks for the stream to drop so the reconnect
// replays it; the follower must end with ErrResubscribe, not sit on Recv.
func TestFilerSyncResubscribeSignalEndsStream(t *testing.T) {
resubscribe := make(chan struct{})
option := &MetadataFollowOption{
ClientName: "syncFrom_A_To_B",
EventErrorType: DontLogError,
Resubscribe: resubscribe,
}
fn := makeSubscribeMetadataFunc(option, func(resp *filer_pb.SubscribeMetadataResponse) error {
return nil
})
done := make(chan error, 1)
go func() {
done <- fn(&blockingFilerClient{})
}()
close(resubscribe)
select {
case err := <-done:
if !errors.Is(err, ErrResubscribe) {
t.Fatalf("expected ErrResubscribe, got %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("follower did not end after the resubscribe signal")
}
}
// A dropped stream mid-replay must keep the checkpoints the drain already
// settled: the resubscribe's SinceNs resumes at the last settled entry
// instead of re-reading the whole ref set.
func TestChunkReplayCheckpointSurvivesReconnect(t *testing.T) {
var events []*filer_pb.SubscribeMetadataResponse
for i, ts := range []int64{101, 102, 103, 104} {
events = append(events, makeSubEvent("/watched", fmt.Sprintf("f%d", i), ts))
}
logData := buildLogFileData(events)
refsResp := &filer_pb.SubscribeMetadataResponse{
LogFileRefs: []*filer_pb.LogFileChunkRef{{
FilerId: "f0",
Chunks: []*filer_pb.FileChunk{{FileId: "log-1"}},
}},
}
markerResp := &filer_pb.SubscribeMetadataResponse{
EventNotification: &filer_pb.EventNotification{},
TsNs: 200,
}
option := &MetadataFollowOption{
ClientName: "mount",
StartTsNs: 100,
PathPrefix: "/watched/",
LogFileReaderFn: func(chunks []*filer_pb.FileChunk) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(logData)), nil
},
}
var sinceNs []int64
client := &recordingFilerClient{
onSubscribe: func(req *filer_pb.SubscribeMetadataRequest) {
sinceNs = append(sinceNs, req.SinceNs)
},
stream: &fakeSubscribeStream{responses: []*filer_pb.SubscribeMetadataResponse{refsResp, markerResp}},
}
count := 0
wantErr := fmt.Errorf("apply boom")
err := makeSubscribeMetadataFunc(option, func(resp *filer_pb.SubscribeMetadataResponse) error {
count++
if count == 3 {
return wantErr
}
return nil
})(client)
if !errors.Is(err, ErrLogFileRead) {
t.Fatalf("expected ErrLogFileRead, got %v", err)
}
// Entries 101 and 102 settled; 102's checkpoint emits when 103 pops, but
// 103 failed first, so the last checkpoint is 101.
if option.StartTsNs != 101 {
t.Fatalf("StartTsNs = %d, want 101", option.StartTsNs)
}
client.stream = &fakeSubscribeStream{}
if err := makeSubscribeMetadataFunc(option, func(*filer_pb.SubscribeMetadataResponse) error {
return nil
})(client); err != nil {
t.Fatalf("resubscribe: %v", err)
}
if len(sinceNs) != 2 || sinceNs[0] != 100 || sinceNs[1] != 101 {
t.Fatalf("expected subscribes at 100 then 101, got %v", sinceNs)
}
}