worker

package
v0.1.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package worker implements the outbox consumer (ADR-016 §Class 2): a SEPARATE process (cmd/appximo-worker) that drains rows the engine wrote to public.outbox and runs each through a Processor. It is deliberately NOT a goroutine inside the engine — it survives engine restarts and never touches the engine's request hot path (the engine only WRITES to the outbox; the worker only READS).

Delivery model — at-least-once:

LISTEN outbox.NotifyChannel   ← wake-up hint (ephemeral; lost while down)
  + periodic poll fallback    ← the TABLE is the durable source of truth
  → SELECT … WHERE state='pending' FOR UPDATE SKIP LOCKED LIMIT N   (one tx)
  → Processor.Process(row) per row
  → UPDATE … sent_at=now(), state='sent'    (success)
    UPDATE … attempts+1 [, state='failed']  (failure / exhausted)
  → COMMIT

FOR UPDATE SKIP LOCKED makes concurrent workers claim DISJOINT rows, so a row is processed by at most one worker at a time. A worker that dies mid-batch aborts its tx; the locks release, sent_at stays NULL, and another worker reclaims the row — hence delivery is at-least-once and Processors MUST be idempotent (see Processor).

Index

Constants

View Source
const DefaultServiceTokenTTL = 60 * time.Second

DefaultServiceTokenTTL is how long a worker's service JWT is valid. Kept SHORT (60s, minted per operation) so there is no long-lived credential to leak — ADR-016 §Class 2 write-back.

Variables

This section is empty.

Functions

This section is empty.

Types

type Beginner

type Beginner interface {
	Begin(ctx context.Context) (pgx.Tx, error)
}

Beginner is any pgx handle that can start a transaction — both *pgx.Conn and *pgxpool.Pool satisfy it. Drain takes a Beginner so tests can pass a pool (to exercise concurrent SKIP LOCKED) while the running worker passes a dedicated *pgx.Conn.

type Config

type Config struct {
	Channel      string        // LISTEN channel; default outbox.NotifyChannel
	BatchSize    int           // rows claimed per Drain; default 50
	MaxAttempts  int           // attempts before a row is parked 'failed'; default 5
	PollInterval time.Duration // fallback poll cadence; default 5s
	BackoffMin   time.Duration // reconnect backoff floor; default 2s
	BackoffMax   time.Duration // reconnect backoff ceiling; default 30s
}

Config tunes the worker. Zero values fall back to the defaults applied by New.

type Connector

type Connector func(ctx context.Context) (*pgx.Conn, error)

Connector opens a fresh, DEDICATED Postgres connection. The worker calls it for the LISTEN connection and for the drain connection, and again after a drop. It must NOT hand out a pooled connection: a listener needs a permanent connection, and a pool silently breaks LISTEN by rotating the underlying conn (ADR-016 / the outbox investigation).

type DrainResult

type DrainResult struct {
	Processed int // rows marked 'sent' this batch
	Failed    int // rows that errored this batch (incl. those parked 'failed')
}

DrainResult summarizes one Drain (one transaction / one batch).

func Drain

func Drain(ctx context.Context, db Beginner, proc Processor, batchSize, maxAttempts int, log *zerolog.Logger) (DrainResult, error)

Drain claims up to batchSize pending rows with FOR UPDATE SKIP LOCKED, runs each through proc, and records the outcome — all inside ONE transaction. A processing error does NOT abort the batch: that row's attempts is incremented (and its state flips to 'failed' once attempts reaches maxAttempts) while the batch's other rows still commit.

db may be any pgx handle that can Begin a tx. For concurrent draining, call Drain from multiple goroutines on a *pgxpool.Pool (or on separate *pgx.Conn): SKIP LOCKED guarantees each row is claimed by at most one of them. log may be nil. Returns the per-batch tallies.

func (DrainResult) Claimed

func (r DrainResult) Claimed() int

Claimed is the number of rows locked this batch (Processed + Failed).

type EngineClient

type EngineClient struct {
	// contains filtered or unexported fields
}

