messages

package
v0.13.3 Latest Latest
Warning

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

Go to latest
Published: May 9, 2026 License: GPL-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package messages is graywolf's APRS messaging domain. This file provides the storage/repository layer on top of configstore's GORM DB handle: CRUD on Message rows, conversation rollups for the master/detail UI, msgid allocation with per-peer collision skipping, ack correlation queries, retry-scheduler feeds, and participant extraction for tactical threads.

Phase boundaries: this file owns persistence only. The router (Phase 2), sender / retry manager (Phase 3), and REST handlers (Phase 4) consume the methods defined here but live in sibling files not yet created.

Index

Constants

View Source
const (
	EventMessageReceived     = "message.received"
	EventMessageAcked        = "message.acked"
	EventMessageRejected     = "message.rejected"
	EventMessageReplyAckRcvd = "message.reply_ack_received"
	// EventMessageSentRF is emitted when the TxHook confirms the
	// governor sent an outbound RF frame we originated. SentAt on the
	// row is flipped in the same transaction.
	EventMessageSentRF = "message.sent_rf"
	// EventMessageSentIS is emitted when the APRS-IS SendLine call
	// returned nil for an operator-originated outbound.
	EventMessageSentIS = "message.sent_is"
	// EventMessageFailed is emitted when the sender has exhausted
	// its retry budget on a DM or hit a terminal governor error.
	EventMessageFailed = "message.failed"
	// EventMessageDeleted is emitted when an operator soft-deletes
	// an outbound or inbound row via REST.
	EventMessageDeleted = "message.deleted"
	// EventMessageUpdated is emitted when a row's rendered state
	// changes without a more specific event (e.g. an invite gets
	// InviteAcceptedAt stamped). The webapi SSE layer already maps
	// unknown event types to the "updated" wire kind, but handlers
	// should prefer this constant for readability.
	EventMessageUpdated = "message.updated"
)

Event types emitted by the router and sender. The set is open — additional types may be added in later phases (e.g. message.deleted from REST soft-delete).

View Source
const (
	DefaultLocalTxRingTTL  = 5 * time.Minute
	DefaultLocalTxRingSize = 256
)

Defaults for the LocalTxRing. Tuned for messaging traffic: a 5-minute TTL covers the entire DM ack cycle (max 5 attempts × ~10min backoff = ~20min is longer, but acks only need to be swallowed for a few retransmits — the ring is a self-filter, not a durable record) and 256 entries holds roughly 5 minutes of sustained outbound messaging comfortably.

View Source
const (
	FallbackPolicyRFOnly     = "rf_only"
	FallbackPolicyISFallback = "is_fallback"
	FallbackPolicyISOnly     = "is_only"
	FallbackPolicyBoth       = "both"
)

Fallback policy wire values — mirror configstore's column semantics.

View Source
const (
	// DefaultRouterQueueCapacity is the internal bounded-channel size
	// between SendPacket (fan-out producer) and the consumer goroutine.
	// 256 matches the APRS fan-out queue capacity and gives roughly 30
	// seconds of headroom at realistic inbound message rates (a very
	// busy channel tops out at a few msg/s).
	DefaultRouterQueueCapacity = 256

	// DefaultRouterDedupWindow is the window over which
	// (from_call, msg_id, text_hash) tuples are treated as duplicates
	// at the router's pre-insert check.
	//
	// 5 minutes comfortably covers APRS sender retry lifetimes: graywolf
	// itself uses a 30s backoff × 4 attempts (~120s) and other clients
	// retry for up to 10 minutes. A window at or below the sender's
	// inter-attempt interval lets every retry slip through and persist
	// as a duplicate row, because each miss extends the expiry by only
	// one window. Keep this ≥ the longest realistic retry span so the
	// auto-ACK path alone handles repeated copies (APRS101 §14.2).
	DefaultRouterDedupWindow = 5 * time.Minute
)

Defaults for the router.

View Source
const (
	SubmitKindMessages        = "messages"
	SubmitKindMessagesAutoAck = "messages-autoack"
)

SubmitSourceKind values used by the messages sender. The router uses "messages-autoack" for DM auto-acks; the sender uses "messages" for operator-originated outbound. TxHook consumers registered by the Service filter on these values.

View Source
const (
	ThreadKindDM       = "dm"
	ThreadKindTactical = "tactical"
)

Thread-kind wire values. Kept as string constants so handlers, DTOs, and persisted columns all agree on the same literals.

View Source
const (
	AckStateNone      = "none"
	AckStateAcked     = "acked"
	AckStateRejected  = "rejected"
	AckStateBroadcast = "broadcast"
)

AckState wire values.

View Source
const (
	MessageKindText   = "text"
	MessageKindInvite = "invite"
)

MessageKind wire values. Classifies the body of a persisted message so the UI can render specialized affordances (invite → Accept button) without re-parsing the APRS text. "text" is the legacy default for every row written before the invite feature landed; migration 6 backfills legacy NULL/"" rows to "text" explicitly.

View Source
const (
	FolderAll   = "all"
	FolderInbox = "inbox"
	FolderSent  = "sent"
)

Folder discriminator for List.

View Source
const DefaultListLimit = 100

DefaultListLimit is applied when Filter.Limit is non-positive. The UI polls on 5 s intervals with a reasonable window and the tests want a sane default; 100 is generous without being unbounded.

View Source
const DefaultMaxMessageText = 67

DefaultMaxMessageText is the APRS101 addressee-line cap applied when no override is set. Mirrored here (rather than imported from pkg/webapi/dto) because pkg/messages must not depend on the webapi layer. Any change to this constant must be kept in sync with dto.MaxMessageText — the load-path test asserts the two agree.

View Source
const DefaultRetryMaxAttempts = 4

DefaultRetryMaxAttempts is the cap applied when MessagePreferences has an unset (0) value. Matches the seeded default. 4 attempts = 1 initial send + 3 retries at 30s intervals, total ~90s of RF activity before the row fails.

View Source
const DefaultSubscriberBuffer = 32

DefaultSubscriberBuffer controls per-subscriber buffering.

View Source
const MaxMessageTextCeiling = 200

MaxMessageTextCeiling is the hard upper bound accepted for the override. Kept in sync with dto.MaxMessageTextUnsafe. See the sender gate test that asserts a body over this ceiling is rejected even when the override requests it.

View Source
const ShortRetryDelay = 5 * time.Second

