inbox

package
v0.64.0 Latest Latest
Warning

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

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 27 Imported by: 0

Documentation

Overview

Package inbox provides durable consumer-side idempotency: a ledger that records processed event ids so redeliveries are skipped. It is the consumer-side complement to the transactional outbox.

Consumers take the event id from the delivery (messaging.Metadata.DedupKey, which validates the x-outbox-event-id header — or, when the delivery carries no such header, the AMQP message_id property — against the ledger grammar) and wrap their handler in deps.Inbox.ProcessOnce, which re-checks the grammar, records the id and runs the handler atomically, exactly once per id.

Index

Constants

View Source
const (
	DefaultHoldTableName     = "gobricks_inbox_hold"
	DefaultHoldDrainInterval = 5 * time.Second
	DefaultHoldMaxBackoff    = 5 * time.Minute
	DefaultHoldMaxAge        = time.Hour
	DefaultHoldLeaseDuration = 60 * time.Second
)

The hold's defaults. They are applied only when the hold is enabled, so a deployment reading inbox.hold.* back does not find settings for a hold it never asked for.

View Source
const DefaultRetentionPeriod = 7 * 24 * time.Hour

DefaultRetentionPeriod is the default processed-event retention (7 days). It must exceed the broker's maximum redelivery window. Written as a duration (168h) because Go's time.ParseDuration does not accept a "7d" unit.

View Source
const DefaultTableName = "gobricks_inbox"

DefaultTableName is the default inbox ledger table name.

Variables

This section is empty.

Functions

This section is empty.

Types

type Cleanup

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

Cleanup is a scheduler.Executor that removes processed-event records older than the configured retention period. Runs daily at 04:00 by default.

It resolves the database through the tenant-aware getDB resolver and fans out across the configured tenants in (static) multi-tenant mode, because the scheduler builds the JobContext from a tenant-less context (shared with the outbox cleanup via multitenant.FanOutRetentionCleanup).

func (*Cleanup) Execute

func (c *Cleanup) Execute(jobCtx scheduler.JobContext) error

type HoldDrain added in v0.61.0

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

HoldDrain replays held messages, tenant by tenant, in the order they were parked. One pass leases a tenant, replays its rows oldest-first, and either releases the tenant when nothing remains or defers it behind a backoff.

Replays go through the streams lane, so the drain runs where the consumers do; the ledger is the control-plane database, so every replica sees the same holds and the lease is what stops them replaying the same tenant at once.

func (*HoldDrain) Execute added in v0.61.0

func (d *HoldDrain) Execute(jobCtx scheduler.JobContext) error

Execute runs one drain pass over every holding consumer.

type HoldRow added in v0.61.0

type HoldRow struct {
	Consumer   string
	Stream     string
	Offset     int64
	TenantID   string
	Data       []byte
	Properties []byte
	HeldAt     time.Time
}

HoldRow is one parked stream delivery. (Consumer, Stream, Offset) is its identity: a partition's offsets are unique within it, and a super stream's partition is its own stream.

type HoldStats added in v0.61.0

type HoldStats struct {
	Tenants         int64
	Rows            int64
	OldestHeldSince time.Time
}

HoldStats is what the gauges report for one consumer.

type HoldStore added in v0.61.0

type HoldStore interface {
	// Park inserts the row and marks its tenant held, in tx. It is idempotent on
	// the row's identity: a redelivery of an already-parked offset reports
	// inserted=false rather than failing.
	Park(ctx context.Context, tx dbtypes.Tx, row *HoldRow) (inserted bool, err error)

	// HeldTenants lists the tenants currently held for a consumer.
	HeldTenants(ctx context.Context, db dbtypes.Interface, consumer string) ([]string, error)

	// ListTenants returns every held tenant's full drain state, oldest first.
	ListTenants(ctx context.Context, db dbtypes.Interface, consumer string) ([]HoldTenant, error)

	// DueTenants lists held tenants whose next attempt has come and whose lease is
	// free, oldest first.
	DueTenants(ctx context.Context, db dbtypes.Interface, consumer string, limit int) ([]HoldTenant, error)

	// AcquireLease takes or renews the drain lease for one tenant, reporting false
	// when another owner holds a live one.
	AcquireLease(ctx context.Context, db dbtypes.Interface, consumer, tenant, owner string, lease time.Duration) (bool, error)

	// ReleaseLease drops a lease this owner holds.
	ReleaseLease(ctx context.Context, db dbtypes.Interface, consumer, tenant, owner string) error

	// NextRows returns the tenant's rows in (stream, offset) order.
	NextRows(ctx context.Context, db dbtypes.Interface, consumer, tenant string, limit int) ([]HoldRow, error)

	// DeleteRow removes one replayed row. Fenced by the lease: a write affecting no
	// rows means the lease was lost, and the caller discards the replay's outcome.
	DeleteRow(ctx context.Context, db dbtypes.Interface, consumer, stream string, offset int64, tenant, owner string) (deleted bool, err error)

	// Defer records a failed replay: one more attempt, the next one backed off,
	// the error bounded, the lease cleared. Fenced by the lease.
	Defer(ctx context.Context, db dbtypes.Interface, consumer, tenant, owner string, backoff time.Duration, lastErr string) (updated bool, err error)

	// Release deletes the tenant's marker, and only when no rows remain. Fenced by
	// the lease.
	Release(ctx context.Context, db dbtypes.Interface, consumer, tenant, owner string) (released bool, err error)

	// Stats reports what the gauges publish for one consumer.
	Stats(ctx context.Context, db dbtypes.Interface, consumer string) (HoldStats, error)

	// CreateTable creates both tables and their indexes if they do not exist.
	CreateTable(ctx context.Context, db dbtypes.Interface) error
}

HoldStore is the hold ledger's persistence. Every method takes the resolved control-plane database: a hold lives there and nowhere else, because a tenant whose own database is down cannot hold its own messages.

Database time is the clock throughout — NOW() on PostgreSQL, SYSTIMESTAMP on Oracle — so replicas with skewed clocks agree on when a lease expired and when a tenant is due.

func NewOracleHoldStore added in v0.61.0

func NewOracleHoldStore(tableName string) (HoldStore, error)

NewOracleHoldStore creates an Oracle hold store, refusing a table name whose derived names would not fit.

func NewPostgresHoldStore added in v0.61.0

func NewPostgresHoldStore(tableName string) (HoldStore, error)

NewPostgresHoldStore creates a PostgreSQL hold store, refusing a table name whose derived names would not fit.

type HoldTenant added in v0.61.0

type HoldTenant struct {
	Consumer      string
	TenantID      string
	HeldSince     time.Time
	Attempts      int
	NextAttemptAt time.Time
	LastError     string
}

HoldTenant is one held tenant's drain state.

type Inbox

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

Inbox implements app.InboxProcessor, backed by the module's lazily-initialized vendor store.

func (*Inbox) ProcessOnce

func (i *Inbox) ProcessOnce(ctx context.Context, eventID string, fn func(ctx context.Context, tx dbtypes.Tx) error) error

ProcessOnce records eventID in the ledger and runs fn exactly once per id, atomically within a single transaction. A redelivery of an already-processed id short-circuits (fn is not run), counts one dedup hit and returns nil. The tenant is resolved from ctx; in single-tenant mode the tenant id is empty.

eventID must match ^[A-Za-z0-9_-]{1,128}$ (messaging.ValidateEventID), or — only under a delivery the sealed typed door opened (messaging.IsSealedDelivery) — the sealed dedup key `<SignFamily>:<jti>` that Metadata.DedupKey composes; any other id is refused BEFORE the ledger with an error wrapping messaging.ErrInvalidEventID and no row is written. The check is here, at the ledger door, rather than only where a header is read, so it holds however the consumer obtained the id — and so a header-sourced id can never spell a sealed dedup key: its `:` is outside the header grammar, and the sealed spelling is admitted only from the framework's own sealed context.

type Module

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

Module implements the GoBricks Module interface for the consumer-side inbox. It provides durable, exactly-once event processing via deps.Inbox.ProcessOnce and a daily cleanup job that prunes old processed-event records.

Register it like any other module (the scheduler is optional but required for the retention cleanup job):

for _, m := range []app.Module{
    scheduler.NewModule(), // optional: enables inbox-cleanup
    inbox.NewModule(),
    &myapp.ConsumerModule{},
} {
    if err := fw.RegisterModule(m); err != nil {
        log.Fatal(err)
    }
}

func NewModule

func NewModule() *Module

NewModule creates a new inbox Module instance.

func (*Module) HoldLedger added in v0.61.0

func (m *Module) HoldLedger() app.HoldLedger

HoldLedger is the port the streams lane parks through, or nil when this module runs no hold. The lane reads a nil as "no hold configured" and refuses any consumer that declared one, so the two answers must not be confused.

func (*Module) InboxProcessor

func (m *Module) InboxProcessor() app.InboxProcessor

InboxProcessor implements app.InboxProvider — returns the processor for ModuleDeps wiring. Returns nil when the inbox is disabled.

func (*Module) Init

func (m *Module) Init(deps *app.ModuleDeps) error

Init implements app.Module.

func (*Module) Name

func (m *Module) Name() string

Name implements app.Module.

func (*Module) RegisterJobs

func (m *Module) RegisterJobs(registrar app.JobRegistrar) error

RegisterJobs implements app.JobProvider. The inbox has no relay; it registers only the retention cleanup job, and only when retention is positive.

func (*Module) SetHoldReplayer added in v0.61.0

func (m *Module) SetHoldReplayer(src func() app.HoldReplayer)

SetHoldReplayer receives the source of the replayer the drain drives. It is a func rather than the value because the streams manager does not exist yet when modules are registered.

func (*Module) SetSharedResolvers added in v0.54.0

func (m *Module) SetSharedResolvers(
	db func(context.Context) (dbtypes.Interface, error),
	_ func(context.Context) (messaging.AMQPClient, error),
)

SetSharedResolvers injects the control-plane ("" key) resolvers. Called by app.RegisterModule; used only when inbox.tenancy=shared. The messaging resolver is ignored — the inbox has no broker of its own (ProcessOnce only touches the database).

func (*Module) Shutdown

func (m *Module) Shutdown() error

Shutdown implements app.Module.

type Record

type Record struct {
	TenantID    string
	EventID     string
	ProcessedAt time.Time
}

Record is a single row in the inbox ledger: a processed event id scoped to a tenant, with the time it was processed.

type Store

type Store interface {
	// MarkProcessed records (tenant_id, event_id) within the given transaction.
	// It returns inserted=true the first time an id is seen and inserted=false on
	// a duplicate (the id was already processed).
	MarkProcessed(ctx context.Context, tx dbtypes.Tx, rec Record) (inserted bool, err error)

	// DeleteProcessed removes ledger rows processed before the given time.
	// Returns the number of rows deleted.
	DeleteProcessed(ctx context.Context, db dbtypes.Interface, before time.Time) (int64, error)

	// CreateTable creates the inbox table and its index if they do not exist.
	// Used for auto-migration when inbox.autocreatetable is true.
	CreateTable(ctx context.Context, db dbtypes.Interface) error
}

Store abstracts inbox ledger operations for vendor-agnostic SQL. Implementations exist for PostgreSQL and Oracle with vendor-specific placeholder styles, DDL, and duplicate-detection (PostgreSQL ON CONFLICT vs Oracle unique-violation catch).

func NewOracleStore

func NewOracleStore(tableName string) (Store, error)

NewOracleStore creates a new Oracle inbox store. Returns an error if the table name is not a safe, unqualified identifier.

func NewPostgresStore

func NewPostgresStore(tableName string) (Store, error)

NewPostgresStore creates a new PostgreSQL inbox store. Returns an error if the table name is not a safe, unqualified identifier.

Directories

Path Synopsis
Package testing provides test utilities for the consumer-side inbox.
Package testing provides test utilities for the consumer-side inbox.

Jump to

Keyboard shortcuts

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