EngineClient lets the worker write BACK to the engine through its HTTP API (not the tenant DB directly) so the write inherits the engine's validation + RBAC + the same path any client takes. For each call it mints a fresh, short-lived, SCOPED service JWT (the shared JWT_SECRET, a tenant-scoped service role — NOT admin) and sets the tenant Host header. The engine resolves the tenant from the Host subdomain and enforces the service role's RBAC.

func NewEngineClient

func NewEngineClient(baseURL, tenantDomain, jwtSecret, role string, ttl time.Duration) *EngineClient

NewEngineClient builds a client. role MUST be a scoped service role defined in the schema RBAC (minimal actions/resources), never an admin role. Zero ttl → DefaultServiceTokenTTL.

func (*EngineClient) Do

func (c *EngineClient) Do(ctx context.Context, tenant, method, path string, body any) (int, []byte, error)

Do performs an authenticated request to the engine API for tenant. body is JSON-encoded when non-nil. It returns the HTTP status and response body; a transport error (not an HTTP error status) returns a non-nil error. The caller decides what a non-2xx means — for the outbox, a non-2xx must NOT mark the row sent (it is retried, at-least-once).

type Processor

type Processor interface {
	Process(ctx context.Context, row Row) error
}

Processor runs the business logic for one outbox Row.

Idempotency is MANDATORY. Because delivery is at-least-once (a worker that crashes after the side-effect but before COMMIT releases the lock with sent_at still NULL, so the row is redelivered), Process may be called more than once for the same Row.ID. A real consumer with external side-effects must dedupe on a stable idempotency key — Row.ID is the natural choice: record "event <id> handled" in the consumer's own table (ideally in the SAME tx as a write-back side-effect) and no-op on the second delivery.

The echo consumer is trivially idempotent: it only logs, so a redelivery just logs twice. Returning a non-nil error keeps the row pending (sent_at stays NULL, attempts incremented) for retry until maxAttempts, after which the row is parked in state='failed' and never retried again.

type ProcessorFunc

type ProcessorFunc func(ctx context.Context, row Row) error

ProcessorFunc adapts a plain function to the Processor interface.

func (ProcessorFunc) Process

func (f ProcessorFunc) Process(ctx context.Context, row Row) error

Process implements Processor.

type Row

type Row struct {
	ID       int64           // public.outbox.id — also the natural idempotency key
	TenantID string          // owning tenant, read from the row (not re-derived)
	Topic    string          // e.g. "echo.test"
	Payload  json.RawMessage // the JSONB payload, copied out of pgx's buffer
	Attempts int             // delivery attempts BEFORE this one (0 on the first)
}

Row is one claimed outbox event handed to a Processor.

type Worker

type Worker struct {
	// contains filtered or unexported fields
}

Worker consumes public.outbox via LISTEN/NOTIFY with a polling fallback.

func New

func New(connect Connector, proc Processor, cfg Config, log zerolog.Logger) *Worker

New builds a Worker, applying defaults for any zero Config field.

func (*Worker) Run

func (w *Worker) Run(ctx context.Context) error

Run drives the consume loop until ctx is cancelled, reconnecting with capped exponential backoff whenever the connection drops. It returns ctx.Err() on a clean shutdown.

type WritebackProcessor

type WritebackProcessor struct {
	// contains filtered or unexported fields
}

WritebackProcessor is the SERVICE-JWT-V1 demonstration consumer: on a "{resource}.created" outbox event it mints a scoped service JWT and PATCHes the created row's status back through the engine API — proving the authenticated write-back chain (event → worker → mint JWT → engine API → RBAC accepts the scoped role → row marked sent). It is NOT real business logic; a real consumer (XLSX/email) replaces the side-effect and dedups on Row.ID (idempotency key).

Idempotency: setting status to a fixed value is naturally idempotent, which is what at-least-once delivery requires. Any non-2xx (or transport error) returns an error so the row stays pending and is retried — never marked sent on failure.

func NewWritebackProcessor

func NewWritebackProcessor(client *EngineClient, statusValue string, log zerolog.Logger) *WritebackProcessor

NewWritebackProcessor builds the demo consumer. statusValue defaults to "done".

func (*WritebackProcessor) Process

func (p *WritebackProcessor) Process(ctx context.Context, row Row) error

Process implements Processor. Non-".created" topics are acked (logged) so the demo never loops or blocks the queue.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL