@@ -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