etcd-io/etcd · error
unexpected revision = 0. Calling SyncUpdates before SyncBase
Error message
unexpected revision = 0. Calling SyncUpdates before SyncBase finishes?
What it means
mirror.NewSyncer(...).SyncBase(ctx) returns channels; the syncer records the latest revision it saw as s.rev. SyncUpdates(ctx) starts a watch from s.rev+1 and panics if s.rev is still 0, i.e. when no revision has been captured yet. The panic message itself asks the diagnostic question: SyncUpdates was called before SyncBase had delivered (and the syncer processed) its first response, so there is no valid revision to resume from.
Source
Thrown at client/v3/mirror/syncer.go:117
return
}
respchan <- resp
if !resp.More {
return
}
// move to next key
key = string(append(resp.Kvs[len(resp.Kvs)-1].Key, 0))
}
}()
return respchan, errchan
}
func (s *syncer) SyncUpdates(ctx context.Context) clientv3.WatchChan {
if s.rev == 0 {
panic("unexpected revision = 0. Calling SyncUpdates before SyncBase finishes?")
}
return s.c.Watch(ctx, s.prefix, clientv3.WithPrefix(), clientv3.WithRev(s.rev+1))
}
View on GitHub (pinned to f744d457f4)
Solutions
- Wait until SyncBase has delivered at least one response (or completed) before calling SyncUpdates — read from the SyncBase channels in the same goroutine that then calls SyncUpdates.
- Run the full mirror pattern in one goroutine: consume baseChan/errChan to exhaustion, then start SyncUpdates (see the documented Mirror example in clientv3/mirror).
- If SyncBase's context was cancelled or errored, create a fresh syncer via mirror.NewSyncer instead of continuing with the spent one.
- As a structural guard, wrap SyncUpdates invocation with a check that SyncBase finished (e.g. signal channel closed in your consumer).
Example fix
// before
baseChan, errChan := syncer.SyncBase(ctx)
go func() { for range baseChan {} }() // not drained / racing
upChan := syncer.SyncUpdates(ctx) // panics: unexpected revision = 0
// after — mirror pattern: drain SyncBase fully, then SyncUpdates
baseChan, errChan := syncer.SyncBase(ctx)
for resp := range baseChan {
handle(resp) // syncer records rev from these responses
}
for err := range errChan {
if err != nil { return err }
}
upChan := syncer.SyncUpdates(ctx) // safe: rev > 0 Defensive patterns
Strategy: validation
Validate before calling
// structural guard: only start SyncUpdates after SyncBase channels are exhausted
baseCh, errCh := syncer.SyncBase(ctx)
for r := range baseCh { handle(r) }
for e := range errCh {
if e != nil { return e }
}
// safe point: syncer.rev > 0
upCh := syncer.SyncUpdates(ctx) Prevention
- Consume SyncBase to completion (both channels) in the same goroutine before calling SyncUpdates — the documented mirror pattern.
- Never reuse a syncer whose SyncBase context was cancelled; build a fresh mirror.NewSyncer.
- Treat 'SyncUpdates panics' as a startup-ordering bug in your code, not an etcd failure.
When it happens
Trigger: Calling s.SyncUpdates(ctx) immediately after s.SyncBase(ctx) without waiting for any kv or header to arrive on the returned channels; SyncBase whose context is cancelled before the first range completes; an empty keyspace is fine (the header still carries a revision) but a failed/interrupted initial sync leaves rev==0.
Common situations: Race at service startup: goroutine consuming SyncUpdates starts before the SyncBase goroutine receives the first response; cancelling SyncBase's context on shutdown and then (re)starting SyncUpdates on the same syncer; reusing a syncer after an error instead of creating a new one.
Related errors
- unexpected createdNotify in delete
- Unknown result op
- bad compare value
- bad value %v of type %T
- unsupported stm
AI-assisted analysis of etcd-io/etcd@f744d457f4 (2026-08-15).
Data as JSON: /api/errors/40258aec7ab44ea9.
Report an issue: GitHub.