engage

package
v0.40.4 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 6 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"
)

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