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
- type Cleanup
- type HoldDrain
- type HoldRow
- type HoldStats
- type HoldStore
- type HoldTenant
- type Inbox
- type Module
- func (m *Module) HoldLedger() app.HoldLedger
- func (m *Module) InboxProcessor() app.InboxProcessor
- func (m *Module) Init(deps *app.ModuleDeps) error
- func (m *Module) Name() string
- func (m *Module) RegisterJobs(registrar app.JobRegistrar) error
- func (m *Module) SetHoldReplayer(src func() app.HoldReplayer)
- func (m *Module) SetSharedResolvers(db func(context.Context) (dbtypes.Interface, error), ...)
- func (m *Module) Shutdown() error
- type Record
- type Store
Constants ¶
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.
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.
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).
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.
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 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
NewOracleHoldStore creates an Oracle hold store, refusing a table name whose derived names would not fit.
func NewPostgresHoldStore ¶ added in v0.61.0
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 (*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) 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).
type Record ¶
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 ¶
NewOracleStore creates a new Oracle inbox store. Returns an error if the table name is not a safe, unqualified identifier.
func NewPostgresStore ¶
NewPostgresStore creates a new PostgreSQL inbox store. Returns an error if the table name is not a safe, unqualified identifier.