go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/sdk/cron/job_scheduler.go (about) 1 /* 2 3 Copyright (c) 2023 - Present. Will Charczuk. All rights reserved. 4 Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository. 5 6 */ 7 8 package cron 9 10 import ( 11 "context" 12 "fmt" 13 "sync" 14 "time" 15 16 "go.charczuk.com/sdk/async" 17 ) 18 19 // NewJobScheduler returns a job scheduler for a given job. 20 func NewJobScheduler(jm *JobManager, job Job) *JobScheduler { 21 js := &JobScheduler{ 22 Job: job, 23 latch: async.NewLatch(), 24 jm: jm, 25 } 26 if typed, ok := job.(ScheduleProvider); ok { 27 js.schedule = typed.Schedule() 28 } 29 return js 30 } 31 32 // JobScheduler is a job instance. 33 type JobScheduler struct { 34 Job Job 35 36 jm *JobManager 37 config JobConfig 38 schedule Schedule 39 lifecycle JobLifecycle 40 nextRuntime time.Time 41 42 latch *async.Latch 43 currentLock sync.Mutex 44 current *JobInvocation 45 lastLock sync.Mutex 46 last *JobInvocation 47 } 48 49 // Name returns the job name. 50 func (js *JobScheduler) Name() string { 51 return js.Job.Name() 52 } 53 54 // Config returns the job config provided by a job or an empty config. 55 func (js *JobScheduler) Config() JobConfig { 56 if typed, ok := js.Job.(ConfigProvider); ok { 57 return typed.Config() 58 } 59 return js.config 60 } 61 62 // Lifecycle returns job lifecycle steps or an empty set. 63 func (js *JobScheduler) Lifecycle() JobLifecycle { 64 if typed, ok := js.Job.(LifecycleProvider); ok { 65 return typed.Lifecycle() 66 } 67 return js.lifecycle 68 } 69 70 // Labels returns the job labels, including 71 // automatically added ones like `name`. 72 func (js *JobScheduler) Labels() map[string]string { 73 output := map[string]string{ 74 "name": js.Name(), 75 "scheduler": string(js.State()), 76 "active": fmt.Sprint(!js.IsIdle()), 77 "enabled": fmt.Sprint(!js.config.Disabled), 78 } 79 if js.Last() != nil { 80 output["last"] = string(js.Last().Status) 81 } 82 for key, value := range js.Config().Labels { 83 output[key] = value 84 } 85 return output 86 } 87 88 // State returns the job scheduler state. 89 func (js *JobScheduler) State() JobSchedulerState { 90 if js.latch.IsStarted() { 91 return JobSchedulerStateRunning 92 } 93 if js.latch.IsStopped() { 94 return JobSchedulerStateStopped 95 } 96 return JobSchedulerStateUnknown 97 } 98 99 // Start starts the scheduler. 100 // This call blocks. 101 func (js *JobScheduler) Start(ctx context.Context) error { 102 if !js.latch.CanStart() { 103 return ErrCannotStart 104 } 105 js.latch.Starting() 106 js.runLoop(ctx) 107 return nil 108 } 109 110 // Stop stops the scheduler. 111 func (js *JobScheduler) Stop(ctx context.Context) error { 112 if !js.latch.CanStop() { 113 return ErrCannotStop 114 } 115 js.latch.Stopping() // trigger the `NotifyStopping` channel 116 117 // if it's currently running 118 // cancel or wait to cancel 119 if current := js.Current(); current != nil { 120 ctx := js.withBaseContext(ctx) 121 gracePeriod := js.Config().ShutdownGracePeriodOrDefault() 122 if gracePeriod > 0 { 123 var cancel func() 124 ctx, cancel = js.withTimeoutOrCancel(ctx, gracePeriod) 125 defer cancel() 126 js.waitCurrentComplete(ctx) 127 } else { 128 current.Cancel() 129 } 130 } 131 132 // wait for the runloop to exit 133 <-js.latch.NotifyStopped() 134 js.latch.Reset() 135 js.nextRuntime = Zero 136 return nil 137 } 138 139 // NotifyStarted notifies the job scheduler has started. 140 func (js *JobScheduler) NotifyStarted() <-chan struct{} { 141 return js.latch.NotifyStarted() 142 } 143 144 // NotifyStopped notifies the job scheduler has stopped. 145 func (js *JobScheduler) NotifyStopped() <-chan struct{} { 146 return js.latch.NotifyStopped() 147 } 148 149 // Enable sets the job as enabled. 150 func (js *JobScheduler) Enable(ctx context.Context) { 151 ctx = js.withBaseContext(ctx) 152 js.config.Disabled = false 153 if lifecycle := js.Lifecycle(); lifecycle.OnEnabled != nil { 154 lifecycle.OnEnabled(ctx) 155 } 156 } 157 158 // Disable sets the job as disabled. 159 func (js *JobScheduler) Disable(ctx context.Context) { 160 ctx = js.withBaseContext(ctx) 161 js.config.Disabled = true 162 if lifecycle := js.Lifecycle(); lifecycle.OnDisabled != nil { 163 lifecycle.OnDisabled(ctx) 164 } 165 } 166 167 // Cancel stops all running invocations. 168 func (js *JobScheduler) Cancel(ctx context.Context) error { 169 ctx = js.withBaseContext(ctx) 170 if js.Current() == nil { 171 return nil 172 } 173 gracePeriod := js.Config().ShutdownGracePeriodOrDefault() 174 if gracePeriod > 0 { 175 ctx, cancel := js.withTimeoutOrCancel(ctx, gracePeriod) 176 defer cancel() 177 js.waitCurrentComplete(ctx) 178 } 179 if current := js.Current(); current != nil && current.Status == JobInvocationStatusRunning { 180 current.Cancel() 181 } 182 return nil 183 } 184 185 // RunAsync starts a job invocation with a given context. 186 func (js *JobScheduler) RunAsync(ctx context.Context) (*JobInvocation, error) { 187 if !js.IsIdle() { 188 return nil, ErrJobAlreadyRunning 189 } 190 191 ctx = js.withBaseContext(ctx) 192 ctx, ji := js.withInvocationContext(ctx) 193 js.setCurrent(ji) 194 195 var err error 196 go func() { 197 defer func() { 198 switch { 199 case err != nil && IsJobCanceled(err): 200 js.onJobCompleteCanceled(ctx) // the job was canceled, either manually or by a timeout 201 case err != nil: 202 js.onJobCompleteError(ctx, err) // the job completed with an error 203 default: 204 js.onJobCompleteSuccess(ctx) // the job completed without error 205 } 206 ji.Cancel() // if the job was created with a timeout, end the timeout 207 js.assignCurrentToLast() // rotate in the current to the last result 208 }() 209 js.onJobBegin(ctx) // signal the job is starting 210 211 select { 212 case <-ctx.Done(): // if the timeout or cancel is triggered 213 err = ErrJobCanceled // set the error to a known error 214 return 215 case err = <-js.safeBackgroundExec(ctx): // run the job in a background routine and catch panics 216 return 217 } 218 }() 219 return ji, nil 220 } 221 222 // Run forces the job to run. 223 // This call will block. 224 func (js *JobScheduler) Run(ctx context.Context) { 225 ji, err := js.RunAsync(ctx) 226 if err != nil { 227 return 228 } 229 <-ji.Finished 230 } 231 232 // 233 // exported utility methods 234 // 235 236 // CanBeScheduled returns if a job will be triggered automatically 237 // and isn't already in flight and set to be serial. 238 func (js *JobScheduler) CanBeScheduled() bool { 239 return !js.config.Disabled && js.IsIdle() 240 } 241 242 // IsIdle returns if the job is not currently running. 243 func (js *JobScheduler) IsIdle() (isIdle bool) { 244 isIdle = js.Current() == nil 245 return 246 } 247 248 // 249 // internal functions 250 // 251 252 func (js *JobScheduler) runLoop(ctx context.Context) { 253 js.latch.Started() 254 defer func() { 255 js.latch.Stopped() 256 js.latch.Reset() 257 }() 258 259 if js.schedule != nil { 260 js.nextRuntime = js.schedule.Next(js.nextRuntime) 261 } 262 if js.nextRuntime.IsZero() { 263 return 264 } 265 266 runAt := time.NewTimer(js.nextRuntime.UTC().Sub(Now())) 267 for { 268 select { 269 case <-runAt.C: 270 runAt.Stop() 271 if js.CanBeScheduled() { 272 _, _ = js.RunAsync(ctx) 273 } 274 if !js.latch.IsStarted() { 275 return 276 } 277 if js.schedule != nil { 278 js.nextRuntime = js.schedule.Next(js.nextRuntime) 279 runAt.Reset(js.nextRuntime.UTC().Sub(Now())) 280 } else { 281 js.nextRuntime = Zero 282 } 283 if js.nextRuntime.IsZero() { 284 return 285 } 286 case <-js.latch.NotifyStopping(): 287 runAt.Stop() 288 return 289 } 290 } 291 } 292 293 // Current returns the current job invocation. 294 func (js *JobScheduler) Current() (current *JobInvocation) { 295 js.currentLock.Lock() 296 if js.current != nil { 297 current = js.current.Clone() 298 } 299 js.currentLock.Unlock() 300 return 301 } 302 303 // Last returns the last job invocation. 304 func (js *JobScheduler) Last() (last *JobInvocation) { 305 js.lastLock.Lock() 306 if js.last != nil { 307 last = js.last 308 } 309 js.lastLock.Unlock() 310 return 311 } 312 313 // SetCurrent sets the current invocation, it is useful for tests etc. 314 func (js *JobScheduler) setCurrent(ji *JobInvocation) { 315 js.currentLock.Lock() 316 js.current = ji 317 js.currentLock.Unlock() 318 } 319 320 // SetLast sets the last invocation, it is useful for tests etc. 321 func (js *JobScheduler) setLast(ji *JobInvocation) { 322 js.lastLock.Lock() 323 js.last = ji 324 js.lastLock.Unlock() 325 } 326 327 func (js *JobScheduler) onRegister(ctx context.Context) error { 328 ctx = js.withBaseContext(ctx) 329 if js.Lifecycle().OnRegister != nil { 330 if err := js.Lifecycle().OnRegister(ctx); err != nil { 331 return err 332 } 333 } 334 return nil 335 } 336 337 func (js *JobScheduler) onRemove(ctx context.Context) error { 338 ctx = js.withBaseContext(ctx) 339 if js.Lifecycle().OnRemove != nil { 340 return js.Lifecycle().OnRemove(ctx) 341 } 342 return nil 343 } 344 345 func (js *JobScheduler) assignCurrentToLast() { 346 js.lastLock.Lock() 347 js.currentLock.Lock() 348 js.last = js.current 349 js.current = nil 350 js.currentLock.Unlock() 351 js.lastLock.Unlock() 352 } 353 354 func (js *JobScheduler) waitCurrentComplete(ctx context.Context) { 355 if js.Current().Status != JobInvocationStatusRunning { 356 return 357 } 358 359 finished := js.current.Finished 360 select { 361 case <-ctx.Done(): 362 js.Current().Cancel() 363 return 364 case <-finished: 365 // tick over the loop to check if the current job is complete 366 return 367 } 368 } 369 370 func (js *JobScheduler) safeBackgroundExec(ctx context.Context) <-chan error { 371 errors := make(chan error, 1) 372 go func() { 373 defer func() { 374 if r := recover(); r != nil { 375 errors <- fmt.Errorf("%v", r) 376 } 377 }() 378 errors <- js.Job.Execute(ctx) 379 }() 380 return errors 381 } 382 383 func (js *JobScheduler) withBaseContext(ctx context.Context) context.Context { 384 if typed, ok := js.Job.(BackgroundProvider); ok { 385 ctx = typed.Background(ctx) 386 } 387 ctx = WithJobScheduler(ctx, js) 388 return ctx 389 } 390 391 func (js *JobScheduler) withTimeoutOrCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { 392 if timeout > 0 { 393 return context.WithTimeout(ctx, timeout) 394 } 395 return context.WithCancel(ctx) 396 } 397 398 func (js *JobScheduler) withInvocationContext(ctx context.Context) (context.Context, *JobInvocation) { 399 ji := newJobInvocation(js.Name()) 400 ji.Parameters = MergeJobParameterValues(js.Config().ParameterValues, GetJobParameterValues(ctx)) 401 ctx, ji.Cancel = js.withTimeoutOrCancel(ctx, js.Config().TimeoutOrDefault()) 402 ctx = WithJobInvocation(ctx, ji) 403 ctx = WithJobParameterValues(ctx, ji.Parameters) 404 return ctx, ji 405 } 406 407 // job lifecycle hooks 408 409 func (js *JobScheduler) onJobBegin(ctx context.Context) { 410 js.currentLock.Lock() 411 js.current.Started = time.Now().UTC() 412 js.current.Status = JobInvocationStatusRunning 413 js.currentLock.Unlock() 414 415 if lifecycle := js.Lifecycle(); lifecycle.OnBegin != nil { 416 lifecycle.OnBegin(ctx) 417 } 418 if js.jm != nil && len(js.jm.onJobBegin) > 0 { 419 jse := JobSchedulerEvent{ 420 Phase: "job.begin", 421 JobName: js.Job.Name(), 422 JobInvocation: GetJobInvocation(ctx).ID, 423 Parameters: GetJobParameterValues(ctx), 424 } 425 for _, listener := range js.jm.onJobBegin { 426 listener(jse) 427 } 428 } 429 } 430 431 func (js *JobScheduler) onJobCompleteCanceled(ctx context.Context) { 432 js.currentLock.Lock() 433 js.current.Complete = time.Now().UTC() 434 js.current.Status = JobInvocationStatusCanceled 435 close(js.current.Finished) 436 js.currentLock.Unlock() 437 438 lifecycle := js.Lifecycle() 439 if lifecycle.OnCancellation != nil { 440 lifecycle.OnCancellation(ctx) 441 } 442 if lifecycle.OnComplete != nil { 443 lifecycle.OnComplete(ctx) 444 } 445 if js.jm != nil && len(js.jm.onJobComplete) > 0 { 446 jse := JobSchedulerEvent{ 447 Phase: "job.canceled", 448 JobName: js.Job.Name(), 449 JobInvocation: GetJobInvocation(ctx).ID, 450 Parameters: GetJobParameterValues(ctx), 451 } 452 for _, listener := range js.jm.onJobComplete { 453 listener(jse) 454 } 455 } 456 } 457 458 func (js *JobScheduler) onJobCompleteSuccess(ctx context.Context) { 459 js.currentLock.Lock() 460 js.current.Complete = time.Now().UTC() 461 js.current.Status = JobInvocationStatusSuccess 462 close(js.current.Finished) 463 js.currentLock.Unlock() 464 465 lifecycle := js.Lifecycle() 466 if lifecycle.OnSuccess != nil { 467 lifecycle.OnSuccess(ctx) 468 } 469 if last := js.Last(); last != nil && last.Status == JobInvocationStatusErrored { 470 if lifecycle.OnFixed != nil { 471 lifecycle.OnFixed(ctx) 472 } 473 } 474 if lifecycle.OnComplete != nil { 475 lifecycle.OnComplete(ctx) 476 } 477 if js.jm != nil && len(js.jm.onJobComplete) > 0 { 478 jse := JobSchedulerEvent{ 479 Phase: "job.complete", 480 JobName: js.Job.Name(), 481 JobInvocation: GetJobInvocation(ctx).ID, 482 Parameters: GetJobParameterValues(ctx), 483 } 484 for _, listener := range js.jm.onJobComplete { 485 listener(jse) 486 } 487 } 488 } 489 490 func (js *JobScheduler) onJobCompleteError(ctx context.Context, err error) { 491 js.currentLock.Lock() 492 js.current.Complete = time.Now().UTC() 493 js.current.Status = JobInvocationStatusErrored 494 js.current.Err = err 495 close(js.current.Finished) 496 js.currentLock.Unlock() 497 498 // 499 // error 500 // 501 502 // always log the error 503 lifecycle := js.Lifecycle() 504 if lifecycle.OnError != nil { 505 lifecycle.OnError(ctx) 506 } 507 508 // 509 // broken; assumes that last is set, and last was a success 510 // 511 512 if last := js.Last(); last != nil && last.Status != JobInvocationStatusErrored { 513 if lifecycle.OnBroken != nil { 514 lifecycle.OnBroken(ctx) 515 } 516 } 517 if lifecycle.OnComplete != nil { 518 lifecycle.OnComplete(ctx) 519 } 520 if js.jm != nil && len(js.jm.onJobComplete) > 0 { 521 jse := JobSchedulerEvent{ 522 Phase: "job.error", 523 JobName: js.Job.Name(), 524 JobInvocation: GetJobInvocation(ctx).ID, 525 Parameters: GetJobParameterValues(ctx), 526 Err: err, 527 } 528 for _, listener := range js.jm.onJobComplete { 529 listener(jse) 530 } 531 } 532 }