Skip to content

Commit 5bb197b

Browse files
authored
fix: prevent duplicate cron entries from concurrent SyncCanaryJob calls (#2984)
* test(sync): add concurrent SyncCanaryJob reschedule race test Spawns 10 goroutines calling SyncCanaryJob concurrently with alternating specs (forcing a changed detection). Without the per-canary-ID lock, the race between Unschedule and newCanaryJob produces multiple orphaned cron entries that survive cleanup sweeps. * fix(sync): prevent duplicate cron entries from concurrent SyncCanaryJob calls Concurrent calls to SyncCanaryJob (from the controller reconcile and the periodic SyncCanaryJobs job) can race: the first call Unschedule's and deletes the canaryJobs map entry, the second sees nil and creates a cron entry, then the first also creates a second cron entry. The orphaned entry survives all cleanup sweeps (SetDifference sees the same UID in both lists) and fires on every schedule tick, doubling check_statuses inserts and inflating aggregated fail counts. Serialize SyncCanaryJob per canary ID with a lazily-initialized mutex from a canarySyncLocks sync.Map to close the race window.
1 parent e9d20f4 commit 5bb197b

3 files changed

Lines changed: 141 additions & 2 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ require (
4444
github.com/labstack/echo/v4 v4.15.1
4545
github.com/liamylian/jsontime/v2 v2.0.0
4646
github.com/lib/pq v1.12.3
47+
github.com/mdelapenya/tlscert v0.2.0
4748
github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.0
4849
github.com/microsoft/go-mssqldb v1.9.8
4950
github.com/nsf/jsondiff v0.0.0-20230430225905-43f6cf3098c1
@@ -66,6 +67,7 @@ require (
6667
github.com/sevennt/echo-pprof v0.1.1-0.20220616082843-66a461746b5f
6768
github.com/spf13/cobra v1.10.2
6869
github.com/spf13/pflag v1.0.10
70+
github.com/testcontainers/testcontainers-go v0.43.0
6971
github.com/testcontainers/testcontainers-go/modules/redis v0.43.0
7072
github.com/timberio/go-datemath v0.1.0
7173
go.mongodb.org/mongo-driver v1.17.9
@@ -339,7 +341,6 @@ require (
339341
github.com/mattn/go-isatty v0.0.20 // indirect
340342
github.com/mattn/go-localereader v0.0.1 // indirect
341343
github.com/mattn/go-runewidth v0.0.21 // indirect
342-
github.com/mdelapenya/tlscert v0.2.0 // indirect
343344
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
344345
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
345346
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -407,7 +408,6 @@ require (
407408
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
408409
github.com/stretchr/testify v1.11.1 // indirect
409410
github.com/temoto/robotstxt v1.1.2 // indirect
410-
github.com/testcontainers/testcontainers-go v0.43.0 // indirect
411411
github.com/tidwall/gjson v1.18.0 // indirect
412412
github.com/tidwall/match v1.2.0 // indirect
413413
github.com/tidwall/pretty v1.2.1 // indirect

pkg/jobs/canary/canary_jobs_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"encoding/json"
55
"fmt"
66
"net/http"
7+
"strings"
8+
"sync"
79
"time"
810

911
canaryCtx "github.com/flanksource/canary-checker/api/context"
@@ -129,3 +131,118 @@ var _ = ginkgo.Describe("Transformed Canary", ginkgo.Ordered, func() {
129131
Expect(transformedCanary.DeletedAt).ToNot(BeNil())
130132
})
131133
})
134+
135+
var _ = ginkgo.Describe("SyncCanaryJob concurrent reschedule", ginkgo.Ordered, func() {
136+
var (
137+
canaryID uuid.UUID
138+
specV1 types.JSON
139+
specV2 types.JSON
140+
dbCanary pkg.Canary
141+
)
142+
143+
ginkgo.BeforeAll(func() {
144+
canaryCtx.DefaultContext = DefaultContext
145+
canaryID = uuid.New()
146+
147+
specV1 = types.JSON(fmt.Sprintf(`{
148+
"schedule": "@every 30s",
149+
"http": [{
150+
"name": "concurrent-test",
151+
"endpoint": "http://127.0.0.1:1/v1"
152+
}]
153+
}`))
154+
155+
specV2 = types.JSON(fmt.Sprintf(`{
156+
"schedule": "@every 30s",
157+
"http": [{
158+
"name": "concurrent-test",
159+
"endpoint": "http://127.0.0.1:1/v2"
160+
}]
161+
}`))
162+
163+
model := &models.Canary{
164+
ID: canaryID,
165+
Name: "concurrent-reschedule-test",
166+
Namespace: "default",
167+
AgentID: uuid.Nil,
168+
Source: "kubernetes/" + canaryID.String(),
169+
Spec: specV1,
170+
}
171+
Expect(DefaultContext.DB().Create(model).Error).To(BeNil())
172+
173+
dbCanary = pkg.Canary{
174+
ID: canaryID,
175+
Name: model.Name,
176+
Spec: specV1,
177+
Source: model.Source,
178+
Namespace: model.Namespace,
179+
}
180+
181+
// Initial sync to populate canaryJobs map and cron.
182+
Expect(SyncCanaryJob(DefaultContext, dbCanary)).To(BeNil())
183+
184+
// Clear any entries accumulated before this spec.
185+
Unschedule(canaryID.String())
186+
})
187+
188+
ginkgo.It("must create exactly 1 cron entry after concurrent reschedules", func() {
189+
// Use spec V2 so DeepEqual detects a change.
190+
dbCanaryV2 := dbCanary
191+
dbCanaryV2.Spec = specV2
192+
193+
// Initial sync with V2 — creates a single cron entry.
194+
Expect(SyncCanaryJob(DefaultContext, dbCanaryV2)).To(BeNil())
195+
196+
before := countCronEntriesForCanary(canaryID.String())
197+
Expect(before).To(Equal(1), "expected exactly 1 cron entry after initial sync")
198+
199+
// Simulate concurrent reschedules. Each goroutine changes the spec
200+
// and calls SyncCanaryJob again, mimicking the race between a
201+
// controller reconcile and the periodic SyncCanaryJobs job.
202+
const goroutines = 10
203+
var wg sync.WaitGroup
204+
errs := make(chan error, goroutines)
205+
206+
for i := 0; i < goroutines; i++ {
207+
wg.Add(1)
208+
go func(i int) {
209+
defer wg.Done()
210+
// Toggle between V1 and V2 so every call sees a
211+
// "changed" spec relative to whatever is in the map.
212+
c := dbCanary
213+
if i%2 == 0 {
214+
c.Spec = specV2
215+
} else {
216+
c.Spec = specV1
217+
}
218+
if err := SyncCanaryJob(DefaultContext, c); err != nil {
219+
errs <- err
220+
}
221+
}(i)
222+
}
223+
wg.Wait()
224+
close(errs)
225+
226+
for err := range errs {
227+
ginkgo.Fail(fmt.Sprintf("unexpected error: %v", err))
228+
}
229+
230+
// Verify only one cron entry exists for this canary.
231+
after := countCronEntriesForCanary(canaryID.String())
232+
Expect(after).To(Equal(1),
233+
"expected exactly 1 cron entry after concurrent reschedules, got %d", after)
234+
})
235+
})
236+
237+
// countCronEntriesForCanary returns the number of cron entries whose
238+
// job carries the given canary Kubernetes UID.
239+
func countCronEntriesForCanary(canaryUID string) int {
240+
count := 0
241+
for _, entry := range CanaryScheduler.Entries() {
242+
jobUID := string(entry.Job.(*job.Job).GetObjectMeta().UID)
243+
if strings.EqualFold(jobUID, canaryUID) {
244+
count++
245+
}
246+
}
247+
return count
248+
}

pkg/jobs/canary/sync.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ import (
2424

2525
var canaryJobs sync.Map
2626

27+
// canarySyncLocks provides per-canary-ID mutual exclusion for SyncCanaryJob
28+
// to prevent a race where two concurrent callers both create cron entries for
29+
// the same canary, leaving an orphaned entry that fires on every tick and
30+
// cannot be cleaned up.
31+
var canarySyncLocks sync.Map
32+
33+
func lockCanarySync(id string) func() {
34+
mu := &sync.Mutex{}
35+
actual, _ := canarySyncLocks.LoadOrStore(id, mu)
36+
actual.(*sync.Mutex).Lock()
37+
return actual.(*sync.Mutex).Unlock
38+
}
39+
2740
const DefaultCanarySchedule = "@every 5m"
2841

2942
func Unschedule(id string) {
@@ -63,6 +76,15 @@ func findJob(dbCanary pkg.Canary) *job.Job {
6376

6477
func SyncCanaryJob(ctx context.Context, dbCanary pkg.Canary) error {
6578
id := dbCanary.ID.String()
79+
80+
// Serialize SyncCanaryJob per canary to prevent duplicate cron entries.
81+
// Without this lock, concurrent calls can race: one call Unschedule's
82+
// and deletes the map entry, another sees nil and creates a new cron
83+
// entry, then the first also creates a second cron entry. The orphaned
84+
// entry survives all cleanup sweeps because it shares the same UID.
85+
unlock := lockCanarySync(id)
86+
defer unlock()
87+
6688
ctx.Logger.V(2).Infof("SyncCanaryJob (id=%s name=%s)", dbCanary.ID, dbCanary.Name)
6789

6890
if ctx.Properties().On(false, "check.*.disabled") {

0 commit comments

Comments
 (0)