Skip to content

Commit 0d47bc6

Browse files
RIT3shSapatatorcolvinCopilot
authored
CBG-5359: Remove fatal panic handler calls from buildRevokedFeed and changesFeed (#8316)
Co-authored-by: Tor Colvin <tor.colvin@couchbase.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 7bf3214 commit 0d47bc6

2 files changed

Lines changed: 130 additions & 4 deletions

File tree

db/changes.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,8 +254,16 @@ func (db *DatabaseCollectionWithUser) buildRevokedFeed(ctx context.Context, ch c
254254
}
255255

256256
go func() {
257-
defer base.FatalPanicHandler(ctx)
258-
defer close(feed)
257+
defer func() {
258+
if panicked := recover(); panicked != nil {
259+
base.WarnfCtx(ctx, "Unexpected panic building revoked feed: %v\n%s", panicked, debug.Stack())
260+
select {
261+
case feed <- &ChangeEntry{Err: base.ErrChannelFeed}:
262+
case <-options.ChangesCtx.Done():
263+
}
264+
}
265+
close(feed)
266+
}()
259267
var itemsSent int
260268
var lastSeq uint64
261269

@@ -456,8 +464,16 @@ func (db *DatabaseCollectionWithUser) changesFeed(ctx context.Context, singleCha
456464
paginationOptions.Since.LowSeq = 0
457465

458466
go func() {
459-
defer base.FatalPanicHandler(ctx)
460-
defer close(feed)
467+
defer func() {
468+
if panicked := recover(); panicked != nil {
469+
base.WarnfCtx(ctx, "Unexpected panic building changes feed: %v\n%s", panicked, debug.Stack())
470+
select {
471+
case feed <- &ChangeEntry{Err: base.ErrChannelFeed}:
472+
case <-options.ChangesCtx.Done():
473+
}
474+
}
475+
close(feed)
476+
}()
461477
var itemsSent int
462478
var lastSeq uint64
463479
// Pagination based on ChannelQueryLimit. This loop may terminated in three ways (see return statements):

rest/revocation_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2722,3 +2722,113 @@ func TestRevocationDeletedRole(t *testing.T) {
27222722
assert.Equal(t, "doc", changes.Results[0].ID)
27232723
assert.True(t, changes.Results[0].Revoked)
27242724
}
2725+
2726+
func TestRevokedFeedPanicLosesRevocations(t *testing.T) {
2727+
defer db.SuspendSequenceBatching()()
2728+
2729+
const triggerDocID = "doc-ch105"
2730+
var (
2731+
armed atomic.Bool
2732+
panicFired atomic.Int32
2733+
)
2734+
2735+
leakyConfig := base.LeakyBucketConfig{
2736+
GetWithXattrCallback: func(key string) error {
2737+
if !armed.Load() {
2738+
return nil
2739+
}
2740+
if key != triggerDocID {
2741+
return nil
2742+
}
2743+
// Fire exactly once so the recover catches a single panic and we don't
2744+
// loop on retries triggered by the changes feed.
2745+
if panicFired.Add(1) != 1 {
2746+
return nil
2747+
}
2748+
panic("injected revoked-feed panic for " + key)
2749+
},
2750+
}
2751+
2752+
revocationTester, rt := InitScenario(t, &RestTesterConfig{
2753+
LeakyBucketConfig: &leakyConfig,
2754+
})
2755+
defer rt.Close()
2756+
2757+
// foo grants both ch1 and ch2, so removing foo later revokes every doc.
2758+
revocationTester.addRole("user", "foo")
2759+
revocationTester.addRoleChannel("foo", "ch1")
2760+
revocationTester.addRoleChannel("foo", "ch2")
2761+
2762+
const numDocs = 10
2763+
for i := 0; i < numDocs; i++ {
2764+
_ = rt.PutDoc(fmt.Sprintf("doc-ch1%02d", i), `{"channels": ["ch1"]}`)
2765+
}
2766+
2767+
for i := 0; i < numDocs; i++ {
2768+
_ = rt.PutDoc(fmt.Sprintf("doc-ch2%02d", i), `{"channels": ["ch2"]}`)
2769+
}
2770+
2771+
// User catches up: user doc + all 2*numDocs docs visible as normal changes.
2772+
initial := revocationTester.getChanges("0", 2*numDocs+1)
2773+
sinceVal := initial.Last_Seq.String()
2774+
2775+
// Revoke the role -> ch1 and ch2 access is lost -> revocations for all
2776+
// 2*numDocs docs are pending.
2777+
revocationTester.removeRole("user", "foo")
2778+
rt.WaitForPendingChanges()
2779+
2780+
// Arm the panic AFTER the test setup so that the bucket reads done during
2781+
// PutDoc/_changes during setup don't trip the trigger.
2782+
armed.Store(true)
2783+
defer armed.Store(false)
2784+
2785+
panickedChanges := rt.GetChanges(
2786+
fmt.Sprintf("/{{.keyspace}}/_changes?since=%s&revocations=true", sinceVal),
2787+
"user",
2788+
)
2789+
2790+
require.Equal(t, int32(1), panicFired.Load(),
2791+
"the leaky bucket callback must have actually panicked during the changes request")
2792+
2793+
// The panic fires partway through the ch1 revocations, so the panicked
2794+
// response is necessarily incomplete: it cannot contain all 2*numDocs
2795+
// revocations.
2796+
revokedSeen := map[string]bool{}
2797+
for _, e := range panickedChanges.Results {
2798+
if e.Revoked {
2799+
revokedSeen[e.ID] = true
2800+
}
2801+
}
2802+
require.Less(t, len(revokedSeen), 2*numDocs,
2803+
"test invariant: panic should have suppressed at least one revocation")
2804+
2805+
// Follow-up request from the advanced last_seq must recover every revocation
2806+
// the panic suppressed. Before the fix, last_seq moved past the suppressed
2807+
// revocations and they were lost forever.
2808+
followup := rt.GetChanges(
2809+
fmt.Sprintf("/{{.keyspace}}/_changes?since=%s&revocations=true",
2810+
panickedChanges.Last_Seq.String()),
2811+
"user",
2812+
)
2813+
for _, e := range followup.Results {
2814+
if e.Revoked {
2815+
revokedSeen[e.ID] = true
2816+
}
2817+
}
2818+
2819+
// Every doc (ch1 and ch2) must eventually be seen as revoked across the
2820+
// panicked response plus the follow-up.
2821+
var missing []string
2822+
for i := 0; i < numDocs; i++ {
2823+
for _, prefix := range []string{"doc-ch1", "doc-ch2"} {
2824+
id := fmt.Sprintf("%s%02d", prefix, i)
2825+
if !revokedSeen[id] {
2826+
missing = append(missing, id)
2827+
}
2828+
}
2829+
}
2830+
assert.Empty(t, missing,
2831+
"revocations were permanently lost after panic recovery: %v", missing)
2832+
assert.Len(t, revokedSeen, 2*numDocs,
2833+
"expected every doc to be revoked exactly once across the panicked response and follow-up")
2834+
}

0 commit comments

Comments
 (0)