Help us shape the future of Go! Take the 2026 Go Developer Survey

notify

package module
v0.6.7 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package notify is the half of a notification that is the same in every project.

Three statements, and the split between them is the whole design.

**A service says a thing happened.** One line in a hook it already has, with no recipients and no time: Service.Announce takes what happened and what it happened to, and nothing else.

**The table it happened to says when, and who.** Two generated methods on its rules interface, which is where an application's own knowledge lives.

**This package does everything in between**: it holds the announcement until it is due, asks who should hear about it, and writes an inbox line per account. None of that is in anybody's service, because none of it differs between applications — and all of it is what applications get subtly wrong.

The audience is computed late, and that is the decision everything follows

A pending notification carries no recipients at all. A post scheduled for Friday notifies whoever can read it on Friday, and somebody added to the group on Thursday evening is one of those people; a recipient list computed on Monday does not know that, and a system that captures one spends the rest of its life patching around it — a job that reconciles lists, a second table of pending additions, a support ticket that says "I never got the announcement".

So the audience is computed at the moment of sending, by asking the table the notification is about. The cost is honest and worth stating: that question is answered in a background job, without a request and without a caller, long after the transaction that caused it committed. Which is why the code that answers it has to be reachable from a job — see Registry.

Immediacy is one column

A direct notification is not a special case. Its `deliver_at` is now(), the dispatcher is nudged when the transaction commits, and the audience is computed microseconds later. A scheduled one has a later `deliver_at` and the same code runs when it arrives. There is no fast path to keep in step with a slow one, which is what makes late resolution cheap rather than expensive: the interesting case — a scheduled notification whose audience changed — is the same code as the boring one.

What this package does not do

No templates, no rendering, no localisation, and no title or body anywhere. A notification is a kind, a payload and a link to the row it is about; the sentence is the application's, for the reason the mail notifier already gives. A copy of a rendered string in the row is a copy that goes stale the day somebody rewords it.

Index

Constants

View Source
const (
	DeviceTable   = "rig_notification_device"
	SettingTable  = "rig_notification_setting"
	DeliveryTable = "rig_notification_delivery"
)

The three tables delivery adds, and the reads over them.

View Source
const (
	DefaultMaxAttempts = 14
	DefaultBackoffBase = time.Minute
	DefaultBackoffCap  = time.Hour
)

Defaults for the retry arithmetic, and the three of them are one decision.

Fourteen attempts, doubling from a minute and capped at an hour, span about eight hours: 1m, 2m, 4m, 8m, 16m, 32m, and hourly after that. The number that matters is the total, because the thing being outlasted is an outage — and an outage is measured in hours by everyone who has had one. Five attempts at a doubling minute, which is what this package shipped with, span thirty-one minutes. That outlasts a blip and nothing else: a provider down for a morning permanently failed every message rig had for it, and the row said Failed rather than "was never really tried".

The cap is what makes more attempts safe to have. Doubling fourteen times from a minute is five days, so without a ceiling the late attempts are not retries, they are a row nobody will look at again. Capped at an hour, the tail is seven hourly knocks, which is the shape somebody watching a provider's status page would choose by hand.

The trade is stated because it is a real one, and it is the reverse of the old default's. Eight hours of attempts means a genuinely undeliverable message occupies a queue for eight hours. Permanent is the answer to that — a provider that knows the recipient is wrong can say so and skip the whole schedule — and it is why these numbers could be raised at all.

View Source
const (
	Table          = "rig_notification"
	RecipientTable = "rig_notification_recipient"
)

The two managed tables. Spelled once, here, because they are the only names in this package a migration also has to know.

View Source
const DefaultClaimTTL = 5 * time.Minute

DefaultClaimTTL is how long a claim is honoured before another dispatcher may take the row.

Five minutes, which is the wrong number for somebody and has to be some number. It is chosen for the slowest channel most applications have, which is mail. The relationship that actually matters is to the channel's own timeout: set this shorter than that and every message is claimed twice under ordinary load — at-least-once stops being a crash-recovery property and becomes an everyday one.

View Source
const DefaultInterval = time.Minute

DefaultInterval is how often the engine looks for work on its own.

A minute, because that is the bound on how late a *scheduled* notification can be — an immediate one does not wait for it, since the commit that caused it nudges the engine directly.

View Source
const DefaultPageSize = 50

DefaultPageSize is how many lines a page holds when the caller says nothing.

View Source
const DefaultRetention = 90 * 24 * time.Hour

DefaultRetention is how long a read and dismissed inbox line is kept.

Ninety days: long enough that "what was I told in the spring" has an answer, and short enough that the busiest table in the schema does not grow forever.

View Source
const DefaultSendTimeout = 30 * time.Second

DefaultSendTimeout bounds one call into a channel.

Thirty seconds, which is the number rigclient already chose for a whole request, and it is here because a Sender is the one seam in rig where the code on the other side belongs to somebody else. Every other outbound call rig makes bounds itself — three seconds for the breach check, ten for a token exchange — and this one could not, because rig does not make it.

What it prevents is not a slow provider, which the backoff already handles. It is a provider that never answers at all: an http.Client with no Timeout, which is Go's default, dialling a host that black-holes the packets. A pass is a single goroutine, so one such call used to stop this replica dispatching anything ever again — on every channel, and the inbox with it, since a pass resolves before it dispatches.

View Source
const MaxPageSize = 200

MaxPageSize bounds what a caller can ask for.

View Source
const MinClaimTTL = time.Minute

MinClaimTTL is the shortest lease NewEngine will accept, and it panics rather than starting under it. Refused at boot rather than discovered as duplicate mail six weeks later.

Variables

View Source
var ErrNoSender = errors.New("notify: no sender for this channel")

ErrNoSender is what a channel with nothing registered answers with.

View Source
var ErrNotFound = rigerr.NotFound("no such notification")

ErrNotFound is what a lookup answers for a notification or an inbox line that is not there, is deleted, or belongs to somebody else.

One error for all three, deliberately. Distinguishing them would tell a caller that a row exists and is not theirs, which is exactly what the narrowing is for.

A rigerr.Error rather than a bare sentinel, so it carries its own status wherever it surfaces. As a bare sentinel it read as CodeInternal to anything that classified it, and the 404 existed only in this package's own fallback error writer — which every generated project replaces. So dismissing a notification that was not there answered 500 in every real application. Carrying the code is what makes that unable to happen again: there is no mapping left for a caller to be missing.

errors.Is against it still works, and the message a client sees is unchanged. What changed is [error.Error], which now reads `NotFound: no such notification` — the code is in front because rigerr.Error puts it there for the log.

Functions

func GroupBy

func GroupBy(key string) *string

GroupBy sets a collapse key of your own.

Two announcements with the same kind and the same key, to the same person, become one line — until it is read, after which the next one starts a fresh line. That falls out of a partial index rather than out of a rule, so there is nothing to get wrong about when a group ends.

func GroupBySubject

func GroupBySubject(s Subject) *string

GroupBySubject derives a collapse key from the row a notification is about, so ten comments on one post become one inbox line saying ten.

This is the answer most of the time. The coarser one — everything in a thread, however many posts it spans — is GroupBy.

func IsPermanent

func IsPermanent(err error) bool

IsPermanent reports whether err asks not to be retried.

It reads through wrapping, so an error a Sender passed Permanent and then annotated with fmt.Errorf still answers true.

func Permanent

func Permanent(err error) error

Permanent wraps an error a retry cannot fix, so the delivery is marked Failed on this attempt rather than spending the rest of its budget.

The case it is for is a provider refusing the recipient rather than refusing the request: an address that does not exist, a device token the vendor has revoked, a message the provider will never accept. Fourteen attempts over eight hours at any of those is the same answer fourteen times.

Use it only when the provider's answer is about the recipient. A 500, a timeout, a connection refused and a 429 are all about the provider, and all of them are what the ordinary schedule is for — wrapping one of those here turns a provider's bad ten minutes into permanently undelivered mail.

Permanent(nil) is nil. That is not a courtesy: Engine.Dispatch decides a send succeeded by testing the error against nil, so a helper that manufactured a non-nil wrapper from a nil error would mark every successful send as a permanent failure. Any helper here that can be handed a nil has to say what it does with one.

func RetryAfter

func RetryAfter(err error, d time.Duration) error

RetryAfter wraps an error with the earliest a retry is worth making, which is most often a 429 or a 503 carrying a Retry-After header.

The interval replaces the computed backoff for this attempt only — it does not change where the doubling had got to, so a provider that asks for ten minutes once does not reset the schedule. It is honoured as a floor rather than exactly: see Engine on why a hundred rows carrying one provider's boundary cannot all return at that boundary.

It does not extend the attempt budget. A provider asking to be retried after longer than the remaining attempts can cover will still run out of them, and that is the intended reading of max_attempts: a stop, not a negotiation.

RetryAfter(nil, d) is nil, for the reason Permanent gives. A d of zero or less is a plain wrap, so the ordinary backoff applies — a provider that sent a Retry-After of 0 is saying "now", and "now" for a queue is "next pass".

func RetryAfterOf

func RetryAfterOf(err error) (time.Duration, bool)

RetryAfterOf is the interval err asks to be retried after, and whether it asked at all.

A permanent error is not retried at all, so this answers false for one even if RetryAfter was also applied: "do not retry" and "retry at this time" cannot both be honoured, and refusing to retry is the stronger of the two. Reading them in the other order would let a Sender that wrapped both keep a delivery alive that it had already said was hopeless.

Types

type Announcement

type Announcement struct {
	// Kind is what happened, as the application names it. A string here and the
	// project's own Go enum type when it narrows rig_notification.kind to a
	// Postgres enum of its own — worth doing for one reason, which is that a
	// switch over kinds inside NotifyWho becomes one the compiler can see.
	Kind string

	// Subject is the row this is about. A generated helper builds it, so the
	// table name is written by rig and never by a request.
	Subject Subject

	// Payload is what a template needs beyond the linked row. It is stored as
	// jsonb and handed back whole; give the column a Go type with the `go_type`
	// key if it has a shape.
	Payload json.RawMessage

	// At is when this is due, and Due is whether it is due at all.
	//
	// They come from the subject's own NotifyAt, called by the hook that
	// announces — where the row is in hand. The zero time with Due means now,
	// which is the ordinary case and is not a special path: an immediate
	// notification and one scheduled for Friday are the same row with a
	// different column.
	//
	// Due false writes the notification and cancels it. The row is still
	// written and still linked, because a notification that was decided against
	// is a thing that happened and something has to be able to say so.
	At  time.Time
	Due bool

	// Group collapses several events into one inbox line. Nil is one line per
	// event; [GroupBySubject] is the ordinary answer; [GroupBy] sets a coarser
	// key of your own.
	Group *string

	// AccountIDs skips NotifyWho for this announcement, and is the documented
	// exception to everything above.
	//
	// Some audiences genuinely cannot be re-derived: the five people who were
	// @-mentioned in a body that has since been edited. Without this, NotifyWho
	// would have to read a version row to reconstruct a list somebody already
	// had in their hand.
	//
	// It is named as the exception rather than offered as the parameter, because
	// a list captured at write time is a list that stops being true, and that is
	// what late resolution exists to prevent.
	AccountIDs []uuid.UUID
}

