Skip to content

Commit 337a5de

Browse files
authored
subnet/etcd: recover single-subnet watch from compaction (#2524)
watchSubnet has the same hot-loop #2471 fixed in watchSubnets: it takes a start revision it never advances, so once etcd compacts past it every reconnect fails identically at the 5s backoff cap, forever. Detect a compacted watch response, re-read the lease at a current revision, and resume from there. If the re-read finds the lease gone, synthesize the EventRemoved the missed delete would have produced, inferred from its absence since the delete itself sits below the compaction horizon. The revoke decision stays with the caller, where CompleteLease already makes it.
1 parent 1e904bb commit 337a5de

2 files changed

Lines changed: 273 additions & 3 deletions

File tree

pkg/subnet/etcd/registry.go

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,17 +431,39 @@ func (esr *etcdSubnetRegistry) watchSubnet(ctx context.Context, since int64, sn
431431
case wresp, ok := <-rch:
432432
err := wresp.Err()
433433
if !ok || err != nil {
434+
cancel()
435+
// If the watch fell behind etcd's compaction horizon, reconnecting
436+
// at the same revision fails identically forever (mvcc: required
437+
// revision has been compacted). Re-read the lease at a current
438+
// revision and resume from there instead of hot-looping.
439+
if isCompacted(wresp) {
440+
log.Warningf("etcd watch for %s fell behind compaction horizon (compact rev %d), re-reading and resuming", key, wresp.CompactRevision)
441+
next, rerr := esr.resyncWatchSubnet(ctx, sn, sn6, leaseWatchChan)
442+
if rerr != nil {
443+
log.Errorf("failed to re-read subnet lease after compaction: %v", rerr)
444+
time.Sleep(exponentialBackoff)
445+
exponentialBackoff = min(exponentialBackoff*2, maxBackoff)
446+
break innerLoop
447+
}
448+
since = next
449+
exponentialBackoff = initialBackoff
450+
break innerLoop
451+
}
434452
if err != nil {
435-
log.Warningf("etcd watch channel for %s closed with error %v, reconnecting...", key, err)
453+
log.Warningf("etcd watch channel for %s closed with error %v, reconnecting from rev %d...", key, err, since)
436454
} else {
437-
log.Warningf("etcd watch channel for %s closed, reconnecting...", key)
455+
log.Warningf("etcd watch channel for %s closed, reconnecting from rev %d...", key, since)
438456
}
439-
cancel()
440457
time.Sleep(exponentialBackoff)
441458
exponentialBackoff = min(exponentialBackoff*2, maxBackoff)
442459
break innerLoop
443460
}
444461
exponentialBackoff = initialBackoff // Reset backoff on success
462+
// Advance the resume revision so a future reconnect picks up where we
463+
// left off rather than replaying from the original start revision.
464+
if wresp.Header.Revision != 0 {
465+
since = wresp.Header.Revision + 1
466+
}
445467
batch := make([]lease.LeaseWatchResult, 0)
446468
for _, etcdEvent := range wresp.Events {
447469
subnetEvent, err := parseSubnetWatchResponse(ctx, esr.cli, etcdEvent)
@@ -581,6 +603,56 @@ func (esr *etcdSubnetRegistry) resyncWatch(ctx context.Context, ch chan []lease.
581603
return getNextIndex(wr.Cursor)
582604
}
583605

606+
// resyncWatchSubnet re-reads a single subnet lease, emits the result on ch and
607+
// returns the revision to resume from. Single-subnet counterpart to resyncWatch.
608+
//
609+
// A lease deleted while the watch was compacted can't be recovered from the
610+
// watch stream, so synthesize the EventRemoved from its absence and leave the
611+
// revoke policy to the caller.
612+
func (esr *etcdSubnetRegistry) resyncWatchSubnet(ctx context.Context, sn ip.IP4Net, sn6 ip.IP6Net, ch chan []lease.LeaseWatchResult) (int64, error) {
613+
key := path.Join(esr.etcdCfg.Prefix, "subnets", subnet.MakeSubnetKey(sn, sn6))
614+
resp, err := esr.kv().Get(ctx, key)
615+
if err != nil {
616+
return 0, err
617+
}
618+
619+
var wr lease.LeaseWatchResult
620+
if len(resp.Kvs) == 0 {
621+
wr = lease.LeaseWatchResult{
622+
Events: []lease.Event{{
623+
Type: lease.EventRemoved,
624+
Lease: lease.Lease{
625+
EnableIPv4: true,
626+
Subnet: sn,
627+
EnableIPv6: !sn6.Empty(),
628+
IPv6Subnet: sn6,
629+
},
630+
}},
631+
Cursor: watchCursor{resp.Header.Revision},
632+
}
633+
} else {
634+
ttlresp, err := esr.cli.TimeToLive(ctx, etcd.LeaseID(resp.Kvs[0].Lease))
635+
if err != nil {
636+
return 0, err
637+
}
638+
l, err := kvToIPLease(resp.Kvs[0], ttlresp.TTL)
639+
if err != nil {
640+
return 0, err
641+
}
642+
wr = lease.LeaseWatchResult{
643+
Snapshot: []lease.Lease{*l},
644+
Cursor: watchCursor{resp.Header.Revision},
645+
}
646+
}
647+
648+
select {
649+
case ch <- []lease.LeaseWatchResult{wr}:
650+
case <-ctx.Done():
651+
return 0, ctx.Err()
652+
}
653+
return getNextIndex(wr.Cursor)
654+
}
655+
584656
// leasesWatchReset is called when incremental lease watch failed and we need to grab a snapshot
585657
func (esr *etcdSubnetRegistry) leasesWatchReset(ctx context.Context) (lease.LeaseWatchResult, error) {
586658
wr := lease.LeaseWatchResult{}

pkg/subnet/etcd/registry_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,117 @@ func TestWatchSubnetsRecoversFromCompaction(t *testing.T) {
282282
}
283283
}
284284

285+
// TestWatchSubnetRecoversFromCompaction is the single-subnet counterpart to
286+
// TestWatchSubnetsRecoversFromCompaction. watchSubnet had the same hot-loop:
287+
// it reconnected at a now-compacted revision forever and never delivered
288+
// anything again. After the fix it must re-read the lease and resume.
289+
func TestWatchSubnetRecoversFromCompaction(t *testing.T) {
290+
integration.BeforeTestExternal(t)
291+
292+
clus := integration.NewCluster(t, &integration.ClusterConfig{Size: 1})
293+
defer clus.Terminate(t)
294+
295+
client := clus.RandClient()
296+
ctx := context.Background()
297+
298+
r, kvApi := newTestEtcdRegistry(t, ctx, client)
299+
300+
if _, err := kvApi.Put(ctx, "/coreos.com/network/config",
301+
`{ "Network": "10.1.0.0/16", "Backend": { "Type": "host-gw" } }`); err != nil {
302+
t.Fatal("Failed to put network config", err)
303+
}
304+
305+
sn := ip.IP4Net{IP: ip.MustParseIP4("10.1.5.0"), PrefixLen: 24}
306+
attrs := &lease.LeaseAttrs{PublicIP: ip.MustParseIP4("1.2.3.4")}
307+
if _, err := r.createSubnet(ctx, sn, ip.IP6Net{}, attrs, 24*time.Hour); err != nil {
308+
t.Fatal("Failed to create subnet lease", err)
309+
}
310+
311+
// Advance the store revision, then compact past it so watching from an old
312+
// revision is guaranteed to hit "required revision has been compacted".
313+
var compactRev int64
314+
for i := 0; i < 5; i++ {
315+
resp, err := kvApi.Put(ctx, "/coreos.com/network/_bump", fmt.Sprintf("%d", i))
316+
if err != nil {
317+
t.Fatal("Failed to bump revision", err)
318+
}
319+
compactRev = resp.Header.Revision
320+
}
321+
if _, err := client.Compact(ctx, compactRev); err != nil {
322+
t.Fatal("Failed to compact etcd", err)
323+
}
324+
325+
receiver := make(chan []lease.LeaseWatchResult, 16)
326+
go func() { _ = r.watchSubnet(ctx, 1, sn, ip.IP6Net{}, receiver) }()
327+
328+
// Recovery must surface a re-read snapshot carrying the watched lease.
329+
if !waitForSnapshot(receiver, sn, 10*time.Second) {
330+
t.Fatal("watchSubnet did not recover from compaction with a re-read snapshot")
331+
}
332+
333+
// Renewing the lease produces a live event, proving the watch resumed at a
334+
// current revision instead of staying stuck on the compacted one.
335+
if _, err := r.updateSubnet(ctx, sn, ip.IP6Net{}, attrs, 24*time.Hour, 0); err != nil {
336+
t.Fatal("Failed to update subnet lease", err)
337+
}
338+
if !waitForEvent(receiver, lease.EventAdded, sn, 10*time.Second) {
339+
t.Fatal("watchSubnet did not resume delivering live events after recovery")
340+
}
341+
}
342+
343+
// TestWatchSubnetReportsRemovalAfterCompaction pins the design call: recovery
344+
// resumes the watch as it already does after an ordinary delete, but a lease
345+
// deleted below the compaction horizon is reported as a synthesized
346+
// EventRemoved rather than silently missed. That leaves the "lease revoked,
347+
// shut down" decision with the caller.
348+
func TestWatchSubnetReportsRemovalAfterCompaction(t *testing.T) {
349+
integration.BeforeTestExternal(t)
350+
351+
clus := integration.NewCluster(t, &integration.ClusterConfig{Size: 1})
352+
defer clus.Terminate(t)
353+
354+
client := clus.RandClient()
355+
ctx := context.Background()
356+
357+
r, kvApi := newTestEtcdRegistry(t, ctx, client)
358+
359+
if _, err := kvApi.Put(ctx, "/coreos.com/network/config",
360+
`{ "Network": "10.1.0.0/16", "Backend": { "Type": "host-gw" } }`); err != nil {
361+
t.Fatal("Failed to put network config", err)
362+
}
363+
364+
sn := ip.IP4Net{IP: ip.MustParseIP4("10.1.5.0"), PrefixLen: 24}
365+
attrs := &lease.LeaseAttrs{PublicIP: ip.MustParseIP4("1.2.3.4")}
366+
if _, err := r.createSubnet(ctx, sn, ip.IP6Net{}, attrs, 24*time.Hour); err != nil {
367+
t.Fatal("Failed to create subnet lease", err)
368+
}
369+
370+
// Delete the lease, then compact past the deletion. This is the case the
371+
// watch cannot observe directly: the delete event itself is now below the
372+
// compaction horizon, so only a re-read can discover it.
373+
if _, err := kvApi.Delete(ctx, "/coreos.com/network/subnets/10.1.5.0-24"); err != nil {
374+
t.Fatal("Failed to delete subnet lease", err)
375+
}
376+
var compactRev int64
377+
for i := 0; i < 5; i++ {
378+
resp, err := kvApi.Put(ctx, "/coreos.com/network/_bump", fmt.Sprintf("%d", i))
379+
if err != nil {
380+
t.Fatal("Failed to bump revision", err)
381+
}
382+
compactRev = resp.Header.Revision
383+
}
384+
if _, err := client.Compact(ctx, compactRev); err != nil {
385+
t.Fatal("Failed to compact etcd", err)
386+
}
387+
388+
receiver := make(chan []lease.LeaseWatchResult, 16)
389+
go func() { _ = r.watchSubnet(ctx, 1, sn, ip.IP6Net{}, receiver) }()
390+
391+
if !waitForEvent(receiver, lease.EventRemoved, sn, 10*time.Second) {
392+
t.Fatal("watchSubnet did not report the lease as removed after compaction")
393+
}
394+
}
395+
285396
// TestResyncWatchCancelWithBlockedReceiver is a regression test for the
286397
// ctx-aware send in resyncWatch. Before the fix, a blocked receiver caused
287398
// resyncWatch to hang indefinitely. After the fix it must return
@@ -370,3 +481,90 @@ func waitForEvent(receiver chan []lease.LeaseWatchResult, etype lease.EventType,
370481
}
371482
}
372483
}
484+
485+
// TestWatchSubnetSurvivesCompactionAfterReconnect reproduces the sequence seen
486+
// in the field, which the tests above only approximate. They start a watch that
487+
// is already below the compaction horizon; here the watch is established and
488+
// healthy first, drifts below the horizon while still connected, and only then
489+
// loses its connection. That drift is what makes this hard to spot in
490+
// production: the watch works perfectly until something unrelated bounces etcd,
491+
// possibly hours later, and only then wedges.
492+
func TestWatchSubnetSurvivesCompactionAfterReconnect(t *testing.T) {
493+
integration.BeforeTestExternal(t)
494+
495+
clus := integration.NewCluster(t, &integration.ClusterConfig{Size: 1})
496+
defer clus.Terminate(t)
497+
498+
client := clus.RandClient()
499+
ctx := context.Background()
500+
501+
r, kvApi := newTestEtcdRegistry(t, ctx, client)
502+
503+
if _, err := kvApi.Put(ctx, "/coreos.com/network/config",
504+
`{ "Network": "10.1.0.0/16", "Backend": { "Type": "host-gw" } }`); err != nil {
505+
t.Fatal("Failed to put network config", err)
506+
}
507+
508+
sn := ip.IP4Net{IP: ip.MustParseIP4("10.1.5.0"), PrefixLen: 24}
509+
attrs := &lease.LeaseAttrs{PublicIP: ip.MustParseIP4("1.2.3.4")}
510+
if _, err := r.createSubnet(ctx, sn, ip.IP6Net{}, attrs, 24*time.Hour); err != nil {
511+
t.Fatal("Failed to create subnet lease", err)
512+
}
513+
514+
// Start where a real caller starts: at the revision the lease was read at,
515+
// which is current, rather than at one that is already compacted.
516+
_, index, err := r.getSubnet(ctx, sn, ip.IP6Net{})
517+
if err != nil {
518+
t.Fatal("Failed to read subnet lease", err)
519+
}
520+
521+
receiver := make(chan []lease.LeaseWatchResult, 32)
522+
go func() { _ = r.watchSubnet(ctx, index+1, sn, ip.IP6Net{}, receiver) }()
523+
524+
// The watch is healthy to begin with.
525+
if _, err := r.updateSubnet(ctx, sn, ip.IP6Net{}, attrs, 24*time.Hour, 0); err != nil {
526+
t.Fatal("Failed to update subnet lease", err)
527+
}
528+
if !waitForEvent(receiver, lease.EventAdded, sn, 10*time.Second) {
529+
t.Fatal("watch did not deliver events before the compaction")
530+
}
531+
532+
// Drift below the compaction horizon while still connected. Nothing breaks
533+
// yet: an established watch keeps working across a compaction.
534+
var compactRev int64
535+
for i := 0; i < 5; i++ {
536+
resp, err := kvApi.Put(ctx, "/coreos.com/network/_bump", fmt.Sprintf("%d", i))
537+
if err != nil {
538+
t.Fatal("Failed to bump revision", err)
539+
}
540+
compactRev = resp.Header.Revision
541+
}
542+
if _, err := client.Compact(ctx, compactRev); err != nil {
543+
t.Fatal("Failed to compact etcd", err)
544+
}
545+
546+
// Bounce etcd. This is the trigger: the watch reconnects at a revision the
547+
// store no longer holds.
548+
clus.Members[0].Stop(t)
549+
if err := clus.Members[0].Restart(t); err != nil {
550+
t.Fatal("Failed to restart etcd member", err)
551+
}
552+
clus.Members[0].WaitOK(t)
553+
554+
// Recovery surfaces a re-read snapshot rather than the individual events
555+
// missed while disconnected, which is inherent to re-listing: the events are
556+
// below the horizon and no longer exist to replay.
557+
if !waitForSnapshot(receiver, sn, 30*time.Second) {
558+
t.Fatal("watch never recovered after reconnecting below the compaction horizon")
559+
}
560+
561+
// Only once recovery has landed is a subsequent change proof that the watch
562+
// resumed at a current revision. Doing this before the snapshot would race:
563+
// the change would simply be absorbed into the re-read.
564+
if _, err := r.updateSubnet(ctx, sn, ip.IP6Net{}, attrs, 24*time.Hour, 0); err != nil {
565+
t.Fatal("Failed to update subnet lease after restart", err)
566+
}
567+
if !waitForEvent(receiver, lease.EventAdded, sn, 30*time.Second) {
568+
t.Fatal("watch recovered but stopped delivering live events")
569+
}
570+
}

0 commit comments

Comments
 (0)