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

  1. 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.
  2. Run the full mirror pattern in one goroutine: consume baseChan/errChan to exhaustion, then start SyncUpdates (see the documented Mirror example in clientv3/mirror).
  3. If SyncBase's context was cancelled or errored, create a fresh syncer via mirror.NewSyncer instead of continuing with the spent one.
  4. 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

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


AI-assisted analysis of etcd-io/etcd@f744d457f4 (2026-08-15). Data as JSON: /api/errors/40258aec7ab44ea9. Report an issue: GitHub.