Skip to content

feat(jobs/taskspec): TaskSpec registry for asynq tasks — closes #258#355

Merged
tayebmokni merged 1 commit into
mainfrom
feat/258-taskspec-registry
May 17, 2026
Merged

feat(jobs/taskspec): TaskSpec registry for asynq tasks — closes #258#355
tayebmokni merged 1 commit into
mainfrom
feat/258-taskspec-registry

Conversation

@tayebmokni
Copy link
Copy Markdown
Contributor

Summary

  • Adds packages/go/jobs/taskspec/, a single-source-of-truth declaration of every asynq task type (name, queue, retry policy, timeout, payload schema, handler).
  • Producers call taskspec.Enqueue (validates payload against the pinned draft-2020-12 schema via jsonschemautil, applies Queue/MaxRetry/Timeout to asynq.EnqueueContext); consumers call taskspec.Dispatch (walks the registry and wires each Handler onto an asynq.ServeMux).
  • Mirrors the shape of packages/go/policy and packages/go/plugins/capabilities: process-wide Default() singleton, first-writer-wins on duplicate name, race-clean Register / Get / Names, sorted output.
  • Handler signature is (ctx, payload []byte) so the asynq dependency does not leak into every package that declares a spec — the adapter unwrapping *asynq.Task lives only in dispatch.go.

Test plan

  • go vet ./jobs/taskspec/...
  • go test -race -count=1 ./jobs/taskspec/... — 22 tests, all pass
  • Register/Get round-trip, empty-name rejection, duplicate-name rejection
  • Concurrent Register + Names race-clean; concurrent duplicate Register sees exactly 1 success
  • Enqueue happy path asserts TaskInfo carries Queue/MaxRetry/Timeout from the spec
  • Enqueue rejects nil client (typed ErrNilClient), unknown name (typed ErrUnknownTask), schema-violating payload (typed ErrInvalidPayload)
  • Enqueue with nil schema skips validation; []byte payload passed verbatim (webhook signing relies on byte-stability)
  • Dispatch wires every non-nil-handler spec onto a real asynq.ServeMux; nil handlers are skipped not panicked; handler errors propagate through ProcessTask

…closes #258

Consolidates task name, queue, retry policy, payload schema, and handler
into one TaskSpec value. Producers (Enqueue) and consumers (Dispatch)
dereference the same struct, so drift between them is structural rather
than coordinated.

  - taskspec.TaskSpec — declarative descriptor with Name/Queue/MaxRetry/
    Timeout/PayloadSchema/Handler. Handler signature is (ctx, []byte)
    so asynq does not leak into every package that declares a spec.
  - taskspec.Registry — concurrent-safe, first-writer-wins on duplicate
    Name. NewRegistry for tests; Default() singleton for production.
  - taskspec.Enqueue — looks up spec, validates payload against the
    pinned 2020-12 schema (via packages/go/jsonschemautil), and applies
    Queue/MaxRetry/Timeout via asynq.EnqueueContext. Typed errors:
    ErrNilClient, ErrUnknownTask, ErrInvalidPayload.
  - taskspec.Dispatch — walks the registry and wires each Handler onto
    an asynq.ServeMux under the spec's Name. Skips nil-Handler specs
    rather than panic.

Tests cover register/get round-trip, duplicate rejection, sorted Names,
nil-client guard, schema-validation gate, []byte passthrough (webhook
delivery relies on signature stability), nil-schema bypass, mux wiring
of multiple handlers, nil-Handler skip, error propagation, and the race-
clean concurrent Register + Names workout. All 22 tests pass under
go test -race -count=1.

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
@tayebmokni tayebmokni merged commit 128e9cc into main May 17, 2026
8 checks passed
@tayebmokni tayebmokni deleted the feat/258-taskspec-registry branch May 17, 2026 21:10
tayebmokni added a commit that referenced this pull request May 18, 2026
## Summary

