Replies: 1 comment
|
This is very likely lock serialization, and the sparse/hole structure of this backlog is what makes it worse — though I want to flag upfront that this is a strong, code-grounded hypothesis rather than something I've isolated with a profiler, and it may be compounding with (not replacing) the disk/block-cache contention you already suspected. Both the mirror's write path and the cold-scan consumer's read path take the same single, whole-store The lock// server/filestore.go:174-177
type fileStore struct {
srv *Server
mu sync.RWMutex
state StreamState
...One The read side: one RLock per delivered message
// server/consumer.go:4884-4887
} else {
// No filter here.
sm, sseq, err = o.mset.store.LoadNextMsg(_EMPTY_, false, fseq, &pmsg.StoreMsg)
}// server/filestore.go:9151-9157
func (fs *fileStore) LoadNextMsg(filter string, wc bool, start uint64, sm *StoreMsg) (*StoreMsg, uint64, error) {
if fs.isClosed() {
return nil, 0, ErrStoreClosed
}
fs.mu.RLock()
defer fs.mu.RUnlock()
...For a The write side: two Locks per live message, not oneEvery normal mirrored message is persisted through // server/filestore.go:5127-5129
// Store stores a message. We hold the main filestore lock for any write operation.
func (fs *fileStore) StoreMsg(subj string, hdr, msg []byte, ttl int64) (uint64, int64, error) {
fs.mu.Lock()But this stream isn't just receiving live messages — it's sparse. // server/stream.go:3217-3227
// If the deliver sequence matches then the upstream stream has expired or deleted messages.
if dseq == mset.mirror.dseq+1 {
if err := mset.skipMsgs(mset.mirror.sseq+1, sseq-1); err != nil {
...
}
mset.mirror.dseq++
mset.mirror.sseq = sseq
}
// server/stream.go:3345-3348
if node == nil {
if err := store.SkipMsgs(start, end-start+1); err != nil {
return err
}// server/filestore.go:5238-5240
func (fs *fileStore) SkipMsgs(seq uint64, num uint64) error {
fs.mu.Lock()
defer fs.mu.Unlock()So for every live message that arrives after a gap, B does two exclusive Where I'd push back on my own reasoning: I don't think it's safe to assume these critical sections are uniformly short. Answering the two questions directly
One thing in your own harness worth flagging: |
Uh oh!
There was an error while loading. Please reload this page.
Problem statement
With a JetStream Mirror Stream that has to sync a large sparse backlog
from its source, running a
DeliverAll"cold-scan" Consumer on the mirror atthe same time makes the mirror catch-up ~2.9× slower than without the
Consumer.
Is this expected contention (a cold scanner competing with the mirror write
thread over the file store / block cache), or is there something in the mirror
path that is unnecessarily serialized behind consumer reads?
Setup
Single node, Hub + LeafNode as two separate
nats-serverprocesses (bothJetStream, domains
hubandleaf), mirror on the LeafNode.Environment where it was measured:
nats-serverv2.14.2Replicas: 1Source stream A:
MaxMsgsPerSubject: 1(KV semantics),AllowAtomicPublish,AllowRollup,AllowDirect,Storage: File.Mirror B: same retention/storage,
AllowRollup+AllowDirect, mirroring A via external API prefix$JS.hub.API.A clean buildable reproduction package accompanies it (see the zip).
nats-mirror-consumer-perf-repro.zip
The data shape (why the stream is "sparse")
A KV subject space with hot-key write skew produces a stream with a huge seq
span and few live messages:
keys = 1 000 000subjects written once (cold floor, seq1..1M);FirstSeqstays pinned at 1.hot-keys = 300 000: overwrites only touch the top segment of the key space.prewarm = 3 000 000: overwrites published as fast as possible. Eachoverwrite appends a seq but, with
MaxMsgsPerSubject: 1, removes thesubject's previous message — so live
Msgsstays ≈ 1 M whileLastSeqgrowsto ≈ 4 M.
Result:
span ≈ 4 000 000,holes ≈ 3 000 000, hole ratio ≈ 75%. Everymessage retrieval must skip through ~3 dead seqs on average.
Reproduction
The program drives both
nats-serverprocesses itself (external processes,kept running after exit; next run kills + wipes data for a fresh state) and:
A's whole sparse backlog from scratch, no Consumer.
DeliverAllcold-scanConsumer (AckNone, ephemeral, empty filter subject, re-scans head→tail in a
loop) while B syncs; measures the sync time again.
The sync time is measured by
LastSeqgrowth of the mirror until lag reaches0 — so the difference is purely the Consumer's presence.
Results
Data integrity verified after both runs: mirror message counts match the
source and lag returns to 0, so both steps synced the same data.
questions
and block cache. The scan repeatedly walks
LoadNextMsg → firstMatchingover large stretches of holes while holdingfs.mu(RLock over long spans) and pulling cold blocks into cache.Questions for nats-server maintainers:
lock/block-cache contention, or is there a known inefficiency in the mirror
pull-consumer path when it competes with reads?
memory-cache / store options, consumer tuning, or mirror-specific settings)?
I can provide any additional measurements on request. @derekcollison @MauriceVanVeen
All reactions