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
- Variables
- func IsWellKnownBot(callsign string) bool
- func NormalizeFallbackPolicy(p string) string
- func ParseInvite(text string) (tactical string, ok bool)
- type AddresseeMatch
- type BotAddress
- type BotDirectory
- type ConversationSummary
- type Event
- type EventHub
- type Filter
- type IGateLineSender
- type LocalTxRing
- type MessageHistoryEntry
- type MessagePreferencesReader
- type Participant
- type Preferences
- type Preflight
- func (p *Preflight) AutoAckChannel() uint32
- func (p *Preflight) AutoAcksSent() prometheus.Counter
- func (p *Preflight) CheckDedup(fromCall, msgID, text string) bool
- func (p *Preflight) DedupHits() prometheus.Counter
- func (p *Preflight) SendAutoAck(ctx context.Context, pkt *aprs.DecodedAPRSPacket, peerCall, msgID string)
- func (p *Preflight) SetAutoAckChannel(ch uint32)
- type PreflightConfig
- type RFAvailability
- type RetryManager
- type RetryManagerConfig
- type Router
- func (r *Router) AutoAckChannel() uint32
- func (r *Router) Close() error
- func (r *Router) Preflight() *Preflight
- func (r *Router) SendPacket(ctx context.Context, pkt *aprs.DecodedAPRSPacket) error
- func (r *Router) SetAutoAckChannel(ch uint32)
- func (r *Router) Start(ctx context.Context)
- func (r *Router) Stop()
- type RouterClock
- type RouterConfig
- type SendMessageRequest
- type SendPath
- type SendResult
- type Sender
- type SenderClock
- type SenderConfig
- type Service
- func (s *Service) EventHub() *EventHub
- func (s *Service) LocalTxRing() *LocalTxRing
- func (s *Service) MarkRead(ctx context.Context, id uint64) error
- func (s *Service) MarkUnread(ctx context.Context, id uint64) error
- func (s *Service) Preferences() *Preferences
- func (s *Service) Preflight() *Preflight
- func (s *Service) ReloadConfig(ctx context.Context) error
- func (s *Service) ReloadPreferences(ctx context.Context) error
- func (s *Service) ReloadTacticalCallsigns(ctx context.Context) error
- func (s *Service) Resend(ctx context.Context, id uint64) (SendResult, error)
- func (s *Service) RetryManager() *RetryManager
- func (s *Service) Router() *Router
- func (s *Service) SendMessage(ctx context.Context, req SendMessageRequest) (*configstore.Message, error)
- func (s *Service) Sender() *Sender
- func (s *Service) SoftDelete(ctx context.Context, id uint64) error
- func (s *Service) SoftDeleteThread(ctx context.Context, kind, key string) (int, error)
- func (s *Service) Start(ctx context.Context) error
- func (s *Service) Stop()
- func (s *Service) TacticalSet() *TacticalSet
- type ServiceConfig
- type ServiceConfigReader
- type Store
- func (s *Store) AllocateMsgID(ctx context.Context, peerCall string) (string, error)
- func (s *Store) ClearFailureReason(ctx context.Context, id uint64) error
- func (s *Store) ConversationRollup(ctx context.Context, limit int) ([]ConversationSummary, error)
- func (s *Store) FindOutstandingByMsgID(ctx context.Context, msgID, peerCall string) ([]configstore.Message, error)
- func (s *Store) GetByID(ctx context.Context, id uint64) (*configstore.Message, error)
- func (s *Store) Insert(ctx context.Context, m *configstore.Message) error
- func (s *Store) List(ctx context.Context, f Filter) ([]configstore.Message, string, error)
- func (s *Store) ListAwaitingAckOnStartup(ctx context.Context) ([]configstore.Message, error)
- func (s *Store) ListParticipants(ctx context.Context, tacticalKey string, within time.Duration) ([]Participant, time.Duration, error)
- func (s *Store) ListRetryDue(ctx context.Context, now time.Time) ([]configstore.Message, error)
- func (s *Store) MarkRead(ctx context.Context, id uint64) error
- func (s *Store) MarkUnread(ctx context.Context, id uint64) error
- func (s *Store) QueryMessageHistoryByPeer(ctx context.Context, prefix string, limit int) ([]MessageHistoryEntry, error)
- func (s *Store) SoftDelete(ctx context.Context, id uint64) error
- func (s *Store) SoftDeleteByThread(ctx context.Context, kind, key string) ([]uint64, error)
- func (s *Store) Update(ctx context.Context, m *configstore.Message) error
- func (s *Store) UpdateRetrySchedule(ctx context.Context, id uint64, attempts int, nextRetryAt *time.Time) error
- func (s *Store) UpdateSentAtAndAckState(ctx context.Context, id uint64, sentAt time.Time, ackState string) error
- type TacticalSet
Constants ¶
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).
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.
const ( FallbackPolicyRFOnly = "rf_only" FallbackPolicyISFallback = "is_fallback" FallbackPolicyISOnly = "is_only" FallbackPolicyBoth = "both" )
Fallback policy wire values — mirror configstore's column semantics.
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.
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.
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.
const ( AckStateNone = "none" AckStateAcked = "acked" AckStateRejected = "rejected" AckStateBroadcast = "broadcast" )
AckState wire values.
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.
const ( FolderAll = "all" FolderInbox = "inbox" FolderSent = "sent" )
Folder discriminator for List.
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.
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.
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.
const DefaultSubscriberBuffer = 32
DefaultSubscriberBuffer controls per-subscriber buffering.
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.
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 ¶
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.
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.
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.
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.
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 ¶
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 ¶
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 ¶
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
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 ¶
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 ¶
NewEventHub constructs an empty hub. Pass bufSize <= 0 to use the default.
func (*EventHub) EventsDropped ¶
EventsDropped returns the cumulative number of events that could not be delivered because a subscriber's buffer was full.
func (*EventHub) Publish ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
func (p *Preferences) Current() *configstore.MessagePreferences
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 ¶
func (p *Preferences) Load(ctx context.Context) (*configstore.MessagePreferences, error)
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
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
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
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 ¶
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 ¶
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) Preflight ¶ added in v0.13.0
Preflight returns the shared auto-ACK + dedup component the router is using.
func (*Router) SendPacket ¶
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 ¶
SetAutoAckChannel updates the IS-fallback auto-ACK channel. Zero is ignored. Safe to call concurrently with the consumer goroutine.
type RouterClock ¶
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 SendResult ¶
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 ¶
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.
type SenderClock ¶
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) 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) Preferences ¶
func (s *Service) Preferences() *Preferences
Preferences returns the cached preferences snapshot.
func (*Service) Preflight ¶ added in v0.13.0
Preflight returns the shared inbound preflight (auto-ACK + dedup), safe to share across subsystems that consume inbound APRS messages.
func (*Service) ReloadConfig ¶
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 ¶
ReloadPreferences refetches the MessagePreferences singleton and replaces the cached snapshot. Called by the Phase 4 messagesReload channel consumer.
func (*Service) ReloadTacticalCallsigns ¶
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 ¶
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 ¶
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 ¶
Sender returns the outbound sender. REST compose handlers call Sender.Send via SendMessage / Resend wrappers exposed directly on Service.
func (*Service) SoftDelete ¶
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 ¶
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 ¶
Start wires runtime dependencies:
- Loads preferences + tactical callsigns into cached snapshots.
- Registers the sender's TxHook with the governor.
- 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
GetByID returns a single message by primary key. Returns gorm.ErrRecordNotFound wrapped when absent so callers can use errors.Is.
func (*Store) Insert ¶
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 ¶
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 ¶
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 ¶
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) MarkUnread ¶
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 ¶
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 ¶
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 ¶
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.