Documentation
¶
Overview ¶
Package events is the pluggable user-lifecycle eventing primitive.
The service layer emits a typed Event whenever a user is created, updated, or deactivated. By default the publisher is a no-op (Discard), so emitting an event has no observable effect and existing deployments behave exactly as before. When outbound delivery is configured (GATEWAY_WEBHOOKS_ENABLED), the composition root swaps in an outbox-backed Publisher whose background worker delivers a signed webhook to every matching subscription at-least-once, with retry and exponential backoff, recording each attempt in a transactional outbox so delivery is idempotent by event id.
The package is intentionally narrow and self-contained — it depends on neither internal/service nor internal/repo — so it can be reused by the outbound-SCIM connector (which subscribes to the same events) and unit tested without a datastore. Persistence is abstracted behind OutboxStore so the in-memory store backs tests and a SQL-backed store can be wired without touching the delivery engine.
Index ¶
- Constants
- Variables
- func VerifySignature(secret string, body []byte, signature string) bool
- type Delivery
- type DeliveryStatus
- type Discard
- type Event
- type EventType
- type FailureHook
- type IDFunc
- type MemoryOutbox
- func (m *MemoryOutbox) AddSubscription(s Subscription)
- func (m *MemoryOutbox) ClaimDue(_ context.Context, now time.Time, limit int) ([]*Delivery, error)
- func (m *MemoryOutbox) EnqueueDelivery(_ context.Context, d *Delivery) error
- func (m *MemoryOutbox) Get(id string) (Delivery, bool)
- func (m *MemoryOutbox) ListActiveSubscriptions(_ context.Context, projectID string) ([]Subscription, error)
- func (m *MemoryOutbox) MarkDelivered(_ context.Context, id string, at time.Time) error
- func (m *MemoryOutbox) MarkFailed(_ context.Context, id string, attempts int, lastErr string) error
- func (m *MemoryOutbox) Reschedule(_ context.Context, id string, attempts int, lastErr string, ...) error
- type OutboxPublisher
- type OutboxStore
- type Publisher
- type RetryPolicy
- type Sender
- type Subscription
- type User
- type Worker
- type WorkerConfig
Constants ¶
const EventIDHeader = "X-Identity-Event-Id"
EventIDHeader carries the event id so a subscriber can deduplicate at-least-once redeliveries without parsing the body.
const SignatureHeader = "X-Identity-Signature"
SignatureHeader is the HTTP header carrying the hex-encoded HMAC-SHA256 signature of the raw request body. A subscriber recomputes HMAC-SHA256(secret, body) and constant-time-compares it to verify the webhook originated from this server and was not tampered with.
Variables ¶
var ErrDeliveryExists = errors.New("events: delivery already enqueued for this event/subscription")
ErrDeliveryExists is returned by EnqueueDelivery when an outbox row for the same (EventID, SubscriptionID) pair already exists. It makes enqueueing idempotent by event id: re-emitting an event that was already fanned out to a subscription is a no-op rather than a duplicate.
Functions ¶
Types ¶
type Delivery ¶
type Delivery struct {
ID string
EventID string
SubscriptionID string
URL string
Secret string
Payload []byte
Status DeliveryStatus
Attempts int
LastError string
NextAttemptAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
Delivery is one durable outbox row: a single event bound to a single subscription, with its attempt bookkeeping. The payload is the exact signed bytes so a retry re-sends an identical body (and identical signature), which is what makes delivery idempotent by event id on the receiving end.
type DeliveryStatus ¶
type DeliveryStatus string
DeliveryStatus is the lifecycle of one outbox row — one (event, subscription) delivery attempt-set.
const ( // StatusPending means the delivery is awaiting its first attempt or a // retry whose NextAttemptAt has not yet arrived. StatusPending DeliveryStatus = "pending" // StatusDelivered means the subscriber acknowledged the webhook (2xx). StatusDelivered DeliveryStatus = "delivered" // StatusFailed means the delivery exhausted its retry budget and was // abandoned. Failures are surfaced to the operator (audit/log), never // silently dropped. StatusFailed DeliveryStatus = "failed" )
type Discard ¶
type Discard struct{}
Discard is the no-op Publisher used when outbound eventing is disabled. It validates nothing and stores nothing, so emitting is free.
type Event ¶
type Event struct {
ID string `json:"id"`
Type EventType `json:"type"`
ProjectID string `json:"project_id"`
TenantID string `json:"tenant_id,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
User User `json:"user"`
}
Event is the typed payload emitted by the service layer and delivered to subscribers. ID is a unique, caller-supplied identifier used for at-least-once idempotency: a subscriber that has already processed an ID can safely ignore a redelivery, and the delivery engine never enqueues the same (event, subscription) pair twice.
type EventType ¶
type EventType string
EventType enumerates the user-lifecycle events the service emits. The string values are stable wire identifiers: they appear in the webhook payload's "type" field and are matched against a subscription's event filter, so they must never be renumbered or repurposed (append only).
const ( // EventUserCreated is emitted after a new user account is persisted. EventUserCreated EventType = "user.created" // EventUserUpdated is emitted after a user's mutable profile fields // (name, email, status transitions other than deactivation) change. EventUserUpdated EventType = "user.updated" // EventUserDeactivated is emitted after a user is deactivated (status // set to a non-active value, or the account deleted). This is the // deprovisioning signal outbound SCIM connectors act on. EventUserDeactivated EventType = "user.deactivated" )
type FailureHook ¶
type FailureHook func(d *Delivery)
FailureHook is called once when a delivery exhausts its retry budget, so the composition root can surface the abandonment via audit/metrics rather than swallowing it. It must not block.
type IDFunc ¶
type IDFunc func() string
IDFunc generates a unique delivery-row id. Injectable so tests get deterministic ids; production uses a random generator supplied by the composition root.
type MemoryOutbox ¶
type MemoryOutbox struct {
// contains filtered or unexported fields
}
MemoryOutbox is an in-process OutboxStore. It is the differential reference the SQL stores are held to and backs unit tests + single-node deployments.
func NewMemoryOutbox ¶
func NewMemoryOutbox() *MemoryOutbox
NewMemoryOutbox returns an empty in-memory outbox.
func (*MemoryOutbox) AddSubscription ¶
func (m *MemoryOutbox) AddSubscription(s Subscription)
AddSubscription registers a subscription. Provided as a test/wiring seam since this slice does not yet expose subscription-management RPCs.
func (*MemoryOutbox) EnqueueDelivery ¶
func (m *MemoryOutbox) EnqueueDelivery(_ context.Context, d *Delivery) error
EnqueueDelivery implements OutboxStore.
func (*MemoryOutbox) Get ¶
func (m *MemoryOutbox) Get(id string) (Delivery, bool)
Get returns a copy of a delivery by ID — a test/inspection seam.
func (*MemoryOutbox) ListActiveSubscriptions ¶
func (m *MemoryOutbox) ListActiveSubscriptions(_ context.Context, projectID string) ([]Subscription, error)
ListActiveSubscriptions implements OutboxStore.
func (*MemoryOutbox) MarkDelivered ¶
MarkDelivered implements OutboxStore.
func (*MemoryOutbox) MarkFailed ¶
MarkFailed implements OutboxStore.
type OutboxPublisher ¶
type OutboxPublisher struct {
// contains filtered or unexported fields
}
OutboxPublisher is the durable Publisher: Emit fans an event out to every matching active subscription by writing one pending outbox row per subscription, then returns. A background Worker (Run) delivers the rows. Because Emit only writes durable rows, a slow or down subscriber never adds latency to — or fails — the originating RPC.
func NewOutboxPublisher ¶
func NewOutboxPublisher(store OutboxStore, newID IDFunc, now func() time.Time, logger *zap.Logger) *OutboxPublisher
NewOutboxPublisher constructs an OutboxPublisher. now and newID may be nil, in which case time.Now and a panic-free fallback are used; logger may be nil (a no-op logger is substituted).
func (*OutboxPublisher) Emit ¶
func (p *OutboxPublisher) Emit(ctx context.Context, e Event) error
Emit implements Publisher. It validates the event, fans it out to every matching active subscription, and enqueues one idempotent outbox row per subscription. A subscription that already has a row for this event id is skipped (ErrDeliveryExists), so re-emitting is safe.
type OutboxStore ¶
type OutboxStore interface {
// EnqueueDelivery durably stores a pending delivery. It returns
// ErrDeliveryExists when a row for the same (EventID, SubscriptionID)
// already exists, so the caller's fan-out is idempotent.
EnqueueDelivery(ctx context.Context, d *Delivery) error
// ClaimDue returns up to limit pending deliveries whose NextAttemptAt
// is at or before now, marking each claimed so a concurrent worker
// does not pick the same row. The engine processes the returned batch
// and reports the outcome via MarkDelivered / Reschedule / MarkFailed.
ClaimDue(ctx context.Context, now time.Time, limit int) ([]*Delivery, error)
// MarkDelivered records a successful delivery (2xx ack).
MarkDelivered(ctx context.Context, id string, at time.Time) error
// Reschedule records a failed attempt and sets the next retry time.
Reschedule(ctx context.Context, id string, attempts int, lastErr string, nextAttemptAt time.Time) error
// MarkFailed records a delivery that exhausted its retry budget.
MarkFailed(ctx context.Context, id string, attempts int, lastErr string) error
// ListActiveSubscriptions returns every active subscription in the
// given project so the engine can fan an event out.
ListActiveSubscriptions(ctx context.Context, projectID string) ([]Subscription, error)
}
OutboxStore is the persistence boundary for the delivery engine. An in-memory implementation (MemoryOutbox) backs tests and single-node runs; a SQL-backed store (postgres/sqlite) implements the same contract for durable, multi-replica delivery. All methods must be safe for concurrent use.
type Publisher ¶
type Publisher interface {
// Emit records an event for delivery. It returns an error only when
// the event itself is invalid or the durable write fails; a delivery
// failure to a downstream subscriber is never surfaced here (it is
// retried by the worker). A no-op publisher always returns nil.
Emit(ctx context.Context, e Event) error
}
Publisher is the pluggable eventing sink the service layer holds. The service calls Emit on every user-mutation path; the implementation decides what (if anything) happens. The default (Discard) does nothing.
Emit must be safe for concurrent use and must not block on network I/O: the outbox-backed implementation only writes a durable row and lets a background worker handle delivery, so a slow or unreachable subscriber never adds latency to (or fails) the originating RPC.
type RetryPolicy ¶
RetryPolicy bounds delivery retries. After MaxAttempts failed attempts a delivery is abandoned (StatusFailed) and surfaced via FailureHook. The backoff is exponential — BaseDelay * 2^(attempt-1) — capped at MaxDelay.
func DefaultRetryPolicy ¶
func DefaultRetryPolicy() RetryPolicy
DefaultRetryPolicy is a conservative at-least-once policy: six attempts over roughly ten minutes.
type Sender ¶
Sender delivers one signed webhook. The default httpSender POSTs the payload; tests inject a fake. Deliver returns nil on a 2xx acknowledgement and a non-nil error otherwise (so the worker retries).
func NewHTTPSender ¶
NewHTTPSender returns a Sender backed by client (or a 10s-timeout client when nil).
type Subscription ¶
type Subscription struct {
ID string
ProjectID string
TenantID string
URL string
Secret string
EventTypes []EventType // empty ⇒ all types
Active bool
}
Subscription is a per-tenant webhook endpoint. The delivery engine fans an event out to every active subscription in the event's project whose EventTypes filter matches (an empty filter matches all types). Secret is the HMAC key used to sign the payload so the subscriber can verify the webhook originated from this server.
type User ¶
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"`
EmailVerified bool `json:"email_verified"`
}
User is the subset of a user record carried in a lifecycle event. It is deliberately small and free of secrets (no password hash, no tokens): downstream SaaS provisioning needs identity and status, nothing more.
type Worker ¶
type Worker struct {
// contains filtered or unexported fields
}
Worker drains the outbox: it claims due deliveries, sends each, and records the outcome (delivered / rescheduled with backoff / failed). It is safe to run one Worker per replica against a shared durable store — ClaimDue prevents two workers from sending the same row.
func NewWorker ¶
func NewWorker(cfg WorkerConfig) *Worker
NewWorker constructs a Worker from cfg, applying defaults for unset fields.
func (*Worker) ProcessOnce ¶
ProcessOnce claims one due batch and attempts each delivery. Exposed (and the unit of work) so tests can drive the worker deterministically without a ticker. It returns an error only when the store cannot be queried; per-delivery failures are recorded and retried, never returned.
type WorkerConfig ¶
type WorkerConfig struct {
Store OutboxStore
Sender Sender
Policy RetryPolicy
Now func() time.Time
Logger *zap.Logger
Batch int
Interval time.Duration
FailureHook FailureHook
}
WorkerConfig configures a Worker. Store and Sender are required; the rest have sane defaults.