Announcement is a service saying that something happened.

It carries no recipients and no time. Both of those are answered later and by somebody else: the time by the subject's own NotifyAt, the audience by its NotifyWho at the moment of sending.

type Channel

type Channel string

Channel is the three-way switch of a delivery, and every one of them is a *copy* of an inbox line sent somewhere else.

The inbox is deliberately not one. It is the table this module writes, it is always on, and a switch that turned it off would produce a notification nobody can ever find: the badge would be wrong, the count would be wrong, and the row would sit there unread forever. Everything here can be refused because everything here is a copy.

const (
	// ChannelDesktop and ChannelMobile are separate rather than one push
	// channel with a platform column on the device, and that is the whole reason
	// to name them this way: they are separately *answerable*. "Not on my phone
	// during dinner, yes on my laptop while I am working" is the setting people
	// reach for, and a platform on a device row cannot express it — the platform
	// says where a token points, and the question is what a person wants.
	ChannelDesktop Channel = "Desktop"
	// ChannelMobile is the other half of that pair.
	ChannelMobile Channel = "Mobile"
	// ChannelEmail has no device rows. The address is on the account and on the
	// identity already, and a third copy of it is a third thing that can
	// disagree.
	ChannelEmail Channel = "Email"
)

func Channels

func Channels() []Channel

Channels are all three, in the order a report lists them.

type Config

type Config struct {
	// DB is the pool the subject tables are in. The same one, deliberately: an
	// announcement is written in the transaction that caused it, and it cannot
	// be if the two live in different pools.
	DB DB

	// Registry is how the dispatcher reaches each notifiable table's answers.
	// Nil resolves nothing and says so in every report.
	Registry *Registry

	// Now is the clock, so a test can move it. Nil means time.Now.
	Now func() time.Time
}

Config is everything a Service needs that is not a request.

It is resolved from the `notifications:` block in rig.yaml and written into the generated wiring, so a claim lease or a retention window is a line in a file the documentation can quote rather than a literal in a main function nobody diffs.

type DB

type DB = dbx.Pool

DB is the pool the notification tables and their subjects live in. See dbx.Pool for why it is the two interfaces rather than *pgxpool.Pool.

type Delivery

type Delivery struct {
	// ID is stable across retries, and it is the whole of what rig can do about
	// exactly-once.
	//
	// Hand it to the provider as its own idempotency key — Message-ID, apns-id,
	// whatever the SDK calls it. rig cannot enforce that, and saying so is
	// better than implying exactly-once and letting somebody find out: the send
	// and the bookkeeping are two systems, no transaction spans both, and a
	// process that handed a message over and died will hand it over again when
	// its lease expires.
	ID uuid.UUID

	TenantID    uuid.UUID
	AccountID   uuid.UUID
	RecipientID uuid.UUID

	Channel Channel
	Kind    string
	// Digest is what the setting said when this row was written. Immediate rows
	// go out on their own; the rest are batched per account and channel.
	Digest Digest

	// Attempts is how many times this has been claimed, including this one.
	Attempts  int
	DeliverAt time.Time
}

Delivery is one copy of an inbox line, on its way.

type DeliveryState

type DeliveryState string

DeliveryState is where one copy is in its life.

const (
	// DeliveryPending is owed and not yet claimed, or claimed by a process that
	// has not marked it.
	DeliveryPending DeliveryState = "Pending"
	// DeliverySent means a channel accepted it, which is not the same as it
	// arriving — and rig does not pretend to know the difference.
	DeliverySent DeliveryState = "Sent"
	// DeliveryFailed is past max_attempts and stops being claimed. Without the
	// cap a permanently broken address consumes a lease and a log line forever.
	DeliveryFailed DeliveryState = "Failed"
	// DeliverySkipped is a setting that refused it, which is a different answer
	// from Failed and worth telling apart in a report.
	DeliverySkipped DeliveryState = "Skipped"
)

type Device

type Device struct {
	ID        uuid.UUID
	AccountID uuid.UUID
	Channel   Channel
	// Token is what the provider was given. Opaque to rig.
	Token string
	// Label is what a person sees in a list of their devices, and the field that
	// makes revoking one possible for somebody who has four.
	Label      string
	LastSeenAt *time.Time
}

Device is somewhere a push can land.

type Digest

type Digest string

Digest is how often somebody wants to hear from a channel.

const (
	// DigestImmediate sends each delivery on its own, as soon as it is due.
	DigestImmediate Digest = "Immediate"
	// DigestHourly waits an hour and sends whatever accumulated as one message.
	DigestHourly Digest = "Hourly"
	// DigestDaily is the same, a day at a time.
	DigestDaily Digest = "Daily"
	// DigestWeekly is the widest window, and the one retention has to outlive:
	// a weekly digest under a daily retention is assembled from rows that were
	// pruned before it ran.
	DigestWeekly Digest = "Weekly"
	// DigestOff sends nothing on this channel and still writes the inbox line —
	// the person will see it when they look.
	//
	// It is not the same as `is_enabled: false`, and the difference is worth a
	// sentence because somebody will set the wrong one. Both mean "no mail"
	// today. `Off` is about this person preferring to look rather than be told;
	// `is_enabled: false` is about the channel being unavailable to them, and it
	// would stop meaning the same thing if a future channel ever needed to
	// refuse the inbox line too.
	DigestOff Digest = "Off"
)

func (Digest) Window

func (d Digest) Window() (time.Duration, bool)

Window is how long a digest waits before it goes out as one message.

Immediate and Off have none: one sends now and the other never sends.

type DispatchReport

type DispatchReport struct {
	// Claimed is how many rows this pass took, Sent how many a channel
	// accepted, and Failed how many reached max_attempts and stopped.
	Claimed int
	Sent    int
	Failed  int
	// Retrying is how many failed this time and will be tried again.
	Retrying int
	// Rejected is how many a channel refused permanently, with [Permanent], and
	// which were failed on this attempt rather than spending the rest of the
	// schedule.
	//
	// It is worth telling apart from Failed because the two mean opposite things
	// about the provider. A rising Failed is rig giving up after eight hours of
	// trying; a rising Rejected is the provider answering immediately and
	// definitively, which usually means a list of addresses has gone stale
	// rather than that anything is wrong.
	Rejected int
	// Deferred is how many were rescheduled at a channel's own request, with
	// [RetryAfter], rather than on the computed backoff.
	//
	// A steady non-zero one is a provider telling this project it is sending too
	// fast, which is a different problem from a provider being down and is not
	// visible in Retrying, where it would otherwise be counted.
	Deferred int
	// Held is how many were outside somebody's window and moved to its next
	// opening rather than being sent or dropped.
	Held int
	// Digested is how many were folded into a message with others.
	Digested int
	// Released is how many claims a clean shutdown gave back rather than
	// leaving to expire.
	Released int
	// Abandoned is how many the pass did not reach because the lease ran out
	// before it got to them.
	//
	// It is the number that says a channel is slow enough to be a problem. Zero
	// is the ordinary case and a steady non-zero one means the batch cannot be
	// sent inside a claim — either the provider got slower or claim_ttl and
	// send_timeout no longer fit each other. Those rows are Pending again, with
	// the attempt the claim charged them given back, so nothing is lost; what is
	// lost is the assumption that a pass drains what it claims.
	Abandoned int
}

DispatchReport is what one dispatch pass did.

Every count, including the zeros, for the reason the file sweeper's report gives: a pass that sent nothing is the ordinary case and still worth seeing, because the absence of a line cannot be told from the job not running.

func (DispatchReport) String

func (r DispatchReport) String() string

String is the one line a pass is worth in a log: every count, zeros included.

type Engine

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

Engine is the dispatcher: it takes notifications whose time has come, asks who should hear about them, and writes the inbox lines.

It is the only thing in this module that runs on its own, and it runs in two places at once by design — an in-process goroutine nudged when a transaction commits, and a `serve.Task` an operator's cron invokes. The task is the guarantee; the goroutine is latency and nothing else. Say that in those words, because the shape invites the opposite reading: nothing is lost when the nudge is skipped, since the row is still Pending and the task is still coming.

func NewEngine

func NewEngine(cfg EngineConfig) *Engine

NewEngine builds a dispatcher. It does nothing until Engine.Start.

func (*Engine) ClaimedBy

func (e *Engine) ClaimedBy() uuid.UUID

ClaimedBy is this process's lease identifier, for the log line that makes a stuck lease traceable to a pod.

func (*Engine) Close

func (e *Engine) Close(ctx context.Context) error

Close finishes the pass in flight and stops.

Registered with app.CloseWithin, which runs before the pool closes — not incidental, because what is in flight is a write.

func (*Engine) Dispatch

func (e *Engine) Dispatch(ctx context.Context) (DispatchReport, error)

Dispatch is one pass: claim what is due, send it, mark it.

A pass that fails part way through is a pass. The rows it did not reach are still Pending, the rows it claimed and did not mark come back when their lease expires, and the next pass takes both.

func (*Engine) Nudge

func (e *Engine) Nudge()

Nudge asks for a pass now.

It is an optimization and nothing may be built on it. A dropped nudge — and they are dropped, because the channel holds one — costs at most one interval, since the row is Pending and the next pass takes it.

func (*Engine) Prune

func (e *Engine) Prune(ctx context.Context, retention time.Duration) (PruneReport, error)

Prune removes what nothing will read again.

rig prunes almost nothing else — the auth log grows forever, expired session tokens are never deleted — and this milestone adds three tables to a schema that already does not clean up after itself, the busiest of them getting a row per person per channel per event. So this is the one sweep that exists, and it has two rules and no third:

**Inbox lines that were read and dismissed**, past `retention`, with their deliveries. Read but not dismissed is somebody's history and stays; dismissed but unread is somebody's decision and also stays until the window passes.

**Notifications nothing points at any more.** A hard-deleted subject leaves exactly this behind on purpose: the delete could not remove the notification itself, because it might be about a row in another table whose link would refuse, so it leaves a resolved row for this pass.

It runs in the dispatch task rather than on its own schedule, the way the file sweeper's two rules share one.

func (*Engine) ReleaseClaims

func (e *Engine) ReleaseClaims(ctx context.Context) (int, error)

ReleaseClaims gives back every lease this process still holds.

**A clean shutdown must not cost a lease.** The TTL exists for crashes, and a process that knows it is going has no excuse for being slow about saying so: leaving them to expire turns every ordinary rollout into a delivery delay, and a rollout that replaces every pod turns it into that delay repeatedly.

`attempts` is left where it is. A send that outlived the close budget is abandoned rather than cancelled — the provider may still deliver it — so the retry after it is the at-least-once case again, which is the same case and not a new one.

func (*Engine) Resolve

func (e *Engine) Resolve(ctx context.Context) (Report, error)

Resolve is one pass: take what is due, ask who, write the lines.

Each notification is its own transaction. One tenant's Audience method failing must not hold up everybody else's, and a pass that resolved five and failed on the sixth should keep the five.

func (*Engine) Start

func (e *Engine) Start()

Start runs the dispatcher until Engine.Close.

This is the one place rig runs periodic work in-process, against a position stated in five other places — a task rather than a goroutine, so it is a cron job rather than something racing itself in every replica. The departure is defensible because a pass holds no state: two replicas resolving the same notification write the same inbox lines, and the unique index absorbs it. What it buys is that a reply notification is in somebody's inbox in milliseconds rather than by the next tick.

func (*Engine) StopClaiming

func (e *Engine) StopClaiming(context.Context) error

StopClaiming stops taking new work while the server is still answering.

Registered with app.Drain, which runs after readiness goes false and before the server stops: the requests still in flight are the last ones whose commits will nudge this, and the time left is better spent finishing than starting.

type EngineConfig

type EngineConfig struct {
	Config

	// Links is one entry per notifiable table: its join table and the column in
	// it. A generator writes them from the document, so no name here came from
	// a request.
	Links []Subject

	// Interval is how often a pass runs without a nudge. Zero means a minute,
	// which is the number a scheduled notification's lateness is bounded by.
	Interval time.Duration

	// Senders are the channels this project can actually send on. A channel
	// with nothing here has no delivery rows written for it at all, which is
	// the right answer: the alternative is a table of copies nobody will take.
	//
	// Empty is a working configuration: the inbox is not a channel, so an
	// application with no transport still has one.
	Senders map[Channel]Sender

	// ClaimTTL is how long this process's claim is honoured. Zero means
	// [DefaultClaimTTL]; under [MinClaimTTL] panics.
	ClaimTTL time.Duration

	// SendTimeout bounds one call into a [Sender]. Zero means
	// [DefaultSendTimeout]; ClaimTTL or longer panics.
	//
	// It is the deadline on the context a channel is handed, and it is the only
	// thing standing between a provider that never answers and a dispatcher that
	// never runs again.
	SendTimeout time.Duration

	// MaxAttempts, BackoffBase and BackoffCap are the retry arithmetic, and the
	// three of them are one decision — see the constants they default to for what
	// the numbers add up to. Zero means the defaults.
	MaxAttempts int
	BackoffBase time.Duration
	// BackoffCap bounds one wait, so a long schedule's tail is a series of
	// knocks rather than a sleep nobody will be awake for. Zero means
	// [DefaultBackoffCap]; under BackoffBase panics.
	BackoffCap time.Duration

	// Jitter is where the spread on a retry comes from: given n, it returns
	// something in [0, n).
	//
	// It exists to be replaced in a test, and for nothing else. A schedule is
	// asserted on by fixing this to the floor or the ceiling of its range, which
	// is not something a test can do to a package-level random source. Nil means
	// math/rand/v2's, which is per-P and needs no seeding and no lock.
	Jitter func(int64) int64

	// DefaultDigest is what an account with no setting for a channel gets.
	// Empty means Immediate.
	DefaultDigest Digest

	// Logger is where a pass says what it did.
	//
	// Nil is not silence: it is [log/slog.Default], the reading every other
	// Logger in rig gives it. Before this field the goroutine threw both of its
	// reports away — [Report] and [DispatchReport] were built for "the log line
	// the task writes" and only the cron half ever wrote one — so a project
	// running the engine in-process could not tell a dispatcher that was
	// working from one that had stopped.
	//
	// The lines are DEBUG, because there is one pair of them per interval
	// forever. What is not debug is a pass that failed, and a pass that found
	// notifications whose subject nothing answers for: both are ERROR and WARN,
	// and both are invisible today.
	Logger *slog.Logger
}

EngineConfig is what an engine needs beyond Config.

type InboxQuery

type InboxQuery struct {
	// UnreadOnly is the filter the badge and the list share.
	UnreadOnly bool
	// Kind narrows to one kind of event. Empty is all of them.
	Kind string

	// Before pages backwards through created_at, which is the order the list is
	// in. A cursor rather than an offset, because an inbox gains rows at the top
	// while somebody is reading it and an offset would show them a line twice.
	Before *time.Time
	Limit  int
}

InboxQuery is what a caller may narrow their own inbox by.

Deliberately small. This is the bell icon's query, not a reporting surface, and every field here is one somebody taps rather than types.

type Message

type Message struct {
	Channel   Channel
	AccountID uuid.UUID
	TenantID  uuid.UUID

	// Deliveries are what this message stands for, oldest first. One for an
	// immediate send; several for a digest.
	Deliveries []Delivery

	// Devices are where to send, for a push channel. Empty for Email, whose
	// address is on the account.
	Devices []Device
	// EmailAddress is where to send for Email, and empty otherwise.
	EmailAddress string
}

Message is what a channel is handed.

A slice rather than one delivery, because a digest is the same call with more in it: an Hourly account with nine pending deliveries gets one message containing nine, and an Immediate account beside it gets nine messages of one. The channel decides what to say with what it is given — "you have 2 unread notifications" and a link to the inbox is the obvious rendering, and rig does not write it, for the reason it writes no other template.

type NoSender

type NoSender struct{ Channel Channel }

NoSender refuses to send anything, and says so.

It is the default, and it fails rather than discarding, because a production deployment whose notifications all silently succeeded into nothing is worse than one that refused to start. A project that genuinely wants no delivery leaves the channel out of the engine's map, which writes no delivery rows at all — and the inbox still works, because the inbox is not a channel.

func (NoSender) Send

Send implements Sender by refusing.

type Notification

type Notification struct {
	ID       uuid.UUID
	TenantID uuid.UUID

	Kind    string
	State   State
	Payload json.RawMessage

	// DeliverAt is when this is due. It is the only difference between a
	// notification that goes out now and one scheduled for Friday.
	DeliverAt  time.Time
	ResolvedAt *time.Time
	CreatedAt  time.Time

	// GroupKey is what will collapse this into an existing inbox line, decided
	// when the announcement was written because that is where the subject was
	// in hand, and read when the audience is resolved.
	GroupKey *string

	// AccountIDs is a list captured at write time, and empty in the ordinary
	// case. See [Announcement.AccountIDs] for why the exception exists and why
	// it is an exception.
	AccountIDs []uuid.UUID
}

Notification is one row of rig_notification, as the engine holds it.

There is no title and no body, and there never will be. Those are rendering: locale-dependent, template-shaped, and stale the day somebody rewords them. What rig knows is that something happened and what it happened to.

type PruneReport

type PruneReport struct {
	// Lines are inbox lines that were read and dismissed, past retention.
	Lines int
	// Deliveries are the copies of them, which have to go first: a delivery
	// points at a line.
	Deliveries int
	// Notifications are the ones left with no inbox line and no link pointing at
	// them — which is what a hard-deleted subject leaves behind, deliberately.
	Notifications int
}

PruneReport is what one retention pass removed.

Every count including the zeros, for the reason the file sweeper's report gives: a pass that removed nothing is the ordinary case and still worth seeing, because the absence of a line cannot be told from the job not running.

func (PruneReport) String

func (r PruneReport) String() string

String is the one line a pass is worth in a log.

type Recipient

type Recipient struct {
	ID             uuid.UUID
	TenantID       uuid.UUID
	NotificationID uuid.UUID
	AccountID      uuid.UUID

	Kind     string
	GroupKey *string
	// EventCount is how many events this line stands for. One, unless a group
	// key collapsed them.
	EventCount int
	ReadAt     *time.Time

	CreatedAt time.Time
	DeletedAt *time.Time
}

Recipient is one inbox line.

Kind is copied from the notification rather than read through the join, and that is not denormalization for speed: it is what lets the collapse index and the live-sync shape work without the inbox touching rig_notification at all — a table holding rows for people who are not recipients yet and may never be.

func (*Recipient) Read

func (r *Recipient) Read() bool

Read reports whether the person has seen it. What the badge counts is the rest.

type Registry

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

Registry is every notifiable table's answers, by table name.

Built once where the services are, and handed to the engine and to the task alike — which is the whole reason a project's main.go grows a constructor both call instead of building services inside the mount closure.

func NewRegistry

func NewRegistry(subjects ...Subjects) *Registry

NewRegistry collects the subjects a project has.

Registering the same table twice is a panic rather than a silent overwrite: it means two services believe they own one table's audience, and whichever one lost would go on looking wired while nobody it was supposed to notify heard anything.

func (*Registry) For

func (r *Registry) For(table string) Subjects

For returns the subject registered for a table, or nil.

Nil is not an error here and is worth saying why: a project can have notifications whose subject table was dropped from a later build, and a dispatcher that failed on one would stop dispatching everything behind it. The engine reports them instead, in the count that exists for exactly this.

func (*Registry) Register

func (r *Registry) Register(subjects ...Subjects)

Register adds a subject after the registry exists.

It is here because of the order services have to be built in: a service needs the notify service to announce anything, and the notify service needs the registry to ask when a notification is due — so the registry is made first, empty, and filled once the services it points at exist. That is a knot rather than a design, and this is the one place it shows.

Registering the same table twice is a panic rather than a silent overwrite, for the reason NewRegistry gives.

func (*Registry) Tables

func (r *Registry) Tables() []string

Tables are the registered tables, for a startup log line that says what this process can resolve.

type Report

type Report struct {
	// Resolved is how many notifications had their audience computed.
	Resolved int
	// Recipients is how many inbox lines were written, and Collapsed how many
	// joined an existing unread line instead.
	Recipients int
	Collapsed  int

	// Empty is how many resolutions produced no recipients at all.
	//
	// It is here because it is the one failure mode of this design that is
	// otherwise silent: a notification with no audience looks exactly like a
	// notification nobody was owed. The commonest cause is an owner-scoped read
	// inside an Audience method, which returns nothing under System claims until
	// it is told not to narrow.
	Empty int

	// Unregistered is how many notifications named a table nothing answers for.
	// A build whose registry lost a service, most often.
	Unregistered int

	// Deliveries is how many copies were written for channels somebody's
	// settings allow, and Held how many were moved into a window rather than
	// being due at once.
	Deliveries int
	Held       int
}

Report is what one pass did, for the log line the task writes.

Every count, including the zeros, for the reason the file sweeper's report gives: a pass that resolved nothing is the ordinary case and still worth seeing, because the absence of a line cannot be told from the job not running.

func (Report) String

func (r Report) String() string

String is the one line a pass is worth in a log: every count, zeros included.

type Sender

type Sender interface {
	Send(ctx context.Context, m Message) error
}

Sender is what an application implements to actually send something.

rig ships no transport: no SMTP, no APNs, no FCM, no web-push, and no dependency for any of them. That is the bargain the mail notifier already makes, repeated without alteration — what rig knows is who is owed what and when, and every provider decision after that is one rig would get wrong.

Returning an error retries with backoff until max_attempts. Returning nil means the provider accepted it, which is not the same as it arriving, and rig does not pretend to know the difference.

A bare error gets the ordinary schedule, which is the right default: "try again" is the safe answer when nobody said otherwise. Two things are worth saying instead of it, and both are optional — Permanent for a provider refusing the recipient rather than the request, which fails the delivery on this attempt instead of spending eight hours on an address that does not exist, and RetryAfter for a provider that named the time to come back. A Sender written before those existed is a correct Sender.

**The context carries a deadline — notifications.send_timeout, thirty seconds by default — and honouring it is this method's job.** rig cannot enforce it, for the same reason it cannot enforce that Delivery.ID reaches the provider: what happens after this call is application code. Pass ctx to the request, and do not hand a provider SDK a client with no timeout of its own.

What a Sender that ignores it costs is worth stating, because the shape hides it. A pass is one goroutine and it resolves before it dispatches, so a call that never returns stops this replica writing inbox lines and sending on *every* channel — not just this one — until the process is restarted. The deadline exists to make that a failed delivery instead. It is the only protection there is, and it is cooperative.

type SenderFunc

type SenderFunc func(ctx context.Context, m Message) error

SenderFunc adapts a function to Sender.

func (SenderFunc) Send

func (f SenderFunc) Send(ctx context.Context, m Message) error

Send implements Sender.

type Service

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

Service is what a project's own code touches: one method to say that something happened, and the inbox reads behind the generated routes.

Everything else — holding an announcement until it is due, asking who should hear about it, writing the lines — is Engine, and no service has to know it exists.

func NewService

func NewService(cfg Config) *Service

NewService builds the service.

func (*Service) Announce

func (s *Service) Announce(ctx context.Context, a Announcement) (*Notification, error)

Announce records that something happened.

One line, in a hook the service already has, and it belongs in `After` rather than `AfterCommit`: the notification row is part of the change, and a change that committed without it is a notification nobody will ever send. That is the same argument the hook package makes about the other direction, read backwards.

It carries no recipients and no time. When it is due is the subject's own answer, asked here through the registry; who hears about it is asked later, in the dispatcher, so that the answer is current rather than however old the announcement is.

func (*Service) Deleted

func (s *Service) Deleted(ctx context.Context, subject Subject) error

Deleted is what the writer calls when a row is being removed outright.

Two things go: the link rows, which is what lets the subject's delete succeed at all rather than failing on 23503, and the inbox lines, because "somebody commented on ⟨deleted⟩" is the failure this exists to prevent.

The notification row itself stays, and that is deliberate rather than an oversight. A notification can be about rows in two tables, and deleting it here would fail on the other table's link — aborting a delete that had already succeeded. What is left is a resolved notification nothing points at, which is precisely the second thing the retention sweep is for.

func (*Service) Deleting

func (s *Service) Deleting(ctx context.Context, subject Subject) error

Deleting is what the subject's writer calls when one of its rows is being soft-deleted.

Cancel what is still pending about it, and soft-delete the inbox lines of what was already resolved. The link rows stay, because they are what says which lines to bring back.

func (*Service) Dismiss

func (s *Service) Dismiss(ctx context.Context, id uuid.UUID) error

Dismiss removes one line from the caller's inbox.

A soft delete against the recipient row, and the notification is untouched. One person clearing their inbox must not change what anybody else sees, which is the one structural argument for the recipient row existing separately at all beyond the collapse index.

func (*Service) Inbox

func (s *Service) Inbox(ctx context.Context, q InboxQuery) ([]*Recipient, error)

Inbox reads the caller's own notifications, newest first.

func (*Service) MarkAllRead

func (s *Service) MarkAllRead(ctx context.Context, q InboxQuery) (int, error)

MarkAllRead marks what the caller can currently see, taking the same filter the list took.

The filter is not a detail. "Mark all read" on a filtered inbox that silently cleared the unfiltered one is the interaction people complain about, and it is the reason this takes a query at all rather than being a bare statement.

func (*Service) MarkRead

func (s *Service) MarkRead(ctx context.Context, id uuid.UUID) error

MarkRead marks one line read. Marking a line that is already read is not an error: the caller asked for it to be read, and it is.

func (*Service) Reschedule

func (s *Service) Reschedule(ctx context.Context, subject Subject, at time.Time, due bool) error

Reschedule moves every pending notification about a row to a new time, or cancels them all.

This is what a `publish_at` that moved reaches, and it is called by the generated writer after every update rather than by a hook somebody has to remember. Cancellation touches Pending only: mail that is out cannot be recalled, and a state transition that pretended otherwise would be a lie the schema tells.

func (*Service) Restoring

func (s *Service) Restoring(ctx context.Context, subject Subject) error

Restoring is the other side: the row is coming back, so its inbox lines do.

A notification cancelled while its deliver_at went past stays cancelled, which is why only the recipient rows are restored and not the state. Reviving it would announce something that was gone when it was due.

func (*Service) UnreadCount

func (s *Service) UnreadCount(ctx context.Context) (int, error)

UnreadCount is the badge: one number, and the only thing most applications ask for on every page load.

type Setting

type Setting struct {
	Channel Channel
	// Kind is empty for the row that answers for every kind, which is the second
	// step of the resolution.
	Kind string

	// Enabled false writes no delivery row at all, rather than a Skipped one.
	// See [DigestOff] for how the two differ.
	Enabled bool
	Digest  Digest

	// ActiveFrom and ActiveUntil bound the window, in the account's own zone.
	// Both nil means all day.
	//
	// The window is stated as when to deliver rather than when to stay quiet,
	// and that is not a naming preference. "Mobile, weekdays, nine to five" is
	// one row; as quiet hours it is two, because the quiet period wraps a
	// weekend and a night, and somebody changing the end of their working day
	// would have to reason about the complement.
	ActiveFrom  *time.Duration
	ActiveUntil *time.Duration

	// ActiveDays are ISO weekdays, 1 being Monday. Empty means every day.
	//
	// An array rather than a bitmask, because it appears in a settings payload a
	// client renders and [1,2,3,4,5] is legible in a way 62 is not.
	ActiveDays []int

	// Zone is the account's own, from rig_account.time_zone. Nil means UTC,
	// which is what that column's own documentation says a null means.
	Zone *time.Location
}

Setting is one account's answer about one channel.

Resolution is three steps and this package states them once, because a settings system whose precedence is folklore is one nobody trusts: **the row for this kind and this channel, else the row for this channel with a null kind, else the default in rig.yaml.** Two partial unique indexes keep each of the first two steps single.

func DefaultSetting

func DefaultSetting(channel Channel, digest Digest) Setting

DefaultSetting is what an account with no row at all gets: enabled, on the project's default digest, all day, every day.

func (Setting) DigestWindowClose

func (s Setting) DigestWindowClose(t time.Time) (time.Time, bool)

DigestWindowClose is when a digest's batch goes out.

The window's close rather than the notification's own time, and that is why the due-set query needs no second concept: a digest is a claim whose batch happens to share an account, and it becomes due like everything else.

func (Setting) NextOpening

func (s Setting) NextOpening(t time.Time) time.Time

NextOpening is when a delivery due at t may actually go out.

t itself when the window is open, and the next opening when it is not. **Outside its window a delivery is held, not dropped**, and that is structural rather than a preference: the inbox line exists either way, so a channel that silently discarded its copy has made the badge and the mailbox disagree, and the person will eventually see the notification and wonder why they were never told. Late is a delay; dropped is a lie.

The times are read in the account's own zone, which is the only reading of a work-hours setting that is not a bug: 09:00 means nine where the person is.

A window that wraps midnight has to work — 22:00 to 06:00 is the ordinary way to say "not overnight" — and it is the arithmetic that is easy to get wrong, which is why the wrap is one branch here rather than an assumption spread over three.

type State

type State string

State is where a notification is in its life.

Resolved means the audience was determined and the inbox lines exist. It does not mean anything was sent, which is the delivery table's business and not this one's.

const (
	StatePending   State = "Pending"
	StateResolved  State = "Resolved"
	StateCancelled State = "Cancelled"
)

The three states, and the whole of the state machine.

Pending means the audience has not been computed yet, which is where every notification starts and where a scheduled one waits. Resolved means it was computed and the inbox lines exist. Cancelled means it will not be: a publish_at that was cleared, a row that was retired before its time came.

Nothing here says anything was sent. That is the delivery table's business and deliberately not this one's, because a notification with an inbox line and no mail is a working notification and a state machine that conflated them would have to lie about one of them.

type Subject

type Subject struct {
	// Table is the subject's own table, not the join. The join's name is derived
	// from it.
	Table string
	// LinkTable is the join table pointing at rig_notification, and Column is
	// the subject's own key in it.
	LinkTable string
	Column    string

	ID uuid.UUID
}

Subject is the row a notification is about: a table, and an identifier in it.

The table name is written by a generator from the compiled document and never by a request, which is what makes it safe for this package to build a statement around it — the same bargain the file owner makes.

type Subjects

type Subjects interface {
	// Table is the subject's own table, which is how the dispatcher finds the
	// right entry for a notification's link row.
	Table() string

	// Audience answers, at the moment of sending, which accounts should hear
	// about a row.
	//
	// It runs under System claims for the row's own tenant, which is what makes
	// the answer current: an account added to the group after the notification
	// was written is in this list, because this list is built now.
	//
	// It must be a pure read, and it may be called more than once for the same
	// notification. The unique index on (notification_id, account_id) makes a
	// repeat harmless; a method with side effects would make it visible.
	Audience(ctx context.Context, n *Notification, subjectID uuid.UUID) ([]uuid.UUID, error)
}

Subjects is what the dispatcher asks about a notifiable table: when notifications about a row are due, and who should hear about one.

It is the answer to the reach problem this milestone actually has. NotifyWho is a method on a service; the dispatcher is a background job; and today those two cannot see each other, because services are built inside the mount function and a task is a subcommand handed a pool. The answer is the one the delete propagation already uses — the job does not need the service, it needs the closure, and the closure carries the service it closed over.

When a notification is due is deliberately not here. That question is asked where the row is in hand — in the hook that announces, and in the one that reacts to an update — and asking it here would mean reading the row back, which a hook inside the write's own transaction cannot do: a generated read goes to the pool, so it would not see the row that has not committed yet.

A generator implements this per notifiable table and registers it where the service is already wired, so adding a link table and forgetting to register does not compile.

Directories

Path Synopsis
Package foundation carries the schema the notify module's tables need.
Package foundation carries the schema the notify module's tables need.
Package notifyhttp is the HTTP shape of an inbox.
Package notifyhttp is the HTTP shape of an inbox.
Package notifymodel is the Go over rig's own notification tables: the rows, the write inputs, and the enums their columns are.
Package notifymodel is the Go over rig's own notification tables: the rows, the write inputs, and the enums their columns are.

Jump to

Keyboard shortcuts

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