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

engage

package
v0.40.21 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package engage is the one door for every non-transactional message (a plug-in reminder, a weekly recap, a milestone): it decides whether a message may go out at all and makes sure each one goes out at most once.

Send checks, in order, each at send time: the user's toggle; quiet hours (pushes, 09:00 to 20:00 local); the budget (4 messages per account per rolling 30 days, push and email together); and then claims (account, kind, key) with an INSERT … ON CONFLICT DO NOTHING, so only the replica that wins the insert delivers, now and after a restart. A refused message leaves no row: it is counted in rivolt_engagement_sends_total and may be tried again under the same key. Charging-complete and software-update pushes are transactional and do not come through here.

A Utility message is one the user scheduled themselves (the plug-in reminder). It is outside quiet hours and the budget, but it needs a time zone the user's browser stored (no UTC guess), never goes between 22:00 and 07:00 local, and is still claimed and recorded.

Index

Constants

View Source
const (
	Sent          = "sent"
	Failed        = "failed"
	AlreadySent   = "already_sent"
	SkippedOff    = "skipped_off"
	SkippedQuiet  = "skipped_quiet"
	SkippedBudget = "skipped_budget"
	SkippedNoTZ   = "skipped_no_tz"
	SkippedWindow = "skipped_window"
	// Fenced: the claim was refused because this pod no longer holds the account's group lease; the
	// owner sends it (or already has).
	Fenced = "fenced"
)

Outcomes, as the metric labels them.

View Source
const (
	StatusEmpty    = "skipped_empty"
	StatusBackfill = "skipped_backfill"
)

Claim statuses a producer may record without sending (see Engage.Skip).

View Source
const (
	MonthlyBudget   = 4
	BudgetWindow    = 30 * 24 * time.Hour
	QuietStartHour  = 9  // pushes go from 09:00 ...
	QuietEndHour    = 20 // ... to 20:00 local
	UtilityEarliest = 7  // a utility never goes before 07:00 ...
	UtilityLatest   = 22 // ... or from 22:00 local
	StuckAfter      = 15 * time.Minute
	RetentionDays   = 400
)

Policy numbers (DA's rulings).

Variables

This section is empty.

Functions

This section is empty.

Types

type Channel

type Channel string

Channel is how a message is delivered.

const (
	Push  Channel = "push"
	Email Channel = "email"
)

type Engage

type Engage struct {
	Store Store
	Now   func() time.Time
	// Observe is told every outcome (kind, outcome) for the metric. May be nil.
	Observe func(kind, outcome string)
}

Engage runs the checks. The zero value is not usable; set Store.

func (*Engage) Maintain

func (e *Engage) Maintain(ctx context.Context) (purged int64, stuck int, err error)

Maintain purges rows past retention and returns how many claims are stuck in 'sending' (a crash between claim and mark: never resent, so this is how a silently lost message becomes visible).

func (*Engage) Send

func (e *Engage) Send(ctx context.Context, m Msg, deliver func(ctx context.Context) error) (string, error)

Send considers one message and, if every check passes and this caller wins the claim, delivers it. The outcome says what happened; the error is for infrastructure faults (a database error) or a delivery failure (then the outcome is Failed and the claim stays: it is not retried under this key).

func (*Engage) Skip

func (e *Engage) Skip(ctx context.Context, uid uuid.UUID, kind, key string, ch Channel, status string) (bool, error)

Skip claims (user, kind, key) as finished without sending: a recap week with nothing in it, or a milestone already passed when the feature shipped. It reports whether this call created the row.

type Msg

type Msg struct {
	User    uuid.UUID
	Kind    string // [a-z0-9_]
	Key     string // what makes this send unique per account, e.g. a local date
	Channel Channel
	// Utility: user-scheduled, outside the budget and quiet hours (see the
	// package comment).
	Utility bool
	// Toggle reads the user's live setting for this kind and channel. A nil
	// Toggle means off.
	Toggle func(ctx context.Context) (bool, error)
}

Msg is one message to consider.

type Store

type Store interface {
	// Timezone is the account's zone: the one its browser stored (stored=true,
	// known=true), else the statement email's (stored=false, known=true), else
	// none (known=false, tz "UTC" only so callers need not special-case it).
	Timezone(ctx context.Context, uid uuid.UUID) (tz string, stored, known bool, err error)
	// BudgetUsed counts budgeted claims (not skipped ones) since the cutoff.
	BudgetUsed(ctx context.Context, uid uuid.UUID, since time.Time) (int, error)
	// Claim inserts the (user, kind, key) row as 'sending'. For a budgeted
	// claim the insert also requires fewer than limit budgeted claims since the
	// cutoff, atomically; claimed=false with overBudget=true says that is why.
	Claim(ctx context.Context, m Msg, now, since time.Time, limit int) (claimed, overBudget bool, err error)
	// Mark ends a claim as 'sent' or 'failed'.
	Mark(ctx context.Context, uid uuid.UUID, kind, key, status string, now time.Time) error
	// RecordSkip inserts a finished row (skipped_empty, skipped_backfill); false
	// when the row already exists.
	RecordSkip(ctx context.Context, uid uuid.UUID, kind, key string, ch Channel, status string, now time.Time) (bool, error)
	// StuckSending counts claims left 'sending' for longer than d.
	StuckSending(ctx context.Context, d time.Duration, now time.Time) (int, error)
	// Purge deletes rows claimed before the cutoff.
	Purge(ctx context.Context, cutoff time.Time) (int64, error)
}

Store is the persistence Engage needs.

func NewStore

func NewStore(db *sql.DB) Store

NewStore returns the Postgres store.

Jump to

Keyboard shortcuts

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