package weed_server

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/prometheus/client_golang/prometheus"

	"github.com/seaweedfs/seaweedfs/weed/stats"

	"google.golang.org/protobuf/proto"

	"github.com/seaweedfs/seaweedfs/weed/filer"
	"github.com/seaweedfs/seaweedfs/weed/glog"
	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
	"github.com/seaweedfs/seaweedfs/weed/util"
	"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
)

// Vars, not consts: the loop tests shrink them to drive parks and give-ups in
// test time.
var (
	// unflushedGapRetryInterval caps the wait of a subscriber parked on a recent
	// (possibly-unflushed) gap, in case the flush notification is missed.
	unflushedGapRetryInterval = 2 * time.Second

	// gapStallWarnInterval paces the warning for a subscriber that stays parked.
	gapStallWarnInterval = time.Minute

	// maxGapStall bounds a gap wait before giving up and skipping it, counted
	// and logged: a dead peer makes the wait permanent, and failing the stream
	// only moves the loop into a client that reconnects to the same wall.
	maxGapStall = 15 * time.Minute

	// metadataGapSettledHorizon is the liveness escape for the peer-watermark
	// holds: a watermark stalled further than this stops holding reads back.
	// Twice the flush interval so a healthy peer's flush always lands within.
	metadataGapSettledHorizon = 2 * filer.LogFlushInterval
)

const (
	// MaxUnsyncedEvents send empty notification with timestamp when certain amount of events have been filtered
	MaxUnsyncedEvents = 1e3

	// idleHeartbeatInterval bounds how often a caught-up subscriber that asked
	// for idle heartbeats is reminded that the source is alive and has nothing
	// newer. It keeps freshness signals such as filer.sync's sync_offset metric
	// from looking stuck during read-only periods on the source.
	idleHeartbeatInterval = 5 * time.Second

	// peerDeliveryClaimInterval paces that heartbeat on the local stream of a
	// filer with peers, where it is not a keepalive but a delivery claim: the
	// peer aggregator turns it into its delivery low-watermark, and every
	// aggregated subscriber in the cluster holds at the minimum across peers.
	// So this, not idleHeartbeatInterval, is how far behind live writes a
	// quiet filer leaves them. Rounded up to the reader's own poll interval.
	peerDeliveryClaimInterval = 200 * time.Millisecond

	// heldWakeFloor coalesces hold releases. Peers advance their watermarks
	// per event they stream, and each release costs a whole pass - a log file
	// listing included - so releasing on every advance turns a busy cluster
	// into a listing storm. It is added to delivery latency, so it stays well
	// under the claim interval that already bounds it.
	heldWakeFloor = 20 * time.Millisecond
)

// metadataStreamSender is satisfied by both gRPC stream types and pipelinedSender.
type metadataStreamSender interface {
	Send(*filer_pb.SubscribeMetadataResponse) error
}

const (
	// batchBehindThreshold: when an event's timestamp is older than this
	// relative to wall clock, the sender switches to batch mode for throughput.
	// When events are closer to current time, they are sent one-by-one for
	// low latency.
	batchBehindThreshold = 2 * time.Minute
	maxBatchSize         = 256
)

// pipelinedSender decouples event reading from gRPC delivery by buffering
// messages in a channel. A dedicated goroutine handles stream.Send(), allowing
// the reader to continue reading ahead without waiting for the client to
// acknowledge each event.
//
// When the client declares support for batching AND events are far behind
// current time (backlog catch-up), multiple events are packed into a single
// stream.Send() using the Events field. Otherwise events are sent one-by-one.
type pipelinedSender struct {
	sendCh   chan *filer_pb.SubscribeMetadataResponse
	errCh    chan error
	done     chan struct{}
	canBatch bool // true only if client set ClientSupportsBatching
}

func newPipelinedSender(stream metadataStreamSender, bufSize int, clientSupportsBatching bool) *pipelinedSender {
	s := &pipelinedSender{
		sendCh:   make(chan *filer_pb.SubscribeMetadataResponse, bufSize),
		errCh:    make(chan error, 1),
		done:     make(chan struct{}),
		canBatch: clientSupportsBatching,
	}
	go s.sendLoop(stream)
	return s
}

func (s *pipelinedSender) sendLoop(stream metadataStreamSender) {
	defer close(s.done)
	for msg := range s.sendCh {
		// LogFileRefs messages are unbatchable: the client recognizes them by
		// the top-level field and skips the rest of the response, so a refs
		// envelope would drop its Events tail and refs inside Events would be
		// applied as an (empty) event. Their TsNs is 0, which the batch
		// heuristic would misread as far behind. Always send them solo.
		// Control messages (nil EventNotification: heartbeats, flush reports)
		// are unbatchable too - nested in an Events tail their watermark
		// state is invisible to receivers.
		shouldBatch := s.canBatch && len(msg.LogFileRefs) == 0 && msg.EventNotification != nil &&
			time.Now().UnixNano()-msg.TsNs > int64(batchBehindThreshold)

		if !shouldBatch {
			// Real-time: send immediately for low latency
			if err := stream.Send(msg); err != nil {
				s.reportErr(err)
				return
			}
			continue
		}

		// Backlog: batch multiple events into one Send for throughput.
		// The first event goes in the top-level fields; additional events
		// go in the Events slice. Old clients ignore the Events field.
		batch := make([]*filer_pb.SubscribeMetadataResponse, 0, maxBatchSize)
		batch = append(batch, msg)
		var trailingSolo *filer_pb.SubscribeMetadataResponse
	drain:
		for len(batch) < maxBatchSize {
			select {
			case next, ok := <-s.sendCh:
				if !ok {
					break drain
				}
				if len(next.LogFileRefs) > 0 || next.EventNotification == nil {
					// already consumed; send it solo right after the batch
					trailingSolo = next
					break drain
				}
				batch = append(batch, next)
			default:
				break drain
			}
		}

		var toSend *filer_pb.SubscribeMetadataResponse
		if len(batch) == 1 {
			toSend = batch[0]
		} else {
			// Pack batch: first event is the envelope, rest go in Events
			toSend = batch[0]
			toSend.Events = batch[1:]
		}
		if err := stream.Send(toSend); err != nil {
			s.reportErr(err)
			return
		}
		if toSend.Events != nil {
			toSend.Events = nil
		}
		if trailingSolo != nil {
			if err := stream.Send(trailingSolo); err != nil {
				s.reportErr(err)
				return
			}
		}
	}
}

func (s *pipelinedSender) reportErr(err error) {
	select {
	case s.errCh <- err:
	default:
	}
	// Don't drain sendCh here — Send() detects the exit via <-s.done
	// and the deferred close(s.done) in sendLoop will fire after this returns.
}

func (s *pipelinedSender) Send(msg *filer_pb.SubscribeMetadataResponse) error {
	select {
	case s.sendCh <- msg:
		return nil
	case err := <-s.errCh:
		return err
	case <-s.done:
		// Sender goroutine exited (stream error or shutdown).
		select {
		case err := <-s.errCh:
			return err
		default:
			return fmt.Errorf("pipelined sender closed")
		}
	}
}

func (s *pipelinedSender) Close() error {
	close(s.sendCh)
	<-s.done
	select {
	case err := <-s.errCh:
		return err
	default:
		return nil
	}
}

// reportUnprovenAggregatedCrossing counts a disk read crossing the eviction
// watermark without proof. A crossing at or below provenThroughTsNs (the
// flush low-watermark frozen before the pass listed files) is proven and not
// reported, so what remains is exactly what the settled-horizon escape
// allowed past a stalled peer.
func reportUnprovenAggregatedCrossing(cursorBeforeTsNs, cursorAfterTsNs, evictedTsNs, provenThroughTsNs int64, clientName, pathPrefix string) {
	if evictedTsNs == 0 || cursorBeforeTsNs >= evictedTsNs || cursorAfterTsNs < evictedTsNs {
		return
	}
	if provenThroughTsNs >= evictedTsNs {
		return
	}
	stats.FilerSubscribeUnprovenGapCrossings.WithLabelValues("aggregated").Inc()
	glog.Warningf("aggregated subscriber %s %s crossed an evicted range (%v..%v] on peer disk reads; a peer that flushes into it later will not be re-read",
		clientName, pathPrefix, time.Unix(0, cursorBeforeTsNs), time.Unix(0, evictedTsNs))
}

// diskReadAdvanced reports whether a persisted read moved the subscriber on.
// A chunk-ref read reports the minute-level name of the last file it shipped,
// clamped so it never rewinds, so it comes back non-zero even when it names the
// position that was already current. Treating that as progress clears the stall
// timer, and a subscriber parked on a gap it re-ships the same refs for would
// reset the timer every retry and never reach the stall bound.
func diskReadAdvanced(processedTsNs int64, cursor log_buffer.MessagePosition) bool {
	return processedTsNs != 0 && processedTsNs > cursor.Time.UnixNano()
}

// gapResumeCursorOffset marks every cursor these loops hand to the memory read:
// gated, so a seal racing the loop's watermark check is refused under the
// read's own lock instead of silently served from the earliest window.
const gapResumeCursorOffset = log_buffer.EvictionGatedOffset

// memoryHoldsGap reports whether nothing after the cursor was evicted. Equality
// counts: the evicted window ends on the watermark, retained windows start
// strictly after it, and the persisted reader skips ts <= cursor, so no wait
// can ever produce the boundary entry - refusing there never ends.
func memoryHoldsGap(currentTsNs, lastEvictedTsNs int64) bool {
	if lastEvictedTsNs == 0 {
		return true // nothing was ever dropped from the ring
	}
	return currentTsNs >= lastEvictedTsNs
}

// errHeldByPeerWatermark aborts a read at an entry beyond the hold point; the
// caller rewinds to the last delivered entry, waits, and re-reads (the
// re-listing is what picks up a late-landing log file). Holding is the normal
// state on a cluster that keeps writing, hence the quiet stop.
var errHeldByPeerWatermark = fmt.Errorf("held by aggregated peer watermark: %w", log_buffer.StopReadingError)

// resolveAggReadHoldTsNs bounds how far an aggregated subscriber may read: a
// cursor that passes T before every source has provably made T visible loses
// whatever arrives late. The hold is the peers' low-watermark (delivery for
// memory reads, flush for persisted reads), relaxed by the settled horizon so
// a stalled peer delays subscribers by at most the horizon.
func resolveAggReadHoldTsNs(peerLowWatermarkTsNs, nowTsNs int64, settledHorizon time.Duration) int64 {
	horizonTsNs := nowTsNs - int64(settledHorizon)
	if peerLowWatermarkTsNs > horizonTsNs {
		return peerLowWatermarkTsNs
	}
	return horizonTsNs
}

// previousMinuteEndTsNs returns the last nanosecond of the minute before
// tsNs: log files are named per minute, so a ref listing bounded here cannot
// include a file whose window crosses tsNs.
func previousMinuteEndTsNs(tsNs int64) int64 {
	return tsNs - tsNs%int64(time.Minute) - 1
}

// chunkRefsStopTsNs bounds the ref listing so no shipped file holds an entry
// past the hold: clients apply shipped files whole and may checkpoint from a
// tail. A file's window starts in its name's minute and spans up to a flush
// interval, hence the double back-off; a frozen peer's freeze-spanning window
// can still overshoot by its freeze.
func chunkRefsStopTsNs(holdTsNs, untilNs int64) int64 {
	stopTsNs := previousMinuteEndTsNs(holdTsNs - int64(filer.LogFlushInterval))
	if untilNs != 0 && untilNs < stopTsNs {
		stopTsNs = untilNs
	}
	return stopTsNs
}

// gapStallReporter makes a parked subscriber visible: a flush that never lands
// stalls the stream for good, and filer.sync and mount followers just stop
// advancing with no error on either side.
//
// The gauge counts parked subscribers per scope. It deliberately carries no
// per-client label: clientName embeds the ephemeral source port (a series per
// reconnect), and the client-supplied name is not unique either - every mount
// registers as "mount" - so same-named streams would clobber and delete each
// other's series. A count needs no identity and no cleanup; the logs carry the
// client details.
type gapStallReporter struct {
	scope      string
	clientName string
	pathPrefix string
	since      time.Time
	lastWarnAt time.Time
}

func (r *gapStallReporter) gauge() prometheus.Gauge {
	return stats.FilerSubscribeGapStalledGauge.WithLabelValues(r.scope)
}

// stalledFor reports how long this subscriber has been parked, zero if it is not.
func (r *gapStallReporter) stalledFor() time.Duration {
	if r.since.IsZero() {
		return 0
	}
	return time.Since(r.since)
}

// park records that the subscriber is waiting on a gap. It stays quiet until
// the stall has lasted gapStallWarnInterval: during a catch-up burst a
// subscriber parks and resumes every couple of seconds, and a warning per
// cycle would bury the long-stall warnings this reporter exists to surface.
func (r *gapStallReporter) park(cursor time.Time, detail string) {
	now := time.Now()
	if r.since.IsZero() {
		r.since = now
		r.gauge().Inc()
	}
	if now.Sub(r.since) < gapStallWarnInterval {
		return
	}
	if !r.lastWarnAt.IsZero() && now.Sub(r.lastWarnAt) < gapStallWarnInterval {
		return
	}
	r.lastWarnAt = now
	glog.Warningf("%s subscriber %s %s parked %v at %v: %s", r.scope, r.clientName, r.pathPrefix,
		now.Sub(r.since).Truncate(time.Second), cursor, detail)
}

// resumed marks the gap cleared. Only a stall park() had already warned about
// is worth announcing.
func (r *gapStallReporter) resumed() {
	if r.since.IsZero() {
		return
	}
	if !r.lastWarnAt.IsZero() {
		glog.Warningf("%s subscriber %s %s resumed after %v parked", r.scope, r.clientName, r.pathPrefix,
			time.Since(r.since).Truncate(time.Second))
	}
	r.since, r.lastWarnAt = time.Time{}, time.Time{}
	r.gauge().Dec()
}

// gaveUp records that the subscriber stopped waiting on an unprovable gap and
// skipped it. This is the loss the whole gap machinery exists to make loud: it
// shares the unproven-crossing counter and logs at error level.
func (r *gapStallReporter) gaveUp(cursor time.Time, skipToTsNs int64, detail string) {
	stats.FilerSubscribeUnprovenGapCrossings.WithLabelValues(r.scope).Inc()
	glog.Errorf("%s subscriber %s %s skipping the gap (%v..%v] after %v parked: %s; events a peer flushes into that range later will not be delivered",
		r.scope, r.clientName, r.pathPrefix, cursor, time.Unix(0, skipToTsNs), r.stalledFor().Truncate(time.Second), detail)
	r.since, r.lastWarnAt = time.Time{}, time.Time{}
	r.gauge().Dec()
}

// restartStall re-arms the stall clock for a park that outlived maxGapStall
// with nothing to skip to, so the give-up path does not retrigger on every
// retry while still reporting each full cycle.
func (r *gapStallReporter) restartStall(cursor time.Time, detail string) {
	glog.Errorf("%s subscriber %s %s still parked after %v at %v with nothing to skip to: %s", r.scope, r.clientName,
		r.pathPrefix, r.stalledFor().Truncate(time.Second), cursor, detail)
	r.since, r.lastWarnAt = time.Now(), time.Time{}
}

// close releases the gauge on teardown. Unlike resumed() it does not claim
// recovery: a subscriber that disconnects while parked never resumed.
func (r *gapStallReporter) close() {
	if r.since.IsZero() {
		return
	}
	glog.Warningf("%s subscriber %s %s disconnected after %v parked, still behind", r.scope, r.clientName,
		r.pathPrefix, r.stalledFor().Truncate(time.Second))
	r.gauge().Dec()
	r.since = time.Time{}
}

// parkOnGap parks the subscriber on a gap it cannot read past and reports how
// to go on. done: the stream is over - the client is gone, a bounded
// subscription is complete, or the context ended. skip: the park outlived
// maxGapStall and the caller must resume at skipToTsNs, abandoning the gap
// (recorded via gaveUp). Otherwise the caller re-probes. notifyChan may be nil,
// which parks on the retry timer alone - right when no local signal
// corresponds to the event being waited for. The park is where a stalled
// subscriber spends all its time, so every exit the read loop relies on has to
// be checked here too.
func (fs *FilerServer) parkOnGap(ctx context.Context, req *filer_pb.SubscribeMetadataRequest, gapStall *gapStallReporter, evictedTsNs func() int64, cursor log_buffer.MessagePosition, notifyChan <-chan struct{}, reason string) (skipToTsNs int64, skip bool, done bool) {
	// Done exits run before park(): a finished stream was never parked, and
	// marking it so leaves a false "still behind" trace. A cursor at UntilNs is
	// finished - the bound is inclusive, cursors are exclusive, and
	// LoopProcessLogData (the only place UntilNs ends a stream) is unreachable
	// from a park.
	if req.UntilNs != 0 && cursor.Time.UnixNano() >= req.UntilNs {
		return 0, false, true
	}
	if !fs.hasClient(req.ClientId, req.ClientEpoch) {
		return 0, false, true
	}
	gapStall.park(cursor.Time, reason)
	if gapStall.stalledFor() >= maxGapStall {
		// Resume at the eviction watermark: everything retained starts
		// strictly after it, so the recorded loss is exactly (cursor, skipTo].
		if evicted := evictedTsNs(); evicted > cursor.Time.UnixNano() {
			gapStall.gaveUp(cursor.Time, evicted, reason)
			return evicted, true, false
		}
		// Nothing was withheld past the cursor - nothing to skip, nothing being
		// lost; keep waiting on a fresh stall cycle.
		gapStall.restartStall(cursor.Time, reason)
	}
	// Re-probes back off as the stall ages: every retry re-reads the persisted
	// log, and probing the store each 2s for 15 minutes - per parked subscriber,
	// during the outage that parked them - makes the bad time worse.
	waitFor := unflushedGapRetryInterval + gapStall.stalledFor()/8
	if waitFor > gapStallWarnInterval {
		waitFor = gapStallWarnInterval
	}
	retry := time.After(waitFor)
	for {
		select {
		case _, ok := <-notifyChan:
			if !ok {
				// Closed out from under us: a receive now returns instantly, so
				// stop watching it rather than spinning until the timer fires.
				notifyChan = nil
				continue
			}
		case <-ctx.Done():
			return 0, false, true
		case <-retry:
		}
		if !fs.hasClient(req.ClientId, req.ClientEpoch) {
			return 0, false, true
		}
		return 0, false, false
	}
}

// resolveGapResume decides whether a subscriber may skip a gap its disk read
// found empty. Either proof settles it: nothing after the cursor was evicted,
// so memory still holds the whole gap; or the flush watermark observed before
// the read had already passed the earliest in-memory timestamp, so every event
// in the gap would have been on disk when the read ran and the miss is
// authoritative. The aggregated loop passes its peers' proven-covered
// watermark as flushedTsNs (everything at or below it was flushed and inside
// the pass's listing), the local loop its own flush watermark.
func resolveGapResume(currentTsNs, currentOffset, earliestMemTsNs, flushedTsNs, lastEvictedTsNs int64) (advanceToTsNs int64, advance bool) {
	// No in-memory data (zero time → negative UnixNano), or memory not ahead of us.
	if earliestMemTsNs <= 0 || earliestMemTsNs <= currentTsNs {
		return 0, false
	}
	// The gap may still hold unflushed events.
	if !memoryHoldsGap(currentTsNs, lastEvictedTsNs) && flushedTsNs < earliestMemTsNs {
		return 0, false
	}
	// Resume just below earliest, not at it. A sealed window holding a single
	// entry has startTime == stopTime == earliest, and the sealed-buffer lookup
	// only enters a window whose stopTime is strictly after the cursor, so a
	// cursor sitting exactly on earliest skips that window entirely and loses
	// its sole event. One nanosecond lower takes the startTime.After branch and
	// returns the whole window.
	target := earliestMemTsNs - 1
	if target < currentTsNs {
		return 0, false
	}
	if target == currentTsNs && currentOffset <= 0 {
		// The sentinel resume would be the position we already hold.
		return 0, false
	}
	// target > cursor is plainly forward. target == cursor with a positive
	// (exclusive) offset is progress too: that cursor cannot be served -
	// ReadFromBuffer refuses positive offsets below the window - while the
	// sentinel one is, and both deliver exactly the entries after target.
	return target, true
}

// gapPass carries what the shared post-disk gap decisions differ by between
// the two subscribe loops; everything else about them must stay identical, and
// this PR's history shows they drift when edited separately.
type gapPass struct {
	fs        *FilerServer
	req       *filer_pb.SubscribeMetadataRequest
	gapStall  *gapStallReporter
	earliest  func() time.Time
	evicted   func() int64 // gap-proof watermark; aggregated uses the received-ts space
	flushed   func() int64 // what the last disk read proved covered: flushed AND inside its listing
	gapChan   <-chan struct{}
	dataChan  <-chan struct{}
	gapReason func(earliest time.Time, evictedTsNs int64) string
}

type gapOutcome int

const (
	gapProceed  gapOutcome = iota // read memory
	gapContinue                   // restart the pass
	gapDone                       // the stream is over
)

// resolve is the gap decision both loops run between the disk pass and the
// memory read. A cursor the ring evicted past cannot be served from memory
// without skipping what was dropped: keep draining the disk if it just moved,
// skip if a proof says the gap is empty, park otherwise. A cursor memory
// refused with nothing evicted after it re-arms onto the retained window.
func (p *gapPass) resolve(ctx context.Context, cursor *log_buffer.MessagePosition, latch *error, diskAdvanced bool) gapOutcome {
	earliest := p.earliest()
	evictedTsNs := p.evicted()
	cursorTsNs := cursor.Time.UnixNano()
	if !memoryHoldsGap(cursorTsNs, evictedTsNs) {
		if diskAdvanced {
			return gapContinue // the disk may hold more of the gap
		}
		if advanceToTsNs, advance := resolveGapResume(cursorTsNs, cursor.Offset, earliest.UnixNano(), p.flushed(), evictedTsNs); advance {
			p.gapStall.resumed()
			glog.V(3).Infof("%s subscriber %s: gap proven empty, skipping from %v to earliest memory %v",
				p.gapStall.scope, p.gapStall.clientName, cursor.Time, earliest)
			*cursor = log_buffer.NewMessagePosition(advanceToTsNs, gapResumeCursorOffset)
			*latch = nil
			return gapProceed
		}
		// The last empty disk read proved coverage through the eviction
		// watermark itself: the rest of the gap holds nothing - cross without
		// parking or counting. The ring's pre-subscription mark is never
		// proven by rotation, so this is its only exit.
		if p.flushed() >= evictedTsNs {
			p.gapStall.resumed()
			*cursor = log_buffer.NewMessagePosition(evictedTsNs, gapResumeCursorOffset)
			*latch = nil
			return gapContinue
		}
		return p.park(ctx, cursor, latch, p.gapChan, p.gapReason(earliest, evictedTsNs))
	}
	if !diskAdvanced && errors.Is(*latch, log_buffer.ResumeFromDiskError) {
		// Memory refused the cursor though nothing after it was evicted: its
		// exclusive offset predates the retained window. Re-arm it onto the
		// window; failing even that, wait for data.
		if advanceToTsNs, advance := resolveGapResume(cursorTsNs, cursor.Offset, earliest.UnixNano(), p.flushed(), evictedTsNs); advance {
			p.gapStall.resumed()
			*cursor = log_buffer.NewMessagePosition(advanceToTsNs, gapResumeCursorOffset)
			*latch = nil
			return gapProceed
		}
		return p.park(ctx, cursor, latch, p.dataChan, "no readable in-memory entries yet")
	}
	return gapProceed
}

func (p *gapPass) park(ctx context.Context, cursor *log_buffer.MessagePosition, latch *error, notifyChan <-chan struct{}, reason string) gapOutcome {
	skipTo, skip, done := p.fs.parkOnGap(ctx, p.req, p.gapStall, p.evicted, *cursor, notifyChan, reason)
	if done {
		return gapDone
	}
	if skip {
		*cursor = log_buffer.NewMessagePosition(skipTo, gapResumeCursorOffset)
		*latch = nil
	}
	return gapContinue
}

func (fs *FilerServer) SubscribeMetadata(req *filer_pb.SubscribeMetadataRequest, stream filer_pb.SeaweedFiler_SubscribeMetadataServer) error {
	if fs.filer.MetaAggregator == nil || !fs.filer.MetaAggregator.HasRemotePeers() {
		return fs.SubscribeLocalMetadata(req, stream)
	}

	ctx := stream.Context()
	peerAddress := findClientAddress(ctx, 0)

	isReplacing, alreadyKnown, clientName := fs.addClient("", req.ClientName, peerAddress, req.PathPrefix, req.ClientId, req.ClientEpoch)
	if isReplacing {
	} else if alreadyKnown {
		return fmt.Errorf("duplicated subscription detected for client %s id %d", clientName, req.ClientId)
	}
	defer func() {
		glog.V(0).Infof("disconnect %v subscriber %s clientId:%d", clientName, req.PathPrefix, req.ClientId)
		fs.deleteClient("", clientName, req.ClientId, req.ClientEpoch)
	}()

	lastReadTime := log_buffer.NewMessagePosition(req.SinceNs, gapResumeCursorOffset)
	glog.V(0).Infof(" %v starts to subscribe %s from %+v", clientName, req.PathPrefix, lastReadTime)

	// diskAnchorTsNs is the newest ORIGINAL-timestamp position this stream is
	// proven complete through. Memory reads advance the cursor in the ring's
	// bumped (arrival) space while persisted logs keep original timestamps,
	// so a reader that falls off the ring must resume the disk pass here, not
	// at its bumped cursor - that would skip original-space entries memory
	// never delivered. Disk passes advance the anchor directly; contiguous
	// memory reads advance it to the delivery low-watermark observed before
	// the read (per-peer streams are ordered, so everything at or below it
	// had already arrived and been delivered).
	diskAnchorTsNs := req.SinceNs

	sender := newPipelinedSender(stream, 1024, req.ClientSupportsBatching)
	defer sender.Close()

	// Register for instant notification when new data arrives in the aggregated log buffer.
	// Used to replace the 1127ms sleep with event-driven wake-up.
	// Key includes clientId/epoch: a replacement stream may reuse the same
	// clientName (same gRPC conn), and sharing the channel would let the old
	// stream's deferred unregister close it under the new stream.
	aggNotifyName := fmt.Sprintf("aggSubscribe:%s:%d:%d", clientName, req.ClientId, req.ClientEpoch)
	// Same key shape for the reader: LoopProcessLogData registers it as a
	// subscriber internally, once per loop iteration.
	aggReaderName := fmt.Sprintf("aggMeta:%s:%d:%d", clientName, req.ClientId, req.ClientEpoch)
	aggNotifyChan := fs.filer.MetaAggregator.MetaLogBuffer.RegisterSubscriber(aggNotifyName)
	defer fs.filer.MetaAggregator.MetaLogBuffer.UnregisterSubscriber(aggNotifyName)

	gapStall := &gapStallReporter{scope: "aggregated", clientName: clientName, pathPrefix: req.PathPrefix}
	defer gapStall.close()

	var unsyncedEvents int64
	eachEventNotificationFn := fs.eachEventNotificationFn(req, sender, clientName, &unsyncedEvents)

	// lastSeenTsNs tracks how far the subscriber has read so idle heartbeats are
	// only emitted once it is caught up to the buffer head. It is read and
	// written from this single goroutine, so no synchronization is needed.
	var lastSeenTsNs int64
	var lastHeartbeatNs int64
	baseEachLogEntryFn := eachLogEntryFn(req, sender, eachEventNotificationFn, &unsyncedEvents)
	// heldAtTsNs remembers the entry a read was held at (for the log line);
	// the rewind target is the last entry actually delivered.
	var heldAtTsNs int64
	// Each read path holds at its own watermark: persisted logs are complete
	// only up to every peer's flush watermark, the ring only up to every
	// peer's delivery watermark.
	holdMemTsNs := func() int64 {
		return resolveAggReadHoldTsNs(fs.filer.MetaAggregator.PeerLowWatermarkTsNs(), time.Now().UnixNano(), metadataGapSettledHorizon)
	}
	// deliveredUpToTsNs tracks the newest position actually handed to the
	// sender (or intentionally skipped by the gap machinery), so a held read
	// can rewind to a position that skips nothing.
	var deliveredUpToTsNs int64
	guardedEachLogEntryFn := func(holdFn func() int64) log_buffer.EachLogEntryFuncType {
		return func(logEntry *filer_pb.LogEntry) (bool, error) {
			if logEntry.TsNs > holdFn() {
				heldAtTsNs = logEntry.TsNs
				return false, errHeldByPeerWatermark
			}
			lastSeenTsNs = logEntry.TsNs
			deliveredUpToTsNs = logEntry.TsNs
			return baseEachLogEntryFn(logEntry)
		}
	}
	// Frozen BEFORE each pass lists the log files: per-source flushes are
	// ts-ordered, so everything at or below the frozen value is in the
	// listing; a live value could rise mid-pass and admit entries past files
	// this pass cannot see.
	var diskPassFlushLowTsNs int64
	var diskPassHoldTsNs int64
	// What the last disk pass proved covered: flushed on every peer AND inside
	// the pass's listing, so an empty pass proves (cursor, proven] empty.
	var diskPassProvenTsNs int64
	diskEachLogEntryFn := guardedEachLogEntryFn(func() int64 { return diskPassHoldTsNs })
	memEachLogEntryFn := guardedEachLogEntryFn(holdMemTsNs)
	// waitHeld pauses a held read until a peer reports further progress, or
	// the retry interval elapses (a peer dropped past its grace, or a log file
	// landing that no watermark covers). Arriving data is deliberately not a
	// wake-up: on a cluster that keeps writing there is always an entry past
	// the hold, so waking on it spins the loop without ever releasing the
	// hold. The channel is the one for this read's own watermark - a delivery
	// advance cannot release a flush-held read - and was taken before the pass
	// sampled it, so a rise in between wakes us here instead of being missed.
	// False: context ended.
	waitHeld := func(scope string, watermarkChan <-chan struct{}) bool {
		stats.FilerSubscribeWatermarkHolds.WithLabelValues(scope).Inc()
		glog.V(3).Infof("held at %v (deliveredUpTo %v, flushLow %v, deliveryLow %v) for %v",
			time.Unix(0, heldAtTsNs), time.Unix(0, deliveredUpToTsNs),
			time.Unix(0, fs.filer.MetaAggregator.PeerLowFlushWatermarkTsNs()),
			time.Unix(0, fs.filer.MetaAggregator.PeerLowWatermarkTsNs()), clientName)
		select {
		case <-ctx.Done():
			return false
		case <-time.After(heldWakeFloor):
		}
		select {
		case <-watermarkChan:
		case <-ctx.Done():
			return false
		case <-time.After(unflushedGapRetryInterval):
		}
		return true
	}

	var processedTsNs int64
	var readPersistedLogErr error
	var readInMemoryLogErr error
	var isDone bool
	sentRefs := make(map[string]sentRefState)

	aggBuffer := fs.filer.MetaAggregator.MetaLogBuffer
	gaps := &gapPass{
		fs:       fs,
		req:      req,
		gapStall: gapStall,
		earliest: aggBuffer.GetEarliestTime,
		evicted:  aggBuffer.GetLastEvictedOriginalTsNs,
		flushed:  func() int64 { return diskPassProvenTsNs },
		gapChan:  nil, // nothing local signals a peer's flush; the timer paces it
		dataChan: aggNotifyChan,
		gapReason: func(earliest time.Time, evictedTsNs int64) string {
			return fmt.Sprintf("gap evicted through %v is not on a peer's disk yet (earliest memory %v)",
				time.Unix(0, evictedTsNs), earliest)
		},
	}

	for {

		glog.V(4).Infof("read on disk %v aggregated subscribe %s from %+v", clientName, req.PathPrefix, lastReadTime)

		// Taken before either read samples its watermark, so a rise mid-pass
		// cannot land between the sample and the park below.
		flushChan := fs.filer.MetaAggregator.FlushWatermarkAdvancedChan()
		deliveryChan := fs.filer.MetaAggregator.DeliveryWatermarkAdvancedChan()
		cursorBeforeDiskTsNs := lastReadTime.Time.UnixNano()

		// Observe the flush low-watermark before the pass lists files (see
		// diskPassHoldTsNs above).
		diskPassFlushLowTsNs = fs.filer.MetaAggregator.PeerLowFlushWatermarkTsNs()
		diskPassHoldTsNs = resolveAggReadHoldTsNs(diskPassFlushLowTsNs, time.Now().UnixNano(), metadataGapSettledHorizon)
		diskPassProvenTsNs = diskPassFlushLowTsNs

		if req.ClientSupportsMetadataChunks {
			refsStopTsNs := chunkRefsStopTsNs(diskPassHoldTsNs, req.UntilNs)
			// Nothing above the listing bound is proven by this pass.
			if refsStopTsNs < diskPassProvenTsNs {
				diskPassProvenTsNs = refsStopTsNs
			}
			if refsStopTsNs > lastReadTime.Time.UnixNano() {
				processedTsNs, isDone, readPersistedLogErr = fs.chunkDiskPass(ctx, sender, lastReadTime, refsStopTsNs, sentRefs)
			} else {
				processedTsNs, isDone, readPersistedLogErr = 0, false, nil
			}
		} else {
			processedTsNs, isDone, readPersistedLogErr = fs.filer.ReadPersistedLogBuffer(ctx, lastReadTime, req.UntilNs, diskEachLogEntryFn)
		}
		if errors.Is(readPersistedLogErr, errHeldByPeerWatermark) {
			// Stay at the last delivered entry; the held entry is re-read (and
			// re-checked) by the next pass.
			if processedTsNs > 0 {
				lastReadTime = log_buffer.NewMessagePosition(processedTsNs, gapResumeCursorOffset)
				if processedTsNs > diskAnchorTsNs {
					diskAnchorTsNs = processedTsNs
				}
			}
			// A hold is not a gap: clear any stale ResumeFromDiskError so the
			// next pass's disk-miss handling cannot skip past the held entry.
			readInMemoryLogErr = nil
			if !waitHeld("disk", flushChan) {
				return nil
			}
			continue
		}
		if readPersistedLogErr != nil {
			return fmt.Errorf("reading from persisted logs: %w", readPersistedLogErr)
		}
		if isDone {
			return nil
		}

		glog.V(4).Infof("processed to %v: %v", clientName, processedTsNs)
		diskAdvanced := diskReadAdvanced(processedTsNs, lastReadTime)
		// Read after the disk read (an eviction landing mid-read must count) and
		// in received-ts space: the ring's bumped stopTimes exceed anything on
		// any peer's disk, and gating disk cursors on them parks subscribers
		// that drained every peer's log.
		lastEvictedTsNs := fs.filer.MetaAggregator.MetaLogBuffer.GetLastEvictedOriginalTsNs()
		if diskAdvanced {
			gapStall.resumed()
			reportUnprovenAggregatedCrossing(cursorBeforeDiskTsNs, processedTsNs, lastEvictedTsNs, diskPassFlushLowTsNs, clientName, req.PathPrefix)
			lastReadTime = log_buffer.NewMessagePosition(processedTsNs, gapResumeCursorOffset)
			if processedTsNs > diskAnchorTsNs {
				diskAnchorTsNs = processedTsNs
			}
		} else if readInMemoryLogErr == nil {
			// Nothing on disk and memory never spoke: scan forward for the next
			// day that has logs.
			nextDayTs := util.GetNextDayTsNano(lastReadTime.Time.UnixNano())
			// The day jump delivers nothing; stay put until the hold point
			// covers the skipped range.
			if nextDayTs <= diskPassHoldTsNs {
				position := log_buffer.NewMessagePosition(nextDayTs, gapResumeCursorOffset)
				found, err := fs.filer.HasPersistedLogFiles(position)
				if err != nil {
					return fmt.Errorf("checking persisted log files: %w", err)
				}
				if found {
					gapStall.resumed()
					reportUnprovenAggregatedCrossing(cursorBeforeDiskTsNs, nextDayTs, lastEvictedTsNs, diskPassFlushLowTsNs, clientName, req.PathPrefix)
					lastReadTime = position
					if nextDayTs > diskAnchorTsNs {
						diskAnchorTsNs = nextDayTs
					}
				}
			}
		}

		cursorBeforeResolveTsNs := lastReadTime.Time.UnixNano()
		switch gaps.resolve(ctx, &lastReadTime, &readInMemoryLogErr, diskAdvanced) {
		case gapDone:
			return nil
		case gapContinue:
			// A cursor move here is a give-up or proven-empty skip (original space);
			// anchor it so a later eviction rewind cannot undo the decision.
			if ts := lastReadTime.Time.UnixNano(); ts != cursorBeforeResolveTsNs && ts > diskAnchorTsNs {
				diskAnchorTsNs = ts
			}
			continue
		}

		// A held rewind must not re-park below the gap machinery's
		// intentional skips.
		if lastReadTime.Time.UnixNano() > deliveredUpToTsNs {
			deliveredUpToTsNs = lastReadTime.Time.UnixNano()
		}

		glog.V(4).Infof("read in memory %v aggregated subscribe %s from %+v", clientName, req.PathPrefix, lastReadTime)

		// Sampled before the read: a contiguous (unrefused) read delivers
		// every event whose original timestamp is at or below this.
		preMemDeliveryLowTsNs := fs.filer.MetaAggregator.PeerLowWatermarkTsNs()

		lastReadTime, isDone, readInMemoryLogErr = fs.filer.MetaAggregator.MetaLogBuffer.LoopProcessLogData(aggReaderName, lastReadTime, req.UntilNs, func() bool {
			select {
			case <-ctx.Done():
				return false
			default:
			}
			if !fs.hasClient(req.ClientId, req.ClientEpoch) {
				return false
			}
			// Contiguous and caught up: advance the anchor to the delivery
			// low-watermark so long live tails keep eviction rewinds short.
			// Only once the run is connected to the ring - the empty-ring
			// wait lands here too, with disk files still unshipped below the
			// cursor.
			if memoryHoldsGap(lastReadTime.Time.UnixNano(), aggBuffer.GetLastEvictedOriginalTsNs()) {
				if dl := fs.filer.MetaAggregator.PeerLowWatermarkTsNs(); dl > diskAnchorTsNs {
					diskAnchorTsNs = dl
				}
			}
			lastHeartbeatNs = fs.maybeSendIdleHeartbeat(req, sender, fs.filer.MetaAggregator.MetaLogBuffer, lastReadTime.Time.UnixNano(), lastSeenTsNs, lastHeartbeatNs)
			return true
		}, memEachLogEntryFn)
		if readInMemoryLogErr != nil {
			if errors.Is(readInMemoryLogErr, errHeldByPeerWatermark) {
				// The read was contiguous up to the hold: credit the anchor.
				if preMemDeliveryLowTsNs > diskAnchorTsNs {
					diskAnchorTsNs = preMemDeliveryLowTsNs
				}
				// The cursor already advanced onto the held entry; rewind to
				// the last delivered entry so nothing in between is skipped.
				lastReadTime = log_buffer.NewMessagePosition(deliveredUpToTsNs, gapResumeCursorOffset)
				readInMemoryLogErr = nil
				if !waitHeld("memory", deliveryChan) {
					return nil
				}
				continue
			}
			if errors.Is(readInMemoryLogErr, log_buffer.ResumeFromDiskError) {
				// Fell off the ring: resume the disk pass from the anchor, not
				// the (possibly bumped) cursor - redelivery is within the
				// at-least-once contract, skipping is not. For an anchored
				// cursor this is a no-op, so the gap machinery's re-arm onto
				// the retained window stays reachable.
				lastReadTime = log_buffer.NewMessagePosition(diskAnchorTsNs, gapResumeCursorOffset)
				continue
			}
			glog.Errorf("processed to %v: %v", lastReadTime, readInMemoryLogErr)
			if !errors.Is(readInMemoryLogErr, log_buffer.ResumeError) {
				break
			}
		}
		if isDone {
			return nil
		}
		if !fs.hasClient(req.ClientId, req.ClientEpoch) {
			glog.V(0).Infof("client %v is closed", clientName)
			return nil
		}

		// Wait for new data (event-driven instead of 1127ms polling).
		// Drain any stale notification first to avoid a spurious wake-up.
		select {
		case <-aggNotifyChan:
		default:
		}
		select {
		case <-aggNotifyChan:
		case <-ctx.Done():
			return nil
		}
	}

	return readInMemoryLogErr

}

func (fs *FilerServer) SubscribeLocalMetadata(req *filer_pb.SubscribeMetadataRequest, stream filer_pb.SeaweedFiler_SubscribeLocalMetadataServer) error {

	ctx := stream.Context()
	peerAddress := findClientAddress(ctx, 0)

	// use negative client id to differentiate from addClient()/deleteClient() used in SubscribeMetadata()
	req.ClientId = -req.ClientId

	isReplacing, alreadyKnown, clientName := fs.addClient("local", req.ClientName, peerAddress, req.PathPrefix, req.ClientId, req.ClientEpoch)
	if isReplacing {
	} else if alreadyKnown {
		return fmt.Errorf("duplicated local subscription detected for client %s clientId:%d", clientName, req.ClientId)
	}
	defer func() {
		glog.V(0).Infof("disconnect %v local subscriber %s clientId:%d", clientName, req.PathPrefix, req.ClientId)
		fs.deleteClient("local", clientName, req.ClientId, req.ClientEpoch)
	}()

	lastReadTime := log_buffer.NewMessagePosition(req.SinceNs, gapResumeCursorOffset)
	glog.V(0).Infof(" + %v local subscribe %s from %+v clientId:%d", clientName, req.PathPrefix, lastReadTime, req.ClientId)

	sender := newPipelinedSender(stream, 1024, req.ClientSupportsBatching)
	defer sender.Close()

	// Bounded gap waits use the buffer's subscriber notification plus a retry
	// timer, so a flush landing between the disk read and the wait cannot
	// strand the subscriber (no lost-wakeup window). Key includes clientId/
	// epoch so a replacement stream never shares (and loses) the channel.
	localNotifyName := fmt.Sprintf("localGap:%s:%d:%d", clientName, req.ClientId, req.ClientEpoch)
	// Same key shape for the reader: LoopProcessLogData registers it as a
	// subscriber internally, once per loop iteration.
	localReaderName := fmt.Sprintf("localMeta:%s:%d:%d", clientName, req.ClientId, req.ClientEpoch)
	localFlushChan := fs.filer.LocalMetaLogBuffer.RegisterFlushSubscriber(localNotifyName)
	defer fs.filer.LocalMetaLogBuffer.UnregisterFlushSubscriber(localNotifyName)

	gapStall := &gapStallReporter{scope: "local", clientName: clientName, pathPrefix: req.PathPrefix}
	defer gapStall.close()

	var unsyncedEvents int64
	eachEventNotificationFn := fs.eachEventNotificationFn(req, sender, clientName, &unsyncedEvents)

	// lastSeenTsNs tracks how far the subscriber has read so idle heartbeats are
	// only emitted once it is caught up to the buffer head. It is read and
	// written from this single goroutine, so no synchronization is needed.
	var lastSeenTsNs int64
	var lastHeartbeatNs int64
	var lastFlushReportNs int64
	baseEachLogEntryFn := eachLogEntryFn(req, sender, eachEventNotificationFn, &unsyncedEvents)
	eachLogEntryFn := func(logEntry *filer_pb.LogEntry) (bool, error) {
		lastSeenTsNs = logEntry.TsNs
		return baseEachLogEntryFn(logEntry)
	}

	var processedTsNs int64
	var readPersistedLogErr error
	var readInMemoryLogErr error
	var isDone bool
	var lastCheckedFlushTsNs int64 = -1 // Track the last flushed time we checked
	var lastDiskReadTsNs int64 = -1     // Track the last read position we used for disk read
	sentRefs := make(map[string]sentRefState)

	localBuffer := fs.filer.LocalMetaLogBuffer
	gaps := &gapPass{
		fs:       fs,
		req:      req,
		gapStall: gapStall,
		earliest: localBuffer.GetEarliestTime,
		evicted:  localBuffer.GetLastEvictedTsNs, // local disk carries the ring's own timestamps
		flushed:  func() int64 { return lastCheckedFlushTsNs },
		gapChan:  localFlushChan,
		dataChan: localFlushChan,
		gapReason: func(earliest time.Time, evictedTsNs int64) string {
			return fmt.Sprintf("gap is not flushed yet (earliest memory %v, flushed through %v)",
				earliest, time.Unix(0, lastCheckedFlushTsNs))
		},
	}

	for {
		// Check if new data has been flushed to disk since last check, or if read position advanced
		currentFlushTsNs := fs.filer.LocalMetaLogBuffer.GetLastFlushTsNs()
		currentReadTsNs := lastReadTime.Time.UnixNano()
		// Read from disk if: first time, new flush observed, or read position advanced (draining backlog)
		shouldReadFromDisk := lastCheckedFlushTsNs == -1 ||
			currentFlushTsNs > lastCheckedFlushTsNs ||
			currentReadTsNs > lastDiskReadTsNs

		diskAdvanced := false
		if shouldReadFromDisk {
			// Record the position we are about to read from
			lastDiskReadTsNs = currentReadTsNs
			glog.V(4).Infof("read on disk %v local subscribe %s from %+v (lastFlushed: %v)", clientName, req.PathPrefix, lastReadTime, time.Unix(0, currentFlushTsNs))
			if req.ClientSupportsMetadataChunks {
				processedTsNs, isDone, readPersistedLogErr = fs.chunkDiskPass(ctx, sender, lastReadTime, req.UntilNs, sentRefs)
			} else {
				processedTsNs, isDone, readPersistedLogErr = fs.filer.ReadPersistedLogBuffer(ctx, lastReadTime, req.UntilNs, eachLogEntryFn)
			}
			if readPersistedLogErr != nil {
				glog.V(0).Infof("read on disk %v local subscribe %s from %+v: %v", clientName, req.PathPrefix, lastReadTime, readPersistedLogErr)
				return fmt.Errorf("reading from persisted logs: %w", readPersistedLogErr)
			}
			if isDone {
				return nil
			}

			// Update the last checked flushed time
			lastCheckedFlushTsNs = currentFlushTsNs

			diskAdvanced = diskReadAdvanced(processedTsNs, lastReadTime)
			if diskAdvanced {
				gapStall.resumed()
				lastReadTime = log_buffer.NewMessagePosition(processedTsNs, gapResumeCursorOffset)
			} else if readInMemoryLogErr == nil {
				// Nothing on disk and memory never spoke: scan forward for the
				// next day that has logs.
				nextDayTs := util.GetNextDayTsNano(lastReadTime.Time.UnixNano())
				position := log_buffer.NewMessagePosition(nextDayTs, gapResumeCursorOffset)
				found, err := fs.filer.HasPersistedLogFiles(position)
				if err != nil {
					return fmt.Errorf("checking persisted log files: %w", err)
				}
				if found {
					gapStall.resumed()
					lastReadTime = position
				}
			}
		}

		switch gaps.resolve(ctx, &lastReadTime, &readInMemoryLogErr, diskAdvanced) {
		case gapDone:
			return nil
		case gapContinue:
			continue
		}

		glog.V(3).Infof("read in memory %v local subscribe %s from %+v", clientName, req.PathPrefix, lastReadTime)

		lastReadTime, isDone, readInMemoryLogErr = fs.filer.LocalMetaLogBuffer.LoopProcessLogData(localReaderName, lastReadTime, req.UntilNs, func() bool {
			select {
			case <-ctx.Done():
				return false
			default:
			}
			if !fs.hasClient(req.ClientId, req.ClientEpoch) {
				return false
			}
			lastHeartbeatNs = fs.maybeSendIdleHeartbeat(req, sender, fs.filer.LocalMetaLogBuffer, lastReadTime.Time.UnixNano(), lastSeenTsNs, lastHeartbeatNs)
			lastFlushReportNs = fs.maybeSendFlushReport(req, sender, lastFlushReportNs)
			return true
		}, eachLogEntryFn)
		if readInMemoryLogErr != nil {
			if errors.Is(readInMemoryLogErr, log_buffer.ResumeFromDiskError) {
				// Fell behind the ring: back to the disk pass (it re-runs when
				// the flush or the cursor moved), and from there to the gap
				// resolution above if the disk has nothing either.
				continue
			}
			glog.Errorf("processed to %v: %v", lastReadTime, readInMemoryLogErr)
			if !errors.Is(readInMemoryLogErr, log_buffer.ResumeError) {
				break
			}
		}
		if isDone {
			return nil
		}
		if !fs.hasClient(req.ClientId, req.ClientEpoch) {
			return nil
		}
	}

	return readInMemoryLogErr

}

func eachLogEntryFn(req *filer_pb.SubscribeMetadataRequest, sender metadataStreamSender, eachEventNotificationFn func(dirPath string, eventNotification *filer_pb.EventNotification, tsNs int64) error, filtered *int64) log_buffer.EachLogEntryFuncType {
	// A shallow scan of the path fields skips unmarshaling chunk-heavy events
	// this subscriber would filter out anyway; scan surprises fall back to the
	// full decode. Only a delivery resets the shared unsynced-events counter.
	prefilter := req.PathPrefix != "" || len(req.PathPrefixes) > 0 || len(req.Directories) > 0
	return func(logEntry *filer_pb.LogEntry) (bool, error) {
		if prefilter {
			if skeleton, ok := filer_pb.ScanMetadataEventSkeleton(logEntry.Data); ok &&
				!filer_pb.MetadataEventMatchesSubscription(skeleton, req.PathPrefix, req.PathPrefixes, req.Directories) {
				*filtered++
				if *filtered > MaxUnsyncedEvents {
					if err := sender.Send(&filer_pb.SubscribeMetadataResponse{
						EventNotification: &filer_pb.EventNotification{},
						TsNs:              skeleton.TsNs,
					}); err != nil {
						return false, err
					}
					*filtered = 0
				}
				return false, nil
			}
		}
		event := &filer_pb.SubscribeMetadataResponse{}
		// proto.Unmarshal (not UnmarshalVT) validates UTF-8 in string fields, so
		// malformed metadata is rejected here instead of reaching path filtering
		// and subscribers.
		if err := proto.Unmarshal(logEntry.Data, event); err != nil {
			glog.Errorf("unexpected unmarshal filer_pb.SubscribeMetadataResponse: %v", err)
			return false, fmt.Errorf("unexpected unmarshal filer_pb.SubscribeMetadataResponse: %w", err)
		}

		if err := eachEventNotificationFn(event.Directory, event.EventNotification, event.TsNs); err != nil {
			return false, err
		}

		return false, nil
	}
}

// maybeSendFlushReport reports the local flush watermark to a subscriber that
// opted into idle heartbeats (peer aggregators). Unlike heartbeats it is sent
// regardless of catch-up state, and its TsNs stays zero so it never advances
// delivery freshness.
func (fs *FilerServer) maybeSendFlushReport(req *filer_pb.SubscribeMetadataRequest, sender metadataStreamSender, lastFlushReportNs int64) int64 {
	if !req.ClientSupportsIdleHeartbeat || fs.filer == nil {
		return lastFlushReportNs
	}
	now := time.Now().UnixNano()
	if now-lastFlushReportNs < int64(idleHeartbeatInterval) {
		return lastFlushReportNs
	}
	if err := sender.Send(&filer_pb.SubscribeMetadataResponse{FlushedTsNs: fs.filer.LocalFlushedThroughTsNs(now)}); err != nil {
		glog.V(0).Infof("=> flush report to %s: %v", req.ClientName, err)
		return lastFlushReportNs
	}
	return now
}

// maybeSendIdleHeartbeat emits an empty response carrying the current time when
// the subscriber has consumed everything up to the buffer head. The client uses
// it to advance freshness signals (e.g. filer.sync's sync_offset) without moving
// its resume checkpoint, so a restart still re-reads from the last real event.
//
// The catch-up floor is the max of two read-progress markers:
//   - readPositionTsNs: how far the read cursor has advanced. It starts at
//     SinceNs and also covers metadata-chunks mode, where persisted entries are
//     replayed as log file refs rather than through eachLogEntryFn.
//   - lastSeenTsNs: the timestamp of the most recent entry streamed in this
//     call. It advances live while reading the in-memory backlog, before the
//     read cursor returned by LoopProcessLogData has been updated.
//
// While the buffer head is past that floor the subscriber is still behind (e.g.
// replaying a backlog) and no heartbeat is sent. Returns the (possibly advanced)
// lastHeartbeatNs.
func (fs *FilerServer) maybeSendIdleHeartbeat(req *filer_pb.SubscribeMetadataRequest, sender metadataStreamSender, logBuffer *log_buffer.LogBuffer, readPositionTsNs, lastSeenTsNs, lastHeartbeatNs int64) int64 {
	if !req.ClientSupportsIdleHeartbeat {
		return lastHeartbeatNs
	}
	floorTsNs := lastSeenTsNs
	if readPositionTsNs > floorTsNs {
		floorTsNs = readPositionTsNs
	}
	if logBuffer.LastTsNs.Load() > floorTsNs {
		// the buffer holds data the subscriber has not reached yet
		return lastHeartbeatNs
	}
	isLocalStream := fs.filer != nil && logBuffer == fs.filer.LocalMetaLogBuffer
	interval := idleHeartbeatInterval
	if isLocalStream && fs.filer.MetaAggregator != nil && fs.filer.MetaAggregator.HasRemotePeers() {
		interval = peerDeliveryClaimInterval
	}
	now := time.Now().UnixNano()
	if now-lastHeartbeatNs < int64(interval) {
		return lastHeartbeatNs
	}
	// On the local stream the heartbeat is a delivery claim to a peer
	// aggregator and piggybacks the flush watermark; the aggregated ring
	// never flushes, so its heartbeats carry neither. The claims are capped
	// by the in-flight floor and fence later stamps above themselves, so
	// re-checking the buffer head afterwards closes the append race: an
	// event at or below the claim was in flight (capping it), stamped later
	// (fenced above it), or already appended here - and then the head check
	// proves this stream has sent it before the heartbeat.
	heartbeat := &filer_pb.SubscribeMetadataResponse{TsNs: now}
	if isLocalStream {
		heartbeat.TsNs = fs.filer.LocalDeliveredThroughTsNs(now)
		heartbeat.FlushedTsNs = fs.filer.LocalFlushedThroughTsNs(now)
		if logBuffer.LastTsNs.Load() > floorTsNs {
			return lastHeartbeatNs
		}
	}
	if err := sender.Send(heartbeat); err != nil {
		glog.V(0).Infof("=> idle heartbeat to %s: %v", req.ClientName, err)
		return lastHeartbeatNs
	}
	// A heartbeat is a send too: advance the freshness gauge so an idle but
	// healthy subscriber doesn't look stale. The gauge otherwise only moves on
	// real matching events, which never arrive on a quiet path.
	var sourceFiler string
	if fs.option != nil {
		sourceFiler = fs.option.Host.String()
	}
	stats.FilerServerLastSendTsOfSubscribeGauge.WithLabelValues(sourceFiler, req.ClientName, req.PathPrefix).Set(float64(now))
	return now
}

// chunkDiskPass is the disk step for chunk-capable clients: ship the unsent
// refs, then advance the cursor to the shipped content's own end - the final
// entry timestamp of each filer's last shipped chunk, decoded through the
// shared chunk cache. Deriving the cursor from the shipped set itself keeps
// the three positions that must agree in lockstep: the client's refs cover
// exactly up to the cursor, the transition marker (which becomes the client's
// refs filter) equals it, and the memory pass delivers strictly after it - no
// range is decoded twice and none is dropped. The transition is the
// empty-notification marker: both chunk consumers buffer refs until a non-ref
// message, so an idle source would otherwise strand the backlog in the
// client's pending list until the next mutation.
func (fs *FilerServer) chunkDiskPass(ctx context.Context, sender metadataStreamSender, startPos log_buffer.MessagePosition, untilNs int64, sent map[string]sentRefState) (processedTsNs int64, isDone bool, err error) {
	collected, _, err := fs.filer.CollectLogFileRefs(ctx, startPos, untilNs)
	if err != nil {
		return 0, false, err
	}
	refs := deltaLogFileRefs(collected, sent, filer.PersistedLogScanStartTsNs(startPos.Time))
	if len(refs) == 0 {
		return startPos.Time.UnixNano(), false, nil
	}
	if err := fs.sendRefsBatched(sender, refs); err != nil {
		return 0, false, err
	}

	// Shipped content end, read from the shipped chunks alone - a fresh
	// listing here could see a concurrent append and move the cursor past
	// unshipped content. The probe mirrors the client's reader exactly (per
	// file the readable prefix, per filer the newest file with content), so
	// the marker never claims events the client will not apply, and it cannot
	// fail: a dead volume must not block the transition the client waits on.
	cursorTsNs := startPos.Time.UnixNano()
	refsPerFiler := make(map[string][]*filer_pb.LogFileChunkRef, 2)
	for _, ref := range refs {
		refsPerFiler[ref.FilerId] = append(refsPerFiler[ref.FilerId], ref)
	}
	for filerId, filerRefs := range refsPerFiler {
		tailTsNs, answeredFileTsNs, ok, complete := fs.filer.LastShippedLogEntryTsNsForFiler(filerRefs)
		if ok && tailTsNs > cursorTsNs {
			cursorTsNs = tailTsNs
		}
		// Refs the cursor did not reach must re-ship on a later pass: their
		// content sits above the marker, and sent-state that outlives a
		// transient probe failure would strand the cursor behind them for the
		// life of the connection - parking aggregated streams below the
		// watermark. A prefix-limited answer re-ships the answering file too,
		// or its unread suffix is abandoned the moment a later append advances
		// past it. Re-shipped entries at or below the client's checkpoint are
		// filtered client-side, and batches are marker-separated, so a
		// re-shipped whole file cannot rewind a merge mid-batch.
		for _, ref := range filerRefs {
			if refNeedsReship(ref.FileTsNs, ok, answeredFileTsNs, complete) {
				delete(sent, sentRefKey(filerId, ref.FileTsNs))
			}
		}
	}
	// A file selected before the bound can hold entries past it. The client
	// filters those but adopts the marker as its checkpoint, so an unclamped
	// marker makes a later bounded request skip them.
	if untilNs != 0 && cursorTsNs > untilNs {
		cursorTsNs = untilNs
	}
	if err := sender.Send(&filer_pb.SubscribeMetadataResponse{
		EventNotification: &filer_pb.EventNotification{},
		TsNs:              cursorTsNs,
	}); err != nil {
		return 0, false, err
	}
	return cursorTsNs, false, nil
}