Adds `packages/go/jobs/cron` — the leader-elected cron scheduler that
completes the picture started by the asynq chassis (#362) and the
taskspec registry (#355). Exactly one worker fires each scheduled task
across a multi-replica deployment; failover happens within one lease TTL
of leader death.

Four files own the surface:

- **cron.go** — `CronSpec{Name, Schedule, TaskName, Payload}` +
`Registry`. First-writer-wins. The Schedule string is parsed via
`robfig/cron/v3` at Register time so a typo fails at boot, not at first
fire.
- **lease.go** — `Lease{Key, Owner, TTL}`. `Acquire` is `SET NX PX`.
`Renew` and `Release` are Lua compare-and-swap scripts keyed on `Owner`
so a stale process cannot wipe a fresh leader's claim — the TOCTOU
window between a naive GET + EXPIRE/DEL would re-enable that bug.
- **scheduler.go** — Run-loop. Try `Acquire`; if leader, fire any due
CronSpec via `taskspec.Enqueue` and `Renew` every `TTL/3`; on shutdown,
`Release`. Non-leaders idle-poll every `TTL/2` with +-25% jitter to
break the thundering herd.
- **seed.go** — `SeedDefaults` registers the canonical `revisions.purge`
daily 03:00 entry so the system has at least one cron in flight on boot.
Follow-up issues add the rest of the §8.2 catalog.

## Test plan

- [x] Single instance: acquires lease, fires scheduled task on tick.
- [x] Two instances: only one fires (verified via per-scheduler
`FireCount`).
- [x] Leader dies without releasing -> follower acquires within `TTL +
jitter`.
- [x] Bad cron expression rejected at `Register` time (typed
`ErrInvalidSchedule`).
- [x] Shutdown via `ctx` cancel releases the lease (`CurrentOwner`
returns `redis.Nil`).
- [x] Race test: 8 schedulers contending — cumulative fires bounded by
what a single instance would produce; no double-fire across lease
transitions.
- [x] Lease CAS guards: wrong-owner `Renew` and `Release` return
`ErrNotLeader`.
- [x] `go vet ./jobs/cron/...` clean.
- [x] `go test -race -count=1 ./jobs/cron/...` passes (also clean across
`-count=3`).

Resolves #270.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
tayebmokni added a commit that referenced this pull request May 18, 2026
## Summary

- Introduces `packages/go/plugins/abi/jobs` — the bridge between a
plugin's manifest `jobs[]` declarations and the host TaskSpec + Asynq
dispatch chassis (#355, #362). Mirrors the hooks bridge (#350, #364) for
queued background jobs.
- Defines the `gn_handle_job(name_ptr, name_len, payload_ptr,
payload_len) -> i64` ABI with the same packed-i64 return shape as
`gn_handle_hook`, so plugin SDKs share most of their host-call plumbing.
- For each `manifest.Jobs` entry, registers a `taskspec.TaskSpec` routed
to the `plugin` queue, with the asynq Task ID threaded through the
envelope as an idempotency key (#363). Capability-gated on
`jobs.enqueue` via the plugin capabilities Checker (#356, #344) —
manifests declaring jobs without the cap are rejected before any
TaskSpec installs.

## Test plan

- [x] `go vet ./plugins/abi/jobs/...` clean.
- [x] `go test -race -count=1 ./plugins/abi/jobs/...` — 22 cases pass:
pack/unpack round-trip, envelope marshal shapes,
happy-path/unknown/trap/OOM/oversize, manifest registration with correct
queue + MaxRetry, capability denial leaves the registry untouched, trap
returns a retryable error (no `asynq.SkipRetry`), hot-reload across two
fresh registries, duplicate job surfaces
`taskspec.ErrAlreadyRegistered`, and 100 concurrent task pickups for the
same plugin.
- [x] Full `./plugins/... ./jobs/...` test suite stays green.
- [x] DCO sign-off present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
tayebmokni pushed a commit that referenced this pull request May 19, 2026
…355

Operators tweak palette, typography, layout, and spacing through the
admin without editing theme.json. Overrides land in the options table
under theme_mods.<slug>; the renderer merges them at request time.

Backend (apps/api/internal/admin/customizer):
  - GET  /api/v1/admin/customizer/active returns the active theme
    manifest plus persisted overrides.
  - PUT  /api/v1/admin/customizer/active validates a partial override
    by deep-merging onto the manifest and running the install-time
    theme validator; rejects unknown paths via DisallowUnknownFields.
  - DELETE clears the overrides (Reset; idempotent).
  - Gated by new theme.customize capability (admin + super_admin).

Admin UI (apps/admin/src/app/appearance/customizer):
  - Live preview iframe driven by ?customizer=preview&overrides=<b64>.
  - ColorPicker (per palette entry), TypographySection (families +
    sizes), LayoutSection (content/wide), SpacingSection (scale).
  - Save submits the diff; Reset deletes overrides.

Tests (backend + admin):
  - go test -race ./internal/admin/customizer/... — handler GET/PUT/
    DELETE, validation, 401, 403, store round-trip, merge semantics.
  - pnpm vitest run src/app/appearance + Sidebar — CustomizerClient
    save/reset, preview URL, state diff, JSON-pointer paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
tayebmokni added a commit that referenced this pull request May 19, 2026
…355 (#411)

## Summary

- Backend `apps/api/internal/admin/customizer`: `GET/PUT/DELETE
/api/v1/admin/customizer/active`. Overrides land in the options table
under `theme_mods.<slug>`. PUT validates the override by deep-merging
onto the active manifest and running the install-time `theme.Validate`
validator; unknown paths are rejected via `json.DisallowUnknownFields`.
New `theme.customize` capability gates all routes (granted to admin +
super_admin).
- Admin UI `apps/admin/src/app/appearance/customizer`: live preview
iframe driven by `?customizer=preview&overrides=<base64>`. ColorPicker
(palette), TypographySection (families + sizes), LayoutSection
(content/wide), SpacingSection (scale). Save commits the diff, Reset
deletes.
- Wired into `apps/api/cmd/server/main.go` (mounted next to `rum.Mount`)
and the admin sidebar.

## Test plan

- [x] `cd apps/api && go test -race -count=1
./internal/admin/customizer/...` — passes (handler GET/PUT/DELETE,
401/403, merge, store, validation).
- [x] `cd apps/api && go test -race -count=1 ./...` — entire api module
green.
- [x] `pnpm --filter @gonext/admin typecheck` — clean.
- [x] `pnpm --filter @gonext/admin vitest run` — 22 files / 177 tests
pass, including new `state.test.ts` (12) and `CustomizerClient.test.tsx`
(7).
- [ ] Manual smoke test against a running stack — load
`/appearance/customizer`, tweak a color, Save → reload → palette
persists; Reset → palette reverts to theme defaults.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Mohamed Tayeb Mokni <tayeb.mokni@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants