Skip to content

Commit aea7ce6

Browse files
committed
fix(job): key singleton locks by job identity
Manual config-db scraper runs create a fresh duty job while scheduled runs use another instance. The old singleton mutex lived on the Job struct, so jobs with the same logical scraper identity could overlap despite Singleton being enabled. Use Job.ID() as the single computed in-process singleton identity, derived from Name plus ResourceType and ResourceID when a resource is present. Replace the old stored alias field with Aliases, which is only used for property lookup and display names. Addresses flanksource/config-db#2300
1 parent 738c98a commit aea7ce6

5 files changed

Lines changed: 276 additions & 46 deletions

File tree

job/controllers.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import (
1010
)
1111

1212
type JobCronEntry struct {
13-
ID string `json:"id"`
13+
JobID string `json:"id"`
1414
Name string `json:"name"`
15+
Aliases []string `json:"aliases,omitempty"`
1516
Schedule string `json:"schedule"`
1617
ResourceID string `json:"resource_id,omitempty"`
1718
ResourceType string `json:"resource_type,omitempty"`
@@ -34,7 +35,8 @@ func CronDetailsHandler(crons ...*cron.Cron) func(c echo.Context) error {
3435
}
3536

3637
return JobCronEntry{
37-
ID: j.ID,
38+
JobID: j.ID(),
39+
Aliases: j.Aliases,
3840
ResourceID: j.ResourceID,
3941
ResourceType: j.ResourceType,
4042
Name: j.Name,

job/job.go

Lines changed: 65 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ var RetentionHigh = Retention{
110110
type Job struct {
111111
context.Context
112112
entryID *cron.EntryID
113-
lock *sync.Mutex
114113
initialized bool
115114
unschedule func()
116115
statusRing StatusRing
@@ -124,7 +123,7 @@ type Job struct {
124123
Fn func(ctx JobRuntime) error
125124
JobHistory bool
126125
RunNow bool
127-
ID string
126+
Aliases []string
128127
ResourceID, ResourceType string
129128
IgnoreSuccessHistory bool
130129
lastHistoryCleanup time.Time
@@ -141,19 +140,23 @@ type Job struct {
141140

142141
func (j *Job) GetContext() map[string]any {
143142
return map[string]any{
144-
"id": j.ID,
143+
"id": j.ID(),
144+
"aliases": j.Aliases,
145145
"resourceID": j.ResourceID,
146146
"resourceType": j.ResourceType,
147147
"name": j.Name,
148148
"schedule": j.Schedule,
149149
}
150150
}
151151

152-
func (j *Job) PK() string {
153-
return strings.TrimSuffix(
154-
strings.TrimSpace(fmt.Sprintf("%s/%s", j.Name, lo.CoalesceOrEmpty(j.ID, j.ResourceID))),
155-
"/",
156-
)
152+
// ID returns the canonical logical identity for a job.
153+
// Aliases are intentionally excluded; they are configuration/display names.
154+
func (j *Job) ID() string {
155+
parts := []string{strings.TrimSpace(j.Name)}
156+
if j.ResourceID != "" {
157+
parts = append(parts, strings.TrimSpace(j.ResourceType), strings.TrimSpace(j.ResourceID))
158+
}
159+
return strings.Join(lo.Compact(parts), "/")
157160
}
158161

159162
type StatusRing struct {
@@ -352,8 +355,9 @@ func (j *Job) Retain(r Retention) *Job {
352355
return j
353356
}
354357

355-
func (j *Job) SetID(id string) *Job {
356-
j.ID = id
358+
// SetAliases sets friendly names used for job properties and display.
359+
func (j *Job) SetAliases(aliases ...string) *Job {
360+
j.Aliases = aliases
357361
return j
358362
}
359363

@@ -379,7 +383,7 @@ func (j *Job) Run() {
379383
}
380384

381385
ctx, span := j.Context.StartSpan(j.Name)
382-
ctx = ctx.WithName("job." + j.PK())
386+
ctx = ctx.WithName("job." + j.ID())
383387
defer span.End()
384388

385389
r := JobRuntime{
@@ -406,32 +410,31 @@ func (j *Job) Run() {
406410
r.start()
407411
defer r.end()
408412
if j.Singleton {
409-
ctx.Logger.V(4).Infof("acquiring lock")
413+
key := j.ID()
414+
ctx.Logger.V(4).Infof("acquiring lock %s", key)
410415

411-
if j.lock == nil {
412-
j.lock = &sync.Mutex{}
413-
}
414-
if !j.lock.TryLock() {
416+
unlock, ok := singletonLocks.TryLock(key)
417+
if !ok {
415418
r.History.Status = models.StatusSkipped
416-
ctx.Tracef("failed to acquire lock")
419+
ctx.Tracef("failed to acquire lock %s", key)
417420
r.Skipped("job already running, skipping")
418421
return
419422
}
420-
defer j.lock.Unlock()
423+
defer unlock()
421424
}
422425

423426
for i, lock := range j.Semaphores {
424-
ctx.Logger.V(6).Infof("[%s] acquiring sempahore [%d/%d]", j.ID, i+1, len(j.Semaphores))
427+
ctx.Logger.V(6).Infof("[%s] acquiring sempahore [%d/%d]", j.ID(), i+1, len(j.Semaphores))
425428
if err := lock.Acquire(ctx, 1); err != nil {
426429
r.Skipped("too many concurrent jobs, skipping")
427430
return
428431
}
429-
ctx.Logger.V(7).Infof("[%s] acquired sempahore [%d/%d]", j.ID, i+1, len(j.Semaphores))
432+
ctx.Logger.V(7).Infof("[%s] acquired sempahore [%d/%d]", j.ID(), i+1, len(j.Semaphores))
430433

431434
defer func(s *semaphore.Weighted, msg string) {
432435
s.Release(1)
433436
ctx.Logger.V(6).Infof(msg)
434-
}(lock, fmt.Sprintf("[%s] released sempahore [%d/%d]", j.ID, i+1, len(j.Semaphores)))
437+
}(lock, fmt.Sprintf("[%s] released sempahore [%d/%d]", j.ID(), i+1, len(j.Semaphores)))
435438
}
436439

437440
if j.Timeout > 0 {
@@ -449,41 +452,59 @@ func (j *Job) Run() {
449452
}
450453

451454
func (j *Job) getPropertyNames(key string) []string {
452-
if j.ID == "" {
453-
return []string{
454-
fmt.Sprintf("jobs.%s.%s", j.Name, key),
455-
fmt.Sprintf("jobs.%s", key)}
455+
names := make([]string, 0, len(j.Aliases)+2)
456+
for _, alias := range j.aliases() {
457+
names = append(names, fmt.Sprintf("jobs.%s.%s.%s", j.Name, alias, key))
456458
}
457-
return []string{
458-
fmt.Sprintf("jobs.%s.%s.%s", j.Name, j.ID, key),
459+
return append(names,
459460
fmt.Sprintf("jobs.%s.%s", j.Name, key),
460-
fmt.Sprintf("jobs.%s", key)}
461+
fmt.Sprintf("jobs.%s", key),
462+
)
461463
}
462464

463465
func (j *Job) GetProperty(property string) (string, bool) {
464-
if val := j.Context.Properties().String("jobs."+j.Name+"."+property, ""); val != "" {
465-
return val, true
466-
}
467-
if j.ID != "" {
468-
if val := j.Context.Properties().String(fmt.Sprintf("jobs.%s.%s.%s", j.Name, j.ID, property), ""); val != "" {
466+
for _, name := range j.getPropertyNames(property) {
467+
if val := j.Context.Properties().String(name, ""); val != "" {
469468
return val, true
470469
}
471470
}
472471
return "", false
473472
}
474473

475474
func (j *Job) GetPropertyInt(property string, def int) int {
476-
if val := j.Context.Properties().Int("jobs."+j.Name+"."+property, def); val != def {
477-
return val
478-
}
479-
if j.ID != "" {
480-
if val := j.Context.Properties().Int(fmt.Sprintf("jobs.%s.%s.%s", j.Name, j.ID, property), def); val != def {
475+
for _, name := range j.getPropertyNames(property) {
476+
if val := j.Context.Properties().Int(name, def); val != def {
481477
return val
482478
}
483479
}
484480
return def
485481
}
486482

483+
func (j *Job) aliases() []string {
484+
aliases := make([]string, 0, len(j.Aliases))
485+
seen := make(map[string]struct{}, len(j.Aliases))
486+
for _, alias := range j.Aliases {
487+
alias = strings.TrimSpace(alias)
488+
if alias == "" {
489+
continue
490+
}
491+
if _, ok := seen[alias]; ok {
492+
continue
493+
}
494+
seen[alias] = struct{}{}
495+
aliases = append(aliases, alias)
496+
}
497+
return aliases
498+
}
499+
500+
func (j *Job) primaryAlias() string {
501+
aliases := j.aliases()
502+
if len(aliases) == 0 {
503+
return ""
504+
}
505+
return aliases[0]
506+
}
507+
487508
func (j *Job) init() error {
488509
StartJobHistoryEvictor(j.Context)
489510

@@ -544,8 +565,8 @@ func (j *Job) init() error {
544565
j.Context = j.Context.WithDBLogLevel(dbLevel)
545566
}
546567

547-
if j.ID != "" {
548-
j.Context = j.Context.WithName(fmt.Sprintf("%s.%s", strings.ToLower(j.Name), j.ID))
568+
if alias := j.primaryAlias(); alias != "" {
569+
j.Context = j.Context.WithName(fmt.Sprintf("%s.%s", strings.ToLower(j.Name), alias))
549570
} else if j.ResourceID != "" {
550571
j.Context = j.Context.WithName(fmt.Sprintf("%s.%s", strings.ToLower(j.Name), j.ResourceID))
551572
} else {
@@ -564,8 +585,8 @@ func (j *Job) init() error {
564585
}
565586

566587
func (j *Job) Label() string {
567-
if j.ID != "" {
568-
return fmt.Sprintf("%s/%s", j.Name, j.ID)
588+
if alias := j.primaryAlias(); alias != "" {
589+
return fmt.Sprintf("%s/%s", j.Name, alias)
569590
}
570591
return j.Name
571592
}
@@ -582,8 +603,8 @@ func (j *Job) String() string {
582603
}
583604

584605
func (j *Job) GetResourcedName() string {
585-
if j.ID != "" {
586-
return fmt.Sprintf("%s [%s]", j.Name, j.ID)
606+
if alias := j.primaryAlias(); alias != "" {
607+
return fmt.Sprintf("%s [%s]", j.Name, alias)
587608
}
588609

589610
return j.Name

job/job_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,46 @@ func TestJob(t *testing.T) {
1919
RunSpecs(t, "Job Suite")
2020
}
2121

22+
var _ = Describe("Job", func() {
23+
It("builds IDs from logical job identity", func() {
24+
cases := []struct {
25+
name string
26+
job *Job
27+
want string
28+
}{
29+
{
30+
name: "resource identity",
31+
job: &Job{
32+
Name: "Scraper",
33+
ResourceType: ResourceTypeScraper,
34+
ResourceID: "scraper-1",
35+
Aliases: []string{"default/scraper"},
36+
},
37+
want: "Scraper/config_scraper/scraper-1",
38+
},
39+
{
40+
name: "aliases are not identity",
41+
job: &Job{
42+
Name: "Scraper",
43+
Aliases: []string{"manual", "scheduled"},
44+
},
45+
want: "Scraper",
46+
},
47+
{
48+
name: "name fallback",
49+
job: &Job{
50+
Name: "Scraper",
51+
},
52+
want: "Scraper",
53+
},
54+
}
55+
56+
for _, tt := range cases {
57+
Expect(tt.job.ID()).To(Equal(tt.want), tt.name)
58+
}
59+
})
60+
})
61+
2262
var _ = Describe("StatusRing", Label("slow"), func() {
2363
var ch chan uuid.UUID
2464

job/singleton.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package job
2+
3+
import (
4+
"sync"
5+
)
6+
7+
var singletonLocks = newSingletonLockRegistry()
8+
9+
type singletonLockRegistry struct {
10+
mu sync.Mutex
11+
running map[string]struct{}
12+
}
13+
14+
func newSingletonLockRegistry() *singletonLockRegistry {
15+
return &singletonLockRegistry{
16+
running: make(map[string]struct{}),
17+
}
18+
}
19+
20+
func (r *singletonLockRegistry) TryLock(key string) (func(), bool) {
21+
r.mu.Lock()
22+
defer r.mu.Unlock()
23+
24+
if _, ok := r.running[key]; ok {
25+
return nil, false
26+
}
27+
28+
r.running[key] = struct{}{}
29+
30+
var once sync.Once
31+
return func() {
32+
once.Do(func() {
33+
r.mu.Lock()
34+
defer r.mu.Unlock()
35+
delete(r.running, key)
36+
})
37+
}, true
38+
}

0 commit comments

Comments
 (0)