ShortRetryDelay is the grace period before the sender retries an outbound that hit a transient queue-full. It does NOT count against the attempt budget — the retry manager uses it as a back-pressure sleep, not a backoff step.

Variables

View Source
var (
	// ErrMsgIDExhausted is returned by AllocateMsgID when every 001..999
	// slot for the target peer is held by an outstanding outbound DM
	// row. The sender treats this as back-pressure (retry later or drop
	// with a user-visible failure).
	ErrMsgIDExhausted = errors.New("messages: no msgid available for peer (all 999 outstanding)")

	// ErrInvalidThreadKind is returned by Insert when a caller provides
	// a ThreadKind not in the accepted set.
	ErrInvalidThreadKind = errors.New("messages: invalid thread_kind")
)

Sentinel errors surfaced to callers.

View Source
var ErrInvalidInvite = errors.New("messages: invite requires a valid invite_tactical")

ErrInvalidInvite indicates SendMessage was invoked with Kind=invite but InviteTactical was absent or malformed. Returned to handlers so they can surface a 400 to REST callers.

View Source
var ErrMessageTextTooLong = errors.New("messages: text exceeds effective length cap")

ErrMessageTextTooLong is returned by Sender.Send when the row's Text exceeds the effective per-message cap from MessagePreferences. The sender is the authoritative gate — REST DTO validation is early- reject feedback, but this error catches APRS-IS routed, bot- originated, and retry-resend paths that don't pass through the webapi validator. Non-retryable: mutating the body requires a new compose.

View Source
var RetryBackoff = []time.Duration{
	30 * time.Second,
}

RetryBackoff is the default DM ack-timeout backoff ladder. Each entry is a target delay for the Nth attempt; ±10% jitter is applied at scheduling time. When attempts exceed the ladder length the final value is reused until the preferences.RetryMaxAttempts cap fires. A single 30s entry yields constant 30s spacing between every attempt — channel-friendly on shared 1200-baud APRS.

View Source
var WellKnownBots = []BotAddress{
	{Callsign: "QRX", Description: "Store and forward"},
	{Callsign: "SMS", Description: "Send an SMS via APRS"},
	{Callsign: "FIND", Description: "Locate a callsign"},
	{Callsign: "WHO-IS", Description: "Callsign lookup"},
	{Callsign: "REPEAT", Description: "Message repeater"},
	{Callsign: "WXBOT", Description: "Weather information"},
	{Callsign: "MPAD", Description: "Mobile Position/Address Data"},
	{Callsign: "MAIL", Description: "APRS email gateway"},
	{Callsign: "WLNK-1", Description: "Winlink via APRS"},
}

WellKnownBots is the curated list of APRS service addresses. Sourced from the APRS101 reference and common bot practice. Add entries as new services emerge. Case is canonical uppercase.

Functions

func IsWellKnownBot

func IsWellKnownBot(callsign string) bool

IsWellKnownBot reports whether callsign (case-insensitive) collides with a well-known bot name. The tactical-callsign CRUD handler uses this to reject registrations that would poison the routing logic: a user who creates a tactical labelled "SMS" would start intercepting messages intended for the APRS-SMS bot.

func NormalizeFallbackPolicy

func NormalizeFallbackPolicy(p string) string

NormalizeFallbackPolicy returns a canonical wire value for p. Unknown values fall back to "is_fallback" (the seeded default) so the sender never sees an empty policy.

func ParseInvite

func ParseInvite(text string) (tactical string, ok bool)

ParseInvite returns the referenced tactical callsign and ok=true iff text is a valid invite wire body per the strict grammar above. It is deliberately side-effect-free: the caller (Router.persistInbound) decides what to do with the result (stamp Kind=invite + InviteTactical on the Message row, or fall through to plain text).

ParseInvite does NOT lowercase, trim, or normalize text — the APRS body is persisted verbatim and we classify on exactly what was received. A legacy `INVITE TAC` body (no sigil) returns ok=false and persists as plain text.

Types

type AddresseeMatch added in v0.13.0

type AddresseeMatch struct {
	IsForUs    bool
	IsTactical bool
}

AddresseeMatch is the result of resolving an inbound addressee against the local trigger surface (station call + tactical aliases).

func MatchAddressee added in v0.13.0

func MatchAddressee(ourCall, addressee string, tactical *TacticalSet) AddresseeMatch

MatchAddressee reports whether addressee is one we should handle. ourCall is the primary station callsign (with or without SSID); the match against ourCall is base-call only. tactical may be nil.

type BotAddress

type BotAddress struct {
	Callsign    string
	Description string
}

BotAddress is one well-known APRS service addressee. The autocomplete endpoint always surfaces the bot set regardless of whether the operator has ever messaged them; tactical-callsign CRUD rejects any attempt to register a label colliding with one of these names (routing confusion risk).

type BotDirectory

type BotDirectory interface {
	// List returns every registered bot.
	List() []BotAddress
	// Match returns the subset whose Callsign starts with prefix
	// (case-insensitive). An empty prefix matches everything.
	Match(prefix string) []BotAddress
}

BotDirectory is the narrow interface consumed by the autocomplete handler. Kept small so tests can inject fakes; production uses DefaultBotDirectory.

var DefaultBotDirectory BotDirectory = &staticBotDirectory{entries: WellKnownBots}

DefaultBotDirectory is the singleton BotDirectory backed by WellKnownBots. Tests may construct their own via NewBotDirectory.

func NewBotDirectory

func NewBotDirectory(entries []BotAddress) BotDirectory

NewBotDirectory constructs a BotDirectory over the supplied entries. Used by tests that want isolated control of the bot set.

type ConversationSummary

type ConversationSummary struct {
	ThreadKind       string
	ThreadKey        string
	LastAt           time.Time
	LastSnippet      string
	LastSenderCall   string
	UnreadCount      int
	TotalCount       int
	ParticipantCount int // tactical only; 0 for DM
}

ConversationSummary is one row per (thread_kind, thread_key) in the UI's master pane. The REST layer (Phase 4) wraps this in its DTO.

type Event

type Event struct {
	Type       string
	MessageID  uint64
	ThreadKind string
	ThreadKey  string
	Timestamp  time.Time
}

Event is the payload delivered to subscribers. MessageID is 0 for events that do not correspond to a stored row (reserved for future use). ThreadKind/ThreadKey may be empty when the event is not thread-scoped.

type EventHub

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

EventHub is a small non-blocking pub/sub. Subscribers receive events on a buffered channel; a slow consumer drops events rather than blocking the publisher. The default buffer size is 32 — large enough to absorb a burst of classifications without dropping, small enough to make a buggy consumer visible quickly via the EventsDropped counter.

func NewEventHub

func NewEventHub(bufSize int) *EventHub

NewEventHub constructs an empty hub. Pass bufSize <= 0 to use the default.

func (*EventHub) EventsDropped

func (h *EventHub) EventsDropped() uint64

EventsDropped returns the cumulative number of events that could not be delivered because a subscriber's buffer was full.

func (*EventHub) Publish

func (h *EventHub) Publish(e Event)

Publish broadcasts e to all current subscribers. Each send is non-blocking; a slow subscriber's event is dropped and the EventsDropped counter advances. Publish never blocks the caller.

func (*EventHub) Subscribe

func (h *EventHub) Subscribe() (<-chan Event, func())

Subscribe registers a listener and returns its receive channel plus an unsubscribe closure. The closure is idempotent. Closing the channel is the hub's responsibility — the caller must NOT close it.

func (*EventHub) Subscribers

func (h *EventHub) Subscribers() int

Subscribers returns the current number of live subscribers (for metrics / tests).

type Filter

type Filter struct {
	// Folder filters by direction: "inbox" (in), "sent" (out), or
	// "all" / "" (both).
	Folder string
	// Peer matches the PeerCall column. For DM threads this equals the
	// thread key; for tactical threads it equals the human sender
	// (inbound) or our_call (outbound).
	Peer string
	// ThreadKind + ThreadKey together select a specific thread. Either
	// or both may be empty.
	ThreadKind string
	ThreadKey  string
	// Since restricts results to CreatedAt >= Since. Zero = no bound.
	Since time.Time
	// Cursor is an opaque string produced by a previous List call.
	// When non-empty, results are ordered by (UpdatedAt, ID) ascending
	// strictly greater than the cursor.
	Cursor string
	// UnreadOnly limits to rows with Unread=true.
	UnreadOnly bool
	// Limit caps the result set. Values <= 0 apply the package
	// default (DefaultListLimit).
	Limit int
	// IncludeDeleted includes soft-deleted rows. Defaults to false.
	IncludeDeleted bool
}

Filter describes a List query. All fields are optional — an empty Filter returns recent messages across all threads.

type IGateLineSender

type IGateLineSender interface {
	SendLine(line string) error
}

IGateLineSender is the narrow interface the router uses to mirror auto-ACKs back to APRS-IS when the triggering inbound arrived via IS. *igate.Igate satisfies this via its SendLine method.

type LocalTxRing

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

LocalTxRing tracks recent (source, msg_id) tuples submitted by our sender so that the router's self-filter and the iGate gating filter can recognize packets that originated locally and avoid acting on them as if they were inbound.

Entries have a TTL (default 5 minutes) and the ring caps the number of live entries (default 256). Eviction happens lazily on insert and on lookup — no dedicated goroutine. The hot path (Contains) reads under an RLock; inserts and expirations take the write lock.

func NewLocalTxRing

func NewLocalTxRing(size int, ttl time.Duration) *LocalTxRing

NewLocalTxRing returns an empty ring with the given capacity and TTL. Values <= 0 fall back to defaults.

func (*LocalTxRing) Add

func (r *LocalTxRing) Add(source, msgID string)

Add records a newly-submitted (source, msg_id). Existing entries with the same key are refreshed (TTL reset, moved to tail).

func (*LocalTxRing) Contains

func (r *LocalTxRing) Contains(source, msgID string) bool

Contains reports whether (source, msg_id) is in the ring. Expired entries are evicted before the check so a stale positive never leaks.

func (*LocalTxRing) Len

func (r *LocalTxRing) Len() int

Len returns the number of live entries. Useful for metrics.

type MessageHistoryEntry

type MessageHistoryEntry struct {
	Callsign  string
	LastHeard time.Time
}

MessageHistoryEntry is one row of the autocomplete "seen-before" feed. Callsign is the peer we corresponded with; LastHeard is the latest message exchanged with that peer. No other state is surfaced — the autocomplete endpoint merges this with the stationcache hit list and the bot directory.

type MessagePreferencesReader

type MessagePreferencesReader interface {
	GetMessagePreferences(ctx context.Context) (*configstore.MessagePreferences, error)
}

MessagePreferencesReader is the narrow read interface the preferences wrapper consumes. *configstore.Store satisfies it via GetMessagePreferences; tests pass a fake.

type Participant

type Participant struct {
	Callsign   string
	LastActive time.Time
}

Participant is one distinct sender observed on a tactical thread, with the most recent time we saw a message from them.

type Preferences

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

Preferences is a cached snapshot of the MessagePreferences singleton. The sender, retry manager, and service consult it on every outbound decision; Load replaces the snapshot atomically so the messagesReload consumer (Phase 4) can refresh without locking the hot path.

Callers construct via NewPreferences(reader) and call Load(ctx) at startup and on every reload signal. Current() returns the most recent successfully-loaded snapshot; if Load has never succeeded it returns a non-nil pointer to the configstore defaults so the sender always has a policy to evaluate.

func NewPreferences

func NewPreferences(reader MessagePreferencesReader) *Preferences

NewPreferences constructs an unloaded cache. Callers invoke Load before using Current; if they forget, Current returns the built-in defaults.

func (*Preferences) Current

Current returns the most recently loaded snapshot. Never returns nil — if Load has never succeeded, returns a pointer to the default configuration so the sender and retry manager always see a policy. The returned pointer is owned by the cache; callers must NOT mutate it. Treat it as read-only.

func (*Preferences) EffectiveMaxMessageText

func (p *Preferences) EffectiveMaxMessageText() int

EffectiveMaxMessageText returns the per-message body cap the sender must enforce given the current preferences. Semantics:

  • Override == 0 (default, including pre-upgrade rows) → 67.
  • Override in [68, 200] → override.
  • Any out-of-range value (corrupt DB, forward-incompatible migration) normalizes to 67 so a bad row cannot relax the gate.

func (*Preferences) Load

Load fetches the latest singleton from the reader and replaces the cached snapshot. A DB error leaves the previous snapshot in place so a transient read failure doesn't take down the sender.

type Preflight added in v0.13.0

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

Preflight is the inbound-message preflight: a shared cache of (from, msg_id, text_hash) tuples plus the transport for auto-ACKs. Both messages.Router and actions.Classifier consult the same instance so an @@-prefixed packet that the classifier consumes still gets ACKed and dedup-suppressed exactly the way a normal message would. APRS101 §14.2 — every copy is acked even when the original was already deduped.

func NewPreflight added in v0.13.0

func NewPreflight(cfg PreflightConfig) (*Preflight, error)

NewPreflight constructs a Preflight from cfg. Returns an error if any required field is missing.

func (*Preflight) AutoAckChannel added in v0.13.0

func (p *Preflight) AutoAckChannel() uint32

AutoAckChannel returns the live RF channel ID used for auto-ACKs when the inbound was IS-sourced. Reads are lock-free.

func (*Preflight) AutoAcksSent added in v0.13.0

func (p *Preflight) AutoAcksSent() prometheus.Counter

AutoAcksSent returns the live auto-ACK counter for tests.

func (*Preflight) CheckDedup added in v0.13.0

func (p *Preflight) CheckDedup(fromCall, msgID, text string) bool

CheckDedup consults the (from, msg_id, text_hash) cache. Returns true on a hit. Always records the current tuple so the next identical packet within the window also hits. Expired entries are evicted during the pass.

func (*Preflight) DedupHits added in v0.13.0

func (p *Preflight) DedupHits() prometheus.Counter

DedupHits returns the live dedup-hit metric so callers can read it in tests without standing up a registry.

func (*Preflight) SendAutoAck added in v0.13.0

func (p *Preflight) SendAutoAck(
	ctx context.Context,
	pkt *aprs.DecodedAPRSPacket,
	peerCall, msgID string,
)

SendAutoAck builds and submits an auto-ACK for an inbound message. The ack follows the path the message arrived on: RF inbound acks over RF (on the receiving channel when known, configured fallback otherwise); IS inbound acks via IGateSender. Empty msgID is a no-op. Mirroring an IS-sourced ack onto RF would waste local airtime on a channel the correspondent cannot hear.

func (*Preflight) SetAutoAckChannel added in v0.13.0

func (p *Preflight) SetAutoAckChannel(ch uint32)

SetAutoAckChannel updates the IS-fallback auto-ACK channel. Zero is ignored. Safe to call concurrently.

type PreflightConfig added in v0.13.0

type PreflightConfig struct {
	// OurCall returns our primary callsign (possibly with SSID). Required.
	OurCall func() string
	// TxSink is the governor used to submit RF auto-ACK frames. Required.
	TxSink txgovernor.TxSink
	// IGateSender is the IS-side line sender used to mirror auto-ACKs
	// when the inbound was IS-sourced. Optional — IS auto-ACKs are
	// skipped when nil.
	IGateSender IGateLineSender
	// Logger is optional; nil falls back to slog.Default().
	Logger *slog.Logger
	// Registerer is optional; nil disables metric registration but the
	// counters are still created so callers can read them in tests.
	Registerer prometheus.Registerer
	// Clock is optional; nil falls back to wall clock.
	Clock RouterClock
	// AutoAckChannel is the RF channel used when submitting auto-ACKs
	// for IS-sourced inbound. Defaults to 1.
	AutoAckChannel uint32
	// DedupWindow overrides the (from, msg_id, text_hash) dedup window.
	// <= 0 falls back to DefaultRouterDedupWindow.
	DedupWindow time.Duration
}

PreflightConfig captures the preflight's collaborators.

type RFAvailability

type RFAvailability interface {
	IsRunningForChannel(channel uint32) bool
}

RFAvailability reports whether an RF transport is currently reachable for the given channel. The modem subprocess is one such transport; a KISS-TNC backend (TCP server / TCP client / serial) is another. The wiring layer composes a single implementation that returns true when *any* registered backend for the channel is usable, so KISS-only channels (no audio device) can submit even though the modem subprocess is not running.

type RetryManager

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

RetryManager owns the single goroutine that wakes on timer expiry or explicit kick() calls to scan ListRetryDue and re-submit DM outbound via Sender.Send. Tactical rows never participate — they go terminal on first send (see Sender.onTxComplete).

func NewRetryManager

func NewRetryManager(cfg RetryManagerConfig) (*RetryManager, error)

NewRetryManager validates cfg and returns a ready manager. Lifecycle: call Start(ctx) once, Stop() to shut down.

func (*RetryManager) CancelRetry

func (r *RetryManager) CancelRetry(ctx context.Context, id uint64) error

CancelRetry clears NextRetryAt and removes the row from the in-flight map. Called by the REST soft-delete handler before store.SoftDelete so a concurrent retry loop does not race.

func (*RetryManager) Kick

func (r *RetryManager) Kick()

Kick wakes the retry goroutine so it re-scans ListRetryDue. Safe to call from any goroutine; non-blocking.

func (*RetryManager) Resend

func (r *RetryManager) Resend(ctx context.Context, id uint64) (SendResult, error)

Resend re-submits the row identified by id via Sender.Send. Used by the REST /resend handler. Resets the row's attempt counter and clears FailureReason/NextRetryAt; for tactical rows, submits once without re-enrolling in the retry ladder.

Returns SendResult so the handler can surface the outcome. An in-flight guard prevents the retry loop from double-submitting concurrently.

func (*RetryManager) Start

func (r *RetryManager) Start(ctx context.Context)

Start spins up the retry goroutine and bootstraps from ListAwaitingAckOnStartup — already-enrolled DM rows resume. Idempotent.

func (*RetryManager) Stop

func (r *RetryManager) Stop()

Stop cancels the goroutine and waits for it to exit. Idempotent.

type RetryManagerConfig

type RetryManagerConfig struct {
	Store       *Store
	Sender      *Sender
	Preferences *Preferences
	EventHub    *EventHub
	Logger      *slog.Logger
	Clock       SenderClock
	// Rand is the jitter source. If nil, a time-seeded *rand.Rand is
	// used. Tests inject a deterministic source.
	Rand *rand.Rand
}

RetryManagerConfig captures the retry loop's collaborators. All fields except Logger, Clock, and Rand are required.

type Router

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

Router is the inbound-message classification + auto-ACK pipeline. It implements aprs.PacketOutput. Start/Stop control the lifecycle of the consumer goroutine.

func NewRouter

func NewRouter(cfg RouterConfig) (*Router, error)

NewRouter constructs a Router from cfg. Returns an error if any required field is missing.

func (*Router) AutoAckChannel

func (r *Router) AutoAckChannel() uint32

AutoAckChannel returns the live RF channel ID used for auto-ACKs when the inbound was IS-sourced (RF-sourced packets reuse pkt.Channel). Reads are lock-free. Delegates to the shared Preflight.

func (*Router) Close

func (r *Router) Close() error

Close satisfies aprs.PacketOutput. Alias for Stop.

func (*Router) Preflight added in v0.13.0

func (r *Router) Preflight() *Preflight

Preflight returns the shared auto-ACK + dedup component the router is using.

func (*Router) SendPacket

func (r *Router) SendPacket(ctx context.Context, pkt *aprs.DecodedAPRSPacket) error

SendPacket enqueues pkt for classification. Non-blocking: if the internal queue is full, the oldest pending packet is dropped and a metric advances. Always returns nil so the APRS fan-out never stalls on the router.

func (*Router) SetAutoAckChannel

func (r *Router) SetAutoAckChannel(ch uint32)

SetAutoAckChannel updates the IS-fallback auto-ACK channel. Zero is ignored. Safe to call concurrently with the consumer goroutine.

func (*Router) Start

func (r *Router) Start(ctx context.Context)

Start spins up the consumer goroutine. Idempotent: a second call is a no-op.

func (*Router) Stop

func (r *Router) Stop()

Stop closes the queue and waits for the consumer goroutine to drain. Idempotent.

type RouterClock

type RouterClock interface {
	Now() time.Time
}

RouterClock abstracts time for deterministic tests.

type RouterConfig

type RouterConfig struct {
	Store       *Store
	TxSink      txgovernor.TxSink
	IGateSender IGateLineSender
	OurCall     func() string // returns our primary callsign (possibly with SSID)
	LocalTxRing *LocalTxRing
	TacticalSet *TacticalSet
	EventHub    *EventHub
	Logger      *slog.Logger
	Registerer  prometheus.Registerer
	Clock       RouterClock
	// AutoAckChannel is the RF channel used when submitting auto-ACKs.
	// Defaults to 1 (mirrors IGateConfig.TxChannel semantics). Forwarded
	// into Preflight when Preflight is nil.
	AutoAckChannel uint32
	// QueueCapacity overrides the internal packet-queue capacity. <= 0
	// uses DefaultRouterQueueCapacity.
	QueueCapacity int
	// DedupWindow overrides the (from_call, msg_id, text_hash) dedup
	// window forwarded into Preflight when Preflight is nil. <= 0 uses
	// DefaultRouterDedupWindow.
	DedupWindow time.Duration
	// Preflight is the shared auto-ACK + dedup component. When nil the
	// router constructs a private one from the OurCall/TxSink/etc fields
	// for backward compatibility with standalone callers (tests).
	Preflight *Preflight
}

RouterConfig captures the router's collaborators. All fields except Logger, Registerer, and Clock are required.

type SendMessageRequest

type SendMessageRequest struct {
	// To is the destination callsign (DM) or tactical label. Required.
	To string
	// Text is the message body (<=67 APRS chars). Required unless
	// Kind == MessageKindInvite, in which case the service builds the
	// wire body server-side from InviteTactical and the caller's Text
	// is ignored.
	Text string
	// OurCall is the source callsign — usually the operator's
	// primary callsign (possibly with SSID). Required.
	OurCall string
	// ThreadKind is optional; when empty, Service derives it from
	// the tactical set (exact match → tactical, else DM).
	ThreadKind string
	// Kind classifies the outbound row. Empty or "text" is a normal
	// DM/tactical message; "invite" triggers invite semantics below.
	Kind string
	// InviteTactical is the tactical callsign referenced by an
	// invite. Required and validated when Kind == "invite"; ignored
	// otherwise. Uppercase, 1-9 of [A-Z0-9-].
	InviteTactical string
	// FallbackPolicyOverride is a one-shot per-send transport policy
	// override. Empty means "use operator preference". Accepts the
	// FallbackPolicy* constants. Set by callers that need source-
	// aware routing (e.g. Actions replies that echo inbound RF/IS
	// transport). Applies only to the initial dispatch — retry-manager
	// re-attempts use the stored preference.
	FallbackPolicyOverride string
}

SendMessageRequest is the Go-level compose input. The Phase 4 REST handler decodes its DTO and calls Service.SendMessage(ctx, req).

type SendPath

type SendPath string

SendPath identifies which transport a SendResult describes.

const (
	SendPathRF   SendPath = "rf"
	SendPathIS   SendPath = "is"
	SendPathBoth SendPath = "both"
	SendPathNone SendPath = ""
)

type SendResult

type SendResult struct {
	Path      SendPath
	Err       error
	Retryable bool
}

SendResult describes the outcome of one Sender.Send call. Path is the transport that accepted (or failed) the outbound; Err is nil on success. Retryable is true when the caller (RetryManager) should re-arm the row for a later attempt rather than marking it failed.

type Sender

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

Sender is the outbound message pipeline. One instance per Service. Send() is stateless re-entrant — callers (the REST compose path and the retry manager) may call it from multiple goroutines.

func NewSender

func NewSender(cfg SenderConfig) (*Sender, error)

NewSender validates cfg and returns a ready Sender. Returns an error if any required field is missing.

func (*Sender) Send

func (s *Sender) Send(ctx context.Context, row *configstore.Message) SendResult

Send dispatches a single outbound attempt for row. The row must already be persisted with Direction="out", ThreadKind populated, MsgID allocated, and AckState=="none". Send updates the row (QueuedAt, Attempts, FailureReason) via store.Update on terminal paths; the retry manager owns the next-retry bookkeeping.

Returns a SendResult describing the outcome. RetryManager decides whether to re-arm based on Result.Retryable.

func (*Sender) SendWithPolicy added in v0.13.0

func (s *Sender) SendWithPolicy(ctx context.Context, row *configstore.Message, override string) SendResult

SendWithPolicy is Send with a one-shot fallback-policy override. Pass an empty string to defer to the operator's stored preference (identical to Send). Used by callers that need source-aware transport selection — e.g. the Actions reply path, which echoes inbound RF traffic back over RF and inbound IS traffic over IS.

Note: the override applies to this single dispatch only. Retry manager re-attempts use the stored preference, since by then the inbound transport context has been lost.

func (*Sender) SetTxChannel

func (s *Sender) SetTxChannel(ch uint32)

SetTxChannel updates the live TX channel. A zero value is ignored (matches NewSender's default-to-1 behaviour for unset configs). Safe to call concurrently with Send.

func (*Sender) TxChannel

func (s *Sender) TxChannel() uint32

TxChannel returns the live RF channel ID used for outbound submissions. Reads are lock-free and observe the most recent SetTxChannel mutation.

type SenderClock

type SenderClock interface {
	Now() time.Time
}

SenderClock abstracts time for deterministic tests. Mirrors RouterClock semantics so a single fakeClock drives all of messages.

type SenderConfig

type SenderConfig struct {
	Store       *Store
	TxSink      txgovernor.TxSink
	IGateSender IGateLineSender // may be nil when operator runs no igate
	Bridge      RFAvailability  // may be nil in tests
	LocalTxRing *LocalTxRing
	Preferences *Preferences
	EventHub    *EventHub
	Logger      *slog.Logger
	Clock       SenderClock
	// TxChannel is the RF channel used for outbound submissions.
	// Defaults to 1 (matches MessagesConfig.TxChannel semantics).
	TxChannel uint32
	// ChannelModes refuses outbound when the resolved TX channel mode
	// is "packet". Nil = treat every channel as APRS-permissive
	// (preserves the legacy any-channel-does-anything behavior).
	// Lookup errors are silently ignored (fail-open at TX time; the
	// operator's configured channel wins on transient DB issues).
	ChannelModes configstore.ChannelModeLookup
	// IGatePasscode is the APRS-IS passcode; "-1" indicates read-only
	// and disables IS transmits so the sender can short-circuit the
	// IS fallback when the operator hasn't provisioned credentials.
	// Empty string is treated the same as absent ("-1" implicit).
	IGatePasscode string
}

SenderConfig captures the sender's collaborators. All fields except Logger, Clock, Bridge, and IGate are required.

type Service

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

Service is the top-level messages component. It owns Router, Sender, RetryManager, Preferences, TacticalSet, LocalTxRing, and EventHub. Lifecycle: NewService → Start(ctx) → (use) → Stop().

Start registers the TxHook with the governor and loads initial preferences + tactical callsigns into the cached snapshots. Stop unregisters the TxHook and stops Router + RetryManager.

func NewService

func NewService(cfg ServiceConfig) (*Service, error)

NewService constructs a Service from cfg. Returns an error if any required field is missing. Does NOT start any goroutines — callers invoke Start(ctx) after wiring dependencies.

func (*Service) EventHub

func (s *Service) EventHub() *EventHub

EventHub returns the pub/sub hub so REST SSE handlers can subscribe.

func (*Service) LocalTxRing

func (s *Service) LocalTxRing() *LocalTxRing

LocalTxRing returns the self-filter ring so Phase 5 can inject it into the iGate gating filter via the LocalOriginRing interface.

func (*Service) MarkRead

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

MarkRead / MarkUnread proxy to the store for REST handlers.

func (*Service) MarkUnread

func (s *Service) MarkUnread(ctx context.Context, id uint64) error

func (*Service) Preferences

func (s *Service) Preferences() *Preferences

Preferences returns the cached preferences snapshot.

func (*Service) Preflight added in v0.13.0

func (s *Service) Preflight() *Preflight

Preflight returns the shared inbound preflight (auto-ACK + dedup), safe to share across subsystems that consume inbound APRS messages.

func (*Service) ReloadConfig

func (s *Service) ReloadConfig(ctx context.Context) error

ReloadConfig re-resolves the TX channel via the configured TxChannelResolver and pushes the new value into the live Sender + Router. A nil resolver, a zero return, or a value matching the current channel is a no-op. Logs a single info line when the value actually changes so operators can correlate iGate-config saves against the swap.

Called from the app's messagesReload drainer after every iGate config save. Concurrency: SetTxChannel / SetAutoAckChannel use atomics; safe to call while the Router consumer goroutine and the Sender's compose / retry callers are running.

func (*Service) ReloadPreferences

func (s *Service) ReloadPreferences(ctx context.Context) error

ReloadPreferences refetches the MessagePreferences singleton and replaces the cached snapshot. Called by the Phase 4 messagesReload channel consumer.

func (*Service) ReloadTacticalCallsigns

func (s *Service) ReloadTacticalCallsigns(ctx context.Context) error

ReloadTacticalCallsigns refetches the enabled tactical callsign set and swaps it into the router's cache. Called by the Phase 4 messagesReload channel consumer after a tactical CRUD mutation.

func (*Service) Resend

func (s *Service) Resend(ctx context.Context, id uint64) (SendResult, error)

Resend is the REST /resend entry point. Thin wrapper around RetryManager.Resend so handlers can stay agnostic of the retry plumbing.

func (*Service) RetryManager

func (s *Service) RetryManager() *RetryManager

RetryManager returns the retry scheduler for tests and the REST /resend handler.

func (*Service) Router

func (s *Service) Router() *Router

Router returns the inbound-classification router so Phase 5 wiring can append it to the APRS fan-out outputs slice.

func (*Service) SendMessage

func (s *Service) SendMessage(ctx context.Context, req SendMessageRequest) (*configstore.Message, error)

SendMessage persists the outbound row via store.Insert (allocating a msgid for DM) and dispatches it via Sender.Send. Returns the persisted row with its assigned ID so the REST handler can return 202 + echo the row.

The REST handler is responsible for 67-char validation and addressee regex validation; Service validates only the minimum required to persist a sensible row.

func (*Service) Sender

func (s *Service) Sender() *Sender

Sender returns the outbound sender. REST compose handlers call Sender.Send via SendMessage / Resend wrappers exposed directly on Service.

func (*Service) SoftDelete

func (s *Service) SoftDelete(ctx context.Context, id uint64) error

SoftDelete is the REST DELETE entry point. Cancels any pending retry then calls store.SoftDelete. Emits a message.deleted event so SSE subscribers can prune their UI.

func (*Service) SoftDeleteThread

func (s *Service) SoftDeleteThread(ctx context.Context, kind, key string) (int, error)

SoftDeleteThread soft-deletes every message belonging to (kind, key). Cancels each row's pending retry and emits one EventMessageDeleted per deleted row so SSE subscribers can prune the UI without a full rollup refetch. Returns the number of rows deleted (zero is not an error — empty thread is a no-op).

func (*Service) Start

func (s *Service) Start(ctx context.Context) error

Start wires runtime dependencies:

  1. Loads preferences + tactical callsigns into cached snapshots.
  2. Registers the sender's TxHook with the governor.
  3. Starts the Router and RetryManager goroutines.

Idempotent: a second Start call is a no-op.

func (*Service) Stop

func (s *Service) Stop()

Stop unregisters the TxHook and stops the Router + RetryManager. Idempotent.

func (*Service) TacticalSet

func (s *Service) TacticalSet() *TacticalSet

TacticalSet returns the live tactical-set snapshot (read-only). Tests use it to observe reload results.

type ServiceConfig

type ServiceConfig struct {
	Store        *Store
	ConfigStore  ServiceConfigReader
	TxSink       txgovernor.TxSink
	TxHookReg    txgovernor.TxHookRegistry
	IGate        IGateLineSender           // may be nil (no iGate configured)
	Bridge       RFAvailability            // may be nil in tests (alwaysRF)
	StationCache stationcache.StationStore // optional — Phase 4 autocomplete
	Logger       *slog.Logger
	Clock        SenderClock
	// TxChannel is the RF channel used for outbound messages.
	// Defaults to 1 when zero.
	TxChannel uint32
	// TxChannelResolver, if non-nil, is invoked by ReloadConfig to
	// fetch the live TX channel ID. The resolver should return zero
	// when no usable channel is available; ReloadConfig then leaves
	// the current value unchanged. Used by the app-level reload path
	// to push iGate-config changes (TxChannel field) into the running
	// Sender + Router without a daemon restart.
	TxChannelResolver func(context.Context) uint32
	// IGatePasscode lets the sender short-circuit the IS fallback
	// when the operator runs a read-only iGate ("-1").
	IGatePasscode string
	// OurCall returns the operator's primary callsign (possibly with
	// SSID). The router uses it for self-filter and auto-ACK; the
	// sender doesn't read it directly — rows carry FromCall.
	OurCall func() string
	// AutoAckChannel overrides the router's auto-ACK channel. Zero
	// falls back to TxChannel.
	AutoAckChannel uint32
	// ChannelModes is forwarded to the Sender to refuse outbound when
	// the TX channel is packet-mode. Nil disables the check (legacy
	// any-channel-does-anything behavior).
	ChannelModes configstore.ChannelModeLookup
	// LocalTxRing / TacticalSet / EventHub can be injected for tests
	// that need to observe them; if nil the Service constructs its
	// own defaults.
	LocalTxRing *LocalTxRing
	TacticalSet *TacticalSet
	EventHub    *EventHub
}

ServiceConfig wires the Service constructor.

type ServiceConfigReader

type ServiceConfigReader interface {
	MessagePreferencesReader
	ListEnabledTacticalCallsigns(ctx context.Context) ([]configstore.TacticalCallsign, error)
}

ServiceConfigReader is the narrow read/write surface the Service needs from *configstore.Store. Kept as an interface so tests can inject a fake.

type Store

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

Store is the message repository. Thin wrapper around *gorm.DB so the production path (configstore.Store.DB()) and test path (an in-memory configstore.OpenMemory()) share the same code.

func NewStore

func NewStore(db *gorm.DB) *Store

NewStore constructs a repository over the given GORM handle. Callers pass configstore.Store.DB(); tests pass an in-memory DB that already ran configstore.Migrate() so the messages table exists.

func (*Store) AllocateMsgID

func (s *Store) AllocateMsgID(ctx context.Context, peerCall string) (string, error)

AllocateMsgID returns the next 3-digit decimal msgid ("001".."999") for a DM outbound to peerCall. Runs in a transaction: reads the counter, finds an unused slot (skipping values currently held by outstanding outbound DM rows to this peer), writes the counter back, and returns the chosen id. Wraps 999→1.

Returns ErrMsgIDExhausted when all 999 slots for the peer are held by outstanding rows. Caller treats this as back-pressure.

Transaction scope is tight: one SELECT + one UPDATE + one in-memory set lookup. The skip predicate is `ack_state='none' AND direction='out' AND thread_kind='dm' AND peer_call = ? AND deleted_at IS NULL`, so a row that transitioned to broadcast/acked/rejected frees its slot.

func (*Store) ClearFailureReason

func (s *Store) ClearFailureReason(ctx context.Context, id uint64) error

ClearFailureReason writes an empty failure_reason column on the row via a field-selective update — NOT a whole-row Save. Used by the sender after a successful governor submit to clear any stale reason from a prior attempt. Whole-row Save would race with concurrent writes to the same row (e.g. the TxHook setting SentAt), clobbering fields that happen not to be set on the sender's in-memory copy. The field-selective update touches only failure_reason.

func (*Store) ConversationRollup

func (s *Store) ConversationRollup(ctx context.Context, limit int) ([]ConversationSummary, error)

ConversationRollup returns one summary per thread, ordered by LastAt descending (most recent on top). Soft-deleted rows are excluded; tactical participant counts are computed via a correlated subquery on (thread_key, peer_call) over non-deleted rows.

The "exclude archived tactical threads from unread_count" defense- in-depth hook is not implemented at the store layer in Phase 1 because tactical entries do not yet carry an archived flag; Phase 4 can subtract a follow-up count in application code if the field lands. For now ArchivedCount returns to 0 and the UnreadCount is unconditional — callers see the same value they would have before.

func (*Store) FindOutstandingByMsgID

func (s *Store) FindOutstandingByMsgID(ctx context.Context, msgID, peerCall string) ([]configstore.Message, error)

FindOutstandingByMsgID returns every outbound row matching (msg_id, peer_call) regardless of soft-delete status. Used for ack correlation: a late ack may arrive after the operator deleted their outbound, and we still want to set AckedAt for audit even though the row remains DeletedAt != NULL.

func (*Store) GetByID

func (s *Store) GetByID(ctx context.Context, id uint64) (*configstore.Message, error)

GetByID returns a single message by primary key. Returns gorm.ErrRecordNotFound wrapped when absent so callers can use errors.Is.

func (*Store) Insert

func (s *Store) Insert(ctx context.Context, m *configstore.Message) error

Insert persists a new row. The caller sets Direction, ThreadKind, FromCall/ToCall/OurCall, Text, MsgID, Source, Path, etc.; Insert fills the derived columns (PeerCall, ThreadKey) based on the tuple:

  • ThreadKind == "dm": PeerCall/ThreadKey = FromCall for inbound, ToCall for outbound.
  • ThreadKind == "tactical": ThreadKey = ToCall (outbound) or the addressee already set on ToCall (inbound — router normalizes). PeerCall = FromCall (the actual human sender in both directions; equals OurCall for outbound).

CreatedAt defaults to time.Now() when zero so callers don't need to stamp it. Callers that want deterministic timestamps (tests) set it explicitly.

func (*Store) List

func (s *Store) List(ctx context.Context, f Filter) ([]configstore.Message, string, error)

List returns a page of messages matching the filter. The returned cursor points at the last row in the page and can be fed back into a subsequent List call to page forward deterministically.

Ordering: (UpdatedAt ASC, ID ASC). UpdatedAt is populated by GORM on every Create/Save and also advances when the row is touched by ack correlation or retry bookkeeping, so a polling client seeing an updated row after the initial sync will find it past its cursor.

func (*Store) ListAwaitingAckOnStartup

func (s *Store) ListAwaitingAckOnStartup(ctx context.Context) ([]configstore.Message, error)

ListAwaitingAckOnStartup returns every DM outbound row still awaiting ack, time-bound excluded. The retry manager calls this on startup to re-arm its timer from the persisted state.

func (*Store) ListParticipants

func (s *Store) ListParticipants(ctx context.Context, tacticalKey string, within time.Duration) ([]Participant, time.Duration, error)

ListParticipants returns distinct senders on a tactical thread within the requested window. The effective window is clamped by MessagePreferences.RetentionDays (when non-zero) so a 7-day request against a 3-day retention yields a 3-day response — surfaces the clamped value as the second return so the UI can caption it honestly. When retention is 0 (forever) effective = requested.

Sort: last_active DESC so the most recently heard stations come first. OurCall is excluded — the participant chip row is about other stations.

func (*Store) ListRetryDue

func (s *Store) ListRetryDue(ctx context.Context, now time.Time) ([]configstore.Message, error)

ListRetryDue returns DM outbound rows whose NextRetryAt is in the past and that are still awaiting ack. Tactical rows never populate NextRetryAt, so the thread_kind filter is belt-and-suspenders.

func (*Store) MarkRead

func (s *Store) MarkRead(ctx context.Context, id uint64) error

MarkRead clears Unread on the row.

func (*Store) MarkUnread

func (s *Store) MarkUnread(ctx context.Context, id uint64) error

MarkUnread sets Unread on the row.

func (*Store) QueryMessageHistoryByPeer

func (s *Store) QueryMessageHistoryByPeer(ctx context.Context, prefix string, limit int) ([]MessageHistoryEntry, error)

QueryMessageHistoryByPeer returns distinct peer callsigns whose names begin (case-insensitive) with prefix, paired with the most recent CreatedAt time we exchanged any message with them. Results are ordered newest-first. An empty prefix lists every recent peer up to limit. limit <= 0 applies DefaultListLimit.

Used by the stations autocomplete endpoint to seed the "seen before" suggestions even when the station cache has evicted the peer.

func (*Store) SoftDelete

func (s *Store) SoftDelete(ctx context.Context, id uint64) error

SoftDelete sets DeletedAt on the row. Retry code clears NextRetryAt and removes the row from its in-flight map when a soft-delete races with a pending retry; late acks may still flip AckState on the tombstoned row (correlation queries use .Unscoped()).

func (*Store) SoftDeleteByThread

func (s *Store) SoftDeleteByThread(ctx context.Context, kind, key string) ([]uint64, error)

SoftDeleteByThread soft-deletes every non-deleted message belonging to the given (kind, key) pair and returns the IDs of the rows that were tombstoned. Caller is responsible for cancelling per-row retries and emitting deletion events. Empty kind or key is a no-op.

The ID list and the UPDATE happen in a single transaction so a concurrent insert into the same thread doesn't get hidden behind the snapshot — either it lands before the SELECT (and gets deleted) or after (and stays alive).

func (*Store) Update

func (s *Store) Update(ctx context.Context, m *configstore.Message) error

Update saves changes to an existing row. Callers typically Update after flipping AckState/AckedAt/Attempts/NextRetryAt/SentAt; the repository does not derive thread identity on update (Insert owns that).

func (*Store) UpdateRetrySchedule

func (s *Store) UpdateRetrySchedule(ctx context.Context, id uint64, attempts int, nextRetryAt *time.Time) error

UpdateRetrySchedule writes only attempts + next_retry_at via a field-selective update. Pass nextRetryAt=nil to clear the schedule (e.g. on soft-delete or ack). Avoids the whole-row Save race with the TxHook path — see UpdateSentAtAndAckState.

func (*Store) UpdateSentAtAndAckState

func (s *Store) UpdateSentAtAndAckState(ctx context.Context, id uint64, sentAt time.Time, ackState string) error

UpdateSentAtAndAckState writes only sent_at (and optionally ack_state) via a field-selective update. Used by the TxHook body so the post-TX timestamp flip doesn't race with scheduleNext's next_retry_at write — each side touches disjoint columns. Pass ackState="" to leave the column unchanged (DM happy path).

type TacticalSet

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

TacticalSet is a lock-free read-side cache of the enabled tactical callsigns. The router hits Contains() on every inbound message packet; preferences reloads swap in a new snapshot via Store().

The snapshot is kept behind atomic.Pointer[map[...]] so readers do not contend with writers. Empty state is represented as a non-nil empty map so Contains() never observes nil.

func NewTacticalSet

func NewTacticalSet() *TacticalSet

NewTacticalSet constructs an empty set. Seed via Store to populate.

func (*TacticalSet) Contains

func (s *TacticalSet) Contains(key string) bool

Contains reports whether key (case-insensitively) is a member of the current snapshot.

func (*TacticalSet) Load

func (s *TacticalSet) Load() map[string]struct{}

Load returns the current snapshot. The returned map is immutable from the caller's perspective (the TacticalSet may replace it at any time, but the returned reference keeps pointing at the old snapshot for the duration of the caller's use).

func (*TacticalSet) Store

func (s *TacticalSet) Store(newSet map[string]struct{})

Store atomically replaces the active set. The caller keeps ownership of newSet only until the call returns; after Store, the TacticalSet owns the map and callers must not mutate it.

A nil newSet is treated as "empty" — the set never observes nil.

Jump to

Keyboard shortcuts

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