// sendRefsBatched sends refs through the pipelined sender, which keeps them
// out of Events batches; gRPC allows one sending goroutine per stream and the
// sender's goroutine is it.
func (fs *FilerServer) sendRefsBatched(sender metadataStreamSender, refs []*filer_pb.LogFileChunkRef) error {
	const maxRefsPerMessage = 64
	for i := 0; i < len(refs); i += maxRefsPerMessage {
		end := i + maxRefsPerMessage
		if end > len(refs) {
			end = len(refs)
		}
		if err := sender.Send(&filer_pb.SubscribeMetadataResponse{LogFileRefs: refs[i:end]}); err != nil {
			return err
		}
	}
	return nil
}

// sentRefState tracks, per subscription, how many chunks of each log file have
// been shipped as refs. Collection re-lists files up to a flush interval behind
// the cursor (the spanning-file back-off), and a filer appends further chunks
// to its newest file, so consecutive collections overlap; shipping only each
// file's unsent chunk suffix keeps every per-filer ref stream duplicate-free
// and timestamp-sorted - the contract the client's merge reads them under.
type sentRefState struct {
	chunks   int
	fileTsNs int64
}

// refNeedsReship says whether a shipped ref's sent state must be dropped so a
// later pass re-ships it: everything above the file that answered the probe
// (the cursor never reached it), the answering file itself when its read was
// prefix-limited (its unread suffix would otherwise be abandoned the moment a
// later append advances past it), and everything when nothing answered. Files
// below a complete answer stay sent: the client has moved past them, and
// re-shipping cannot rewind its filter.
func refNeedsReship(fileTsNs int64, answered bool, answeredFileTsNs int64, complete bool) bool {
	if !answered {
		return true
	}
	if fileTsNs > answeredFileTsNs {
		return true
	}
	return fileTsNs == answeredFileTsNs && !complete
}

func sentRefKey(filerId string, fileTsNs int64) string {
	return fmt.Sprintf("%s/%d", filerId, fileTsNs)
}

// deltaLogFileRefs reduces a collection to the chunks not yet shipped, updates
// the sent state, and prunes files the scan window has moved past.
//
// A shipped suffix is rebased to logical offset zero: the client's chunk
// reader starts at zero, and a chunk list opening at a higher offset reads as
// instant EOF - an empty replay that would silently drop the appended events.
// The cut is record-aligned because each append is one chunk of whole entries
// (logFlushFunc appends one uploaded window per flush), so the rebased suffix
// decodes as a file of its own.
func deltaLogFileRefs(refs []*filer_pb.LogFileChunkRef, sent map[string]sentRefState, pruneBeforeTsNs int64) []*filer_pb.LogFileChunkRef {
	out := make([]*filer_pb.LogFileChunkRef, 0, len(refs))
	for _, ref := range refs {
		key := sentRefKey(ref.FilerId, ref.FileTsNs)
		prior := sent[key].chunks
		if len(ref.Chunks) <= prior {
			continue
		}
		chunks := ref.Chunks[prior:]
		if base := chunks[0].Offset; base != 0 {
			rebased := make([]*filer_pb.FileChunk, len(chunks))
			for i, c := range chunks {
				cc := proto.Clone(c).(*filer_pb.FileChunk)
				cc.Offset -= base
				rebased[i] = cc
			}
			chunks = rebased
		}
		out = append(out, &filer_pb.LogFileChunkRef{
			Chunks:   chunks,
			FileTsNs: ref.FileTsNs,
			FilerId:  ref.FilerId,
		})
		sent[key] = sentRefState{chunks: len(ref.Chunks), fileTsNs: ref.FileTsNs}
	}
	for key, st := range sent {
		if st.fileTsNs < pruneBeforeTsNs {
			delete(sent, key)
		}
	}
	return out
}

func (fs *FilerServer) eachEventNotificationFn(req *filer_pb.SubscribeMetadataRequest, sender metadataStreamSender, clientName string, filtered *int64) func(dirPath string, eventNotification *filer_pb.EventNotification, tsNs int64) error {
	return func(dirPath string, eventNotification *filer_pb.EventNotification, tsNs int64) error {
		defer func() {
			if *filtered > MaxUnsyncedEvents {
				if err := sender.Send(&filer_pb.SubscribeMetadataResponse{
					EventNotification: &filer_pb.EventNotification{},
					TsNs:              tsNs,
				}); err == nil {
					*filtered = 0
				}
			}
		}()

		*filtered++
		foundSelf := false
		for _, sig := range eventNotification.Signatures {
			if sig == req.Signature && req.Signature != 0 {
				return nil
			}
			if sig == fs.filer.Signature {
				foundSelf = true
			}
		}
		if !foundSelf {
			eventNotification.Signatures = append(eventNotification.Signatures, fs.filer.Signature)
		}

		// get complete path to the file or directory
		var entryName string
		if eventNotification.OldEntry != nil {
			entryName = eventNotification.OldEntry.Name
		} else if eventNotification.NewEntry != nil {
			entryName = eventNotification.NewEntry.Name
		}

		fullpath := util.Join(dirPath, entryName)

		// skip on filer internal meta logs
		if strings.HasPrefix(fullpath, filer.SystemLogDir) {
			return nil
		}

		message := &filer_pb.SubscribeMetadataResponse{
			Directory:         dirPath,
			EventNotification: eventNotification,
			TsNs:              tsNs,
		}

		if !filer_pb.MetadataEventMatchesSubscription(message, req.PathPrefix, req.PathPrefixes, req.Directories) {
			return nil
		}

		// collect timestamps for path
		stats.FilerServerLastSendTsOfSubscribeGauge.WithLabelValues(fs.option.Host.String(), req.ClientName, req.PathPrefix).Set(float64(tsNs))

		// println("sending", dirPath, entryName)
		if err := sender.Send(message); err != nil {
			glog.V(0).Infof("=> client %v: %+v", clientName, err)
			return err
		}
		*filtered = 0
		return nil
	}
}

func (fs *FilerServer) addClient(scope string, clientType string, clientAddress string, pathPrefix string, clientId int32, clientEpoch int32) (isReplacing, alreadyKnown bool, clientName string) {
	clientName = clientType + "@" + clientAddress
	glog.V(0).Infof("+ %v listener %v clientId %v clientEpoch %v", scope, clientName, clientId, clientEpoch)
	if clientId != 0 {
		fs.knownListenersLock.Lock()
		defer fs.knownListenersLock.Unlock()
		epoch, found := fs.knownListeners[clientId]
		if !found || epoch < clientEpoch {
			fs.knownListeners[clientId] = clientEpoch
			isReplacing = true
			if fs.subscribers == nil {
				fs.subscribers = make(map[int32]*metadataSubscriber)
			}
			fs.subscribers[clientId] = &metadataSubscriber{
				clientName:    clientName,
				clientType:    clientType,
				address:       clientAddress,
				pathPrefix:    pathPrefix,
				clientId:      clientId,
				clientEpoch:   clientEpoch,
				connectedAtNs: time.Now().UnixNano(),
			}
		} else {
			alreadyKnown = true
		}
	}
	return
}

func (fs *FilerServer) deleteClient(scope string, clientName string, clientId int32, clientEpoch int32) {
	glog.V(0).Infof("- %v listener %v clientId %v clientEpoch %v", scope, clientName, clientId, clientEpoch)
	if clientId != 0 {
		fs.knownListenersLock.Lock()
		defer fs.knownListenersLock.Unlock()
		epoch, found := fs.knownListeners[clientId]
		if found && epoch <= clientEpoch {
			delete(fs.knownListeners, clientId)
			delete(fs.subscribers, clientId)
		}
	}
}

func (fs *FilerServer) hasClient(clientId int32, clientEpoch int32) bool {
	if clientId != 0 {
		fs.knownListenersLock.Lock()
		defer fs.knownListenersLock.Unlock()
		epoch, found := fs.knownListeners[clientId]
		if found && epoch <= clientEpoch {
			return true
		}
	}
	return false
}
