Refuse draft_week jobs on the generic jobs endpoint
A kind can now be made service-only: Runner.Enqueue, and so POST /api/v1/jobs, refuses it with 400 service_only_kind, and only its typed Kind enqueues it. draft_week is service-only, so StartDraft and its one-draft-in-flight-per-week rule is the only way to start a draft; a confirmed draft_week posted to /api/v1/jobs can no longer run beside the week's unconfirmed draft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+31
-7
@@ -88,6 +88,10 @@ var ErrNotFound = errors.New("jobs: job not found")
|
||||
// registered handler.
|
||||
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||
|
||||
// ErrServiceOnly is returned by Runner.Enqueue for a kind made ServiceOnly,
|
||||
// whose jobs only its own service enqueues.
|
||||
var ErrServiceOnly = errors.New("jobs: job kind is enqueued only by its service")
|
||||
|
||||
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||
// not decode into the kind's payload type.
|
||||
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||
@@ -130,11 +134,13 @@ type Job struct {
|
||||
// never quotes an error.
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, and check reports whether
|
||||
// a payload decodes into the kind's payload type.
|
||||
// handler is a registered kind: run runs a job, check reports whether a
|
||||
// payload decodes into the kind's payload type, and serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind.
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
@@ -286,15 +292,30 @@ func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string,
|
||||
return createJob(ctx, db.New(tx), k.name, data)
|
||||
}
|
||||
|
||||
// ServiceOnly makes Runner.Enqueue refuse the kind with ErrServiceOnly, so
|
||||
// its jobs are enqueued only through k, by the service that checks what
|
||||
// must hold before one starts. It must be called before Start.
|
||||
func (k *Kind[P]) ServiceOnly() error {
|
||||
k.r.mu.Lock()
|
||||
defer k.r.mu.Unlock()
|
||||
if k.r.started {
|
||||
return fmt.Errorf("jobs: service-only %q: runner already started", k.name)
|
||||
}
|
||||
h := k.r.handlers[k.name]
|
||||
h.serviceOnly = true
|
||||
k.r.handlers[k.name] = h
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
||||
// call it after committing the transaction a job was enqueued in.
|
||||
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
||||
|
||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||
// ErrUnknownKind for a kind with no registered handler and
|
||||
// ErrInvalidPayload for a payload that does not decode into the kind's
|
||||
// payload type; neither stores a job.
|
||||
// ErrUnknownKind for a kind with no registered handler, ErrServiceOnly for
|
||||
// a ServiceOnly kind and ErrInvalidPayload for a payload that does not
|
||||
// decode into the kind's payload type; none stores a job.
|
||||
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||
r.mu.Lock()
|
||||
h, ok := r.handlers[kind]
|
||||
@@ -302,6 +323,9 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
|
||||
if !ok {
|
||||
return "", ErrUnknownKind
|
||||
}
|
||||
if h.serviceOnly {
|
||||
return "", ErrServiceOnly
|
||||
}
|
||||
if !h.check(payload) {
|
||||
return "", ErrInvalidPayload
|
||||
}
|
||||
|
||||
@@ -736,3 +736,39 @@ func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceOnlyKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
k := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
if err := k.ServiceOnly(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`)); !errors.Is(err, ErrServiceOnly) {
|
||||
t.Errorf("Runner.Enqueue error = %v, want ErrServiceOnly", err)
|
||||
}
|
||||
var n int
|
||||
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("refused enqueue stored %d jobs, want 0", n)
|
||||
}
|
||||
|
||||
id, err := k.Enqueue(ctx, countPayload{Steps: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
|
||||
t.Errorf("job enqueued through its Kind = %+v, want succeeded", job)
|
||||
}
|
||||
if err := k.ServiceOnly(); err == nil {
|
||||
t.Error("ServiceOnly after Start succeeded, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user