├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── prisma ├── db │ └── .gitignore ├── migrations │ ├── 20210415180023_job_model │ │ └── migration.sql │ ├── 20210415201744_removed_endpoint │ │ └── migration.sql │ ├── 20210504184822_cron │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma └── scheduler.go /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .DS_Store 3 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16 as build 2 | 3 | ADD . /scheduler 4 | WORKDIR /scheduler 5 | 6 | # Do the database migrations and code-generation. 7 | ARG DATABASE_URL 8 | RUN go get github.com/prisma/prisma-client-go 9 | RUN go run github.com/prisma/prisma-client-go db push --preview-feature 10 | 11 | # Build the scheduler. 12 | RUN go build -o scheduler . 13 | 14 | # Copy the scheduler binary to smaller container for deployment. 15 | FROM gcr.io/distroless/base 16 | WORKDIR /scheduler 17 | COPY --from=build /scheduler/scheduler /scheduler/ 18 | ENTRYPOINT ["/scheduler/scheduler"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Morgan Gallant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | usage: 2 | @cat Makefile 3 | 4 | # Create a migration from any changes to prisma/schema.prisma, apply it to DATABASE_URL, and re-generate code. 5 | migrate: 6 | go run github.com/prisma/prisma-client-go migrate dev --preview-feature 7 | 8 | # Generate the Go language bindings for the schema. 9 | generate: 10 | go run github.com/prisma/prisma-client-go generate 11 | 12 | # Reset DATABASE_URL (deleting all data), then apply all migrations. 13 | reset: 14 | go run github.com/prisma/prisma-client-go migrate reset --preview-feature 15 | 16 | # Run the application locally. 17 | run: 18 | go run . 19 | 20 | # Docker commands - same as above, but running inside a docker container. 21 | docker-run: 22 | docker run -it --network="host" -v ${PWD}:/scheduler -w /scheduler golang:1.16.3 make run 23 | docker-reset: 24 | docker run -it --network="host" -v ${PWD}:/scheduler -w /scheduler golang:1.16.3 make reset 25 | docker-generate: 26 | docker run -it --network="host" -v ${PWD}:/scheduler -w /scheduler golang:1.16.3 make generate 27 | docker-migrate: 28 | docker run -it --network="host" -v ${PWD}:/scheduler -w /scheduler golang:1.16.3 make migrate 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new?template=https%3A%2F%2Fgithub.com%2Fmorgangallant%2Fscheduler%2Ftree%2Ftrunk&plugins=postgresql&envs=ENDPOINT%2CSECRET&ENDPOINTDesc=Request+Endpoint&SECRETDesc=openssl+rand+-hex+32&ENDPOINTDefault=https%3A%2F%2Fexample.com%2Fsched) 2 | 3 | A simple job scheduler backed by Postgres used in production at https://operand.ai. Setup needs two 4 | environment variables, `SECRET` and `ENDPOINT`. The secret is attached to incoming/outgoing requests 5 | within the `Scheduler-Secret` HTTP header, and is used both by the scheduler to verify that incoming 6 | request is legit, as well as the end-application to verify the request is coming from the scheduler. 7 | The endpoint is simply the URL to send HTTP requests to. 8 | 9 | This scheduler also has support for CRON-type expressions. This allows the application to specify 10 | a set of id's to run on a schedule. You should probably just make a request on application start 11 | with all the IDs and the relevant CRON expressions. This will tear-down the world and re-start the 12 | CRON server with the new values. This is how you should do it, especially in auto-deployment environments. 13 | When a cron job fires, the application will get a POST with the "cron\_id" set. You should check for this 14 | in the message and respond appropriately. 15 | 16 | This is used in production at [Operand](https://operand.ai). 17 | 18 | Example Usage: 19 | 20 | Scheduling a Job 21 | 22 | ``` 23 | POST https://scheduler.yourcompany.com/insert 24 | Headers: Scheduler-Secret=XXXXXX 25 | 26 | { 27 | "timestamp": "2021-04-15T14:17:00-07:00", 28 | "body": { 29 | "foo": "bar" 30 | } 31 | } 32 | 33 | Response: 34 | {"id":"cknjdu2k300153zugmucamxxo"} 35 | ``` 36 | 37 | Cancelling a Scheduled Job 38 | (note: if the job doesn't exist, this will fail silently.) 39 | 40 | ``` 41 | POST https://scheduler.yourcompany.com/delete 42 | Headers: Scheduler-Secret=XXXXXX 43 | 44 | { 45 | "id":"cknjdu2k300153zugmucamxxo" 46 | } 47 | 48 | Response: None 49 | ``` 50 | 51 | Configuring CRON Jobs 52 | 53 | ``` 54 | POST https://scheduler.yourcompany.com/cron 55 | Headers: Scheduler-Secret=XXXXXX 56 | 57 | { 58 | "jobs":[ 59 | { 60 | "id":"hello_world", 61 | "spec": "30 * * * * *" 62 | }, 63 | { 64 | "id":"hello_world_2", 65 | "spec": "0 * * * * *" 66 | } 67 | ] 68 | } 69 | 70 | Response: None 71 | ``` 72 | 73 | When a scheduled job, or a CRON job fires, your application (located at `endpoint`) 74 | will get the following message in a POST: 75 | ``` 76 | { 77 | "id": "", // will be empty if cron_id non-empty 78 | "cron_id": "", // will be empty if id non-empty 79 | "body": { // Omitted for CRON jobs. 80 | ... // Your stuff here 81 | } 82 | } 83 | ``` 84 | 85 | And that's it! Feel free to file issues or do pull requests. 86 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/morgangallant/scheduler 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365 // indirect 7 | github.com/joho/godotenv v1.3.0 8 | github.com/prisma/prisma-client-go v0.7.0 9 | github.com/robfig/cron/v3 v3.0.0 10 | github.com/shopspring/decimal v1.2.0 // indirect 11 | github.com/takuoki/gocase v1.0.0 // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365 h1:ECW73yc9MY7935nNYXUkK7Dz17YuSUI9yqRqYS8aBww= 4 | github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365/go.mod h1:SK73tn/9oHe+/Y0h39VT4UCxmurVJkR5NA7kMEAOgSE= 5 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 6 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/prisma/prisma-client-go v0.7.0 h1:Uc5bcOMialQfmg3y/q5a85kCtEQxJ/UzdCug2k1cN38= 10 | github.com/prisma/prisma-client-go v0.7.0/go.mod h1:p08hGN9qp6hxJXkxfO+xbAjBMwuNXbUN+slBhuPUEi0= 11 | github.com/prisma/prisma-client-go v0.8.0 h1:ZRizcrtQvFqo5Hy9ngniKQ2y32xj/WK2yBFADDFwcgs= 12 | github.com/prisma/prisma-client-go v0.8.0/go.mod h1:8CNhFyx4n2yb2JdIFf1uL3XcZQsbPHo1P/Rs7hdnsGg= 13 | github.com/prisma/prisma-client-go v0.9.0 h1:dsgEpAGLP0r7Fo6bX+aPBe6TEMKr2ug+IBtYrUd94qg= 14 | github.com/prisma/prisma-client-go v0.9.0/go.mod h1:8CNhFyx4n2yb2JdIFf1uL3XcZQsbPHo1P/Rs7hdnsGg= 15 | github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E= 16 | github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 17 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= 18 | github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= 19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 20 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 21 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 22 | github.com/takuoki/gocase v1.0.0 h1:gPwLJTWVm2T1kUiCsKirg/faaIUGVTI0FA3SYr75a44= 23 | github.com/takuoki/gocase v1.0.0/go.mod h1:QgOKJrbuJoDrtoKswBX1/Dw8mJrkOV9tbQZJaxaJ6zc= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 25 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 26 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 27 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 28 | -------------------------------------------------------------------------------- /prisma/db/.gitignore: -------------------------------------------------------------------------------- 1 | # gitignore generated by Prisma Client Go. DO NOT EDIT. 2 | *_gen.go 3 | -------------------------------------------------------------------------------- /prisma/migrations/20210415180023_job_model/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Job" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "scheduledFor" TIMESTAMP(3) NOT NULL, 6 | "endpoint" TEXT NOT NULL, 7 | "body" JSONB, 8 | 9 | PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateIndex 13 | CREATE INDEX "Job.scheduledFor_index" ON "Job"("scheduledFor"); 14 | -------------------------------------------------------------------------------- /prisma/migrations/20210415201744_removed_endpoint/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `endpoint` on the `Job` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Job" DROP COLUMN "endpoint"; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20210504184822_cron/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Cron" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "specification" TEXT NOT NULL, 6 | 7 | PRIMARY KEY ("id") 8 | ); 9 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator db { 7 | provider = "go run github.com/prisma/prisma-client-go" 8 | } 9 | 10 | model Job { 11 | id String @id @default(cuid()) 12 | createdAt DateTime @default(now()) 13 | scheduledFor DateTime 14 | body Json? 15 | 16 | @@index([scheduledFor]) 17 | } 18 | 19 | model Cron { 20 | id String @id @default(cuid()) 21 | createdAt DateTime @default(now()) 22 | specification String 23 | } 24 | -------------------------------------------------------------------------------- /scheduler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "net/http" 11 | "os" 12 | "time" 13 | 14 | "github.com/joho/godotenv" 15 | "github.com/morgangallant/scheduler/prisma/db" 16 | "github.com/robfig/cron/v3" 17 | ) 18 | 19 | func init() { 20 | // For development environments. 21 | _ = godotenv.Load() 22 | } 23 | 24 | func main() { 25 | if err := run(); err != nil { 26 | log.Fatal(err) 27 | } 28 | } 29 | 30 | func port() string { 31 | if p, ok := os.LookupEnv("PORT"); ok { 32 | return p 33 | } 34 | return "8080" 35 | } 36 | 37 | func endpoint() string { 38 | if e, ok := os.LookupEnv("ENDPOINT"); ok { 39 | return e 40 | } 41 | panic("missing ENDPOINT environment variable") 42 | } 43 | 44 | func secret() string { 45 | if s, ok := os.LookupEnv("SECRET"); ok { 46 | return s 47 | } 48 | panic("missing SECRET environment variable") 49 | } 50 | 51 | func run() error { 52 | client := db.NewClient() 53 | if err := client.Prisma.Connect(); err != nil { 54 | return err 55 | } 56 | defer client.Disconnect() 57 | secret, endpoint := secret(), endpoint() 58 | scheduler := newScheduler(client, secret, endpoint) 59 | cs := newCrons(client, secret, endpoint) 60 | webServer := newWebServer(":"+port(), scheduler, cs) 61 | return runServers(scheduler, cs, webServer) 62 | } 63 | 64 | type server interface { 65 | start() error 66 | stop() 67 | } 68 | 69 | func runServers(servers ...server) error { 70 | c := make(chan error, 1) 71 | for _, server := range servers { 72 | s := server 73 | go func() { 74 | c <- s.start() 75 | }() 76 | } 77 | err := <-c 78 | for _, server := range servers { 79 | server.stop() 80 | } 81 | return err 82 | } 83 | 84 | type scheduler struct { 85 | client *db.PrismaClient 86 | recomp chan struct{} 87 | secret string 88 | endpoint string 89 | } 90 | 91 | func newScheduler(client *db.PrismaClient, secret, endpoint string) *scheduler { 92 | return &scheduler{ 93 | client: client, 94 | recomp: make(chan struct{}), 95 | secret: secret, 96 | endpoint: endpoint, 97 | } 98 | } 99 | 100 | const headerSecretKey = "Scheduler-Secret" 101 | 102 | type jobRequest struct { 103 | JobID string `json:"id"` 104 | CronID string `json:"cron_id"` 105 | Body json.RawMessage `json:"body"` 106 | } 107 | 108 | func sendRequest(secret, endpoint string, jr jobRequest) error { 109 | buf, err := json.Marshal(jr) 110 | if err != nil { 111 | return err 112 | } 113 | req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(buf)) 114 | if err != nil { 115 | return err 116 | } 117 | req.Header.Set(headerSecretKey, secret) 118 | resp, err := http.DefaultClient.Do(req) 119 | if err != nil { 120 | return err 121 | } 122 | defer resp.Body.Close() 123 | if resp.StatusCode != http.StatusOK { 124 | return fmt.Errorf("got non-ok status code %d: %s", resp.StatusCode, resp.Status) 125 | } 126 | return nil 127 | } 128 | 129 | func (s *scheduler) executeJob(job db.JobModel) error { 130 | req := jobRequest{JobID: job.ID} 131 | buf, ok := job.Body() 132 | if ok { 133 | req.Body = json.RawMessage(buf) 134 | } 135 | if err := sendRequest(s.secret, s.endpoint, req); err != nil { 136 | return err 137 | } 138 | log.Printf("Executed job %s.", job.ID) 139 | return nil 140 | } 141 | 142 | func (s *scheduler) executePendingJobs() error { 143 | ctx := context.TODO() 144 | jobs, err := s.client.Job.FindMany( 145 | db.Job.ScheduledFor.BeforeEquals(db.DateTime(time.Now())), 146 | ).Exec(ctx) 147 | if err != nil { 148 | return err 149 | } 150 | for _, job := range jobs { 151 | if err := s.executeJob(job); err != nil { 152 | log.Printf("Failed to execute job %s: %v", job.ID, err) 153 | } 154 | if _, err := s.client.Job.FindUnique( 155 | db.Job.ID.Equals(job.ID), 156 | ).Delete().Exec(ctx); err != nil { 157 | return err 158 | } 159 | } 160 | log.Printf("Executed %d jobs.", len(jobs)) 161 | return nil 162 | } 163 | 164 | func (s *scheduler) nextScheduledJob() (*time.Time, error) { 165 | jobs, err := s.client.Job.FindMany(). 166 | OrderBy(db.Job.ScheduledFor.Order(db.ASC)). 167 | Take(1). 168 | Exec(context.TODO()) 169 | if err != nil { 170 | return nil, err 171 | } 172 | if len(jobs) == 0 { 173 | return nil, nil 174 | } 175 | return &jobs[0].ScheduledFor, nil 176 | } 177 | 178 | func (s *scheduler) start() error { 179 | log.Println("Started scheduler.") 180 | for { 181 | log.Println("Scheduler woke up.") 182 | if err := s.executePendingJobs(); err != nil { 183 | return err 184 | } 185 | ts, err := s.nextScheduledJob() 186 | if err != nil { 187 | return err 188 | } else if ts == nil { 189 | log.Println("Scheduler waiting for job.") 190 | <-s.recomp 191 | continue 192 | } 193 | log.Printf("Scheduler sleeping until %s.", ts.String()) 194 | select { 195 | case <-time.After(time.Until(*ts)): 196 | case <-s.recomp: 197 | } 198 | } 199 | } 200 | 201 | func (s *scheduler) stop() { 202 | log.Println("Closed scheduler.") 203 | } 204 | 205 | func (s *scheduler) createNewJob(ctx context.Context, on time.Time, body []byte) (string, error) { 206 | created, err := s.client.Job.CreateOne( 207 | db.Job.ScheduledFor.Set(db.DateTime(on)), 208 | db.Job.Body.Set(body), 209 | ).Exec(ctx) 210 | if err != nil { 211 | return "", err 212 | } 213 | log.Printf("New job with id %s.", created.ID) 214 | s.recomp <- struct{}{} 215 | return created.ID, nil 216 | } 217 | 218 | func (s *scheduler) deleteFutureJob(ctx context.Context, id string) error { 219 | _, err := s.client.Job.FindUnique( 220 | db.Job.ID.Equals(id), 221 | ).Delete().Exec(ctx) 222 | if errors.Is(err, db.ErrNotFound) { 223 | return nil 224 | } else if err != nil { 225 | return err 226 | } 227 | s.recomp <- struct{}{} 228 | log.Printf("Deleted job %s.", id) 229 | return nil 230 | } 231 | 232 | type crons struct { 233 | client *db.PrismaClient 234 | recomp chan struct{} 235 | endpoint string 236 | secret string 237 | } 238 | 239 | func newCrons(client *db.PrismaClient, secret, endpoint string) *crons { 240 | return &crons{ 241 | client: client, 242 | recomp: make(chan struct{}), 243 | endpoint: endpoint, 244 | secret: secret, 245 | } 246 | } 247 | 248 | type cronJob struct { 249 | JobID string `json:"id"` 250 | Spec string `json:"spec"` 251 | } 252 | 253 | func (cs *crons) getCronJobs() ([]cronJob, error) { 254 | jobs, err := cs.client.Cron.FindMany().Exec(context.TODO()) 255 | if err != nil { 256 | return nil, err 257 | } 258 | ret := make([]cronJob, 0, len(jobs)) 259 | for _, job := range jobs { 260 | ret = append(ret, cronJob{ 261 | JobID: job.ID, 262 | Spec: job.Specification, 263 | }) 264 | } 265 | return ret, nil 266 | } 267 | 268 | type cronJobRequest struct { 269 | JobID string `json:"cron_id"` 270 | } 271 | 272 | func (cs *crons) clearCronJobs() error { 273 | if _, err := cs.client.Cron.FindMany().Delete().Exec(context.TODO()); err != nil { 274 | return err 275 | } 276 | return nil 277 | } 278 | 279 | func (cs *crons) insertCronJob(cj cronJob) error { 280 | if _, err := cs.client.Cron.CreateOne( 281 | db.Cron.Specification.Set(cj.Spec), 282 | db.Cron.ID.Set(cj.JobID), 283 | ).Exec(context.TODO()); err != nil { 284 | return err 285 | } 286 | return nil 287 | } 288 | 289 | func (cs *crons) start() error { 290 | log.Println("Started crons.") 291 | for { 292 | client := cron.New(cron.WithLocation(time.UTC)) 293 | jobs, err := cs.getCronJobs() 294 | if err != nil { 295 | return err 296 | } 297 | for _, job := range jobs { 298 | j := job 299 | if _, err := client.AddFunc(j.Spec, func() { 300 | if err := sendRequest(cs.secret, cs.endpoint, jobRequest{ 301 | CronID: j.JobID, 302 | }); err != nil { 303 | log.Printf("Failed to execute cron job %s (%s): %v", j.JobID, j.Spec, err) 304 | return 305 | } 306 | log.Printf("Executed cron job %s (%s).", j.JobID, j.Spec) 307 | }); err != nil { 308 | return err 309 | } 310 | } 311 | client.Start() 312 | log.Printf("Started crons w/ %d jobs.", len(jobs)) 313 | <-cs.recomp 314 | log.Println("Got crons recompute request, tearing down.") 315 | client.Stop() 316 | } 317 | } 318 | 319 | func (cs *crons) stop() { 320 | log.Println("Closed crons.") 321 | } 322 | 323 | type webs struct { 324 | addr string 325 | mux *http.ServeMux 326 | sched *scheduler 327 | cs *crons 328 | underlying *http.Server 329 | } 330 | 331 | func newWebServer(addr string, s *scheduler, cs *crons) *webs { 332 | ws := &webs{addr: addr, mux: http.NewServeMux(), sched: s, cs: cs} 333 | ws.mux.HandleFunc("/", ws.rootHandler()) 334 | ws.mux.HandleFunc("/cron", ws.cronHandler()) 335 | ws.mux.HandleFunc("/insert", ws.insertHandler()) 336 | ws.mux.HandleFunc("/delete", ws.deleteHandler()) 337 | return ws 338 | } 339 | 340 | func (ws *webs) start() error { 341 | if ws.underlying != nil { 342 | ws.stop() 343 | } 344 | ws.underlying = &http.Server{ 345 | Addr: ws.addr, 346 | Handler: ws.mux, 347 | ReadTimeout: time.Second, 348 | WriteTimeout: time.Second * 10, 349 | } 350 | log.Println("Started web server.") 351 | return ws.underlying.ListenAndServe() 352 | } 353 | 354 | func (ws *webs) stop() { 355 | if ws.underlying == nil { 356 | return 357 | } 358 | ws.underlying.Shutdown(context.Background()) 359 | ws.underlying = nil 360 | log.Println("Closed web server.") 361 | } 362 | 363 | func (ws *webs) rootHandler() http.HandlerFunc { 364 | return func(w http.ResponseWriter, r *http.Request) { 365 | fmt.Fprint(w, "Scheduler (github.com/morgangallant/scheduler) written by Morgan Gallant.") 366 | } 367 | } 368 | 369 | func (ws *webs) insertHandler() http.HandlerFunc { 370 | type request struct { 371 | Timestamp time.Time `json:"timestamp"` 372 | Body json.RawMessage `json:"body"` 373 | } 374 | type response struct { 375 | JobID string `json:"id"` 376 | } 377 | return func(w http.ResponseWriter, r *http.Request) { 378 | if secret := r.Header.Get(headerSecretKey); secret != ws.sched.secret { 379 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 380 | return 381 | } 382 | var req request 383 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 384 | http.Error(w, err.Error(), http.StatusBadRequest) 385 | return 386 | } 387 | jid, err := ws.sched.createNewJob(r.Context(), req.Timestamp, req.Body) 388 | if err != nil { 389 | http.Error(w, err.Error(), http.StatusInternalServerError) 390 | return 391 | } 392 | if err := json.NewEncoder(w).Encode(response{JobID: jid}); err != nil { 393 | http.Error(w, err.Error(), http.StatusInternalServerError) 394 | } 395 | } 396 | } 397 | 398 | func (ws *webs) deleteHandler() http.HandlerFunc { 399 | type request struct { 400 | JobID string `json:"id"` 401 | } 402 | return func(w http.ResponseWriter, r *http.Request) { 403 | if secret := r.Header.Get(headerSecretKey); secret != ws.sched.secret { 404 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 405 | return 406 | } 407 | var req request 408 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 409 | http.Error(w, err.Error(), http.StatusBadRequest) 410 | return 411 | } 412 | if err := ws.sched.deleteFutureJob(r.Context(), req.JobID); err != nil { 413 | http.Error(w, err.Error(), http.StatusInternalServerError) 414 | return 415 | } 416 | } 417 | } 418 | 419 | func (ws *webs) cronHandler() http.HandlerFunc { 420 | type request struct { 421 | Jobs []cronJob `json:"jobs"` 422 | } 423 | return func(w http.ResponseWriter, r *http.Request) { 424 | if secret := r.Header.Get(headerSecretKey); secret != ws.sched.secret { 425 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 426 | return 427 | } 428 | var req request 429 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 430 | http.Error(w, err.Error(), http.StatusBadRequest) 431 | return 432 | } 433 | if err := ws.cs.clearCronJobs(); err != nil { 434 | http.Error(w, err.Error(), http.StatusInternalServerError) 435 | return 436 | } 437 | for _, job := range req.Jobs { 438 | if err := ws.cs.insertCronJob(job); err != nil { 439 | http.Error(w, err.Error(), http.StatusInternalServerError) 440 | return 441 | } 442 | } 443 | ws.cs.recomp <- struct{}{} // Signal to the crons that it needs to recompute. 444 | } 445 | } 446 | --------------------------------------------------------------------------------