Documentation
¶
Overview ¶
Package messaging provides rabbitMQ integration, outbox/inbox patterns, and background workers for reliable asynchronous communication between microservices.
Index ¶
- Constants
- Variables
- func OutboxDBRetryConfig(platformMode constants.PlatformMode) *retry.Config
- func WithOutboxDBLockRetry(ctx context.Context, cfg *retry.Config, operation string, fn func() error) error
- type AgentChatRunData
- type AgentContinueRunData
- type AgentExecuteActionData
- type AgentExecuteRunData
- type AgentReplyData
- type AgentReplyPatchData
- type AgentRunCompletedData
- type AgentRunStepData
- type AlertFanoutData
- type BulkOperation
- type ChatHistoryMessage
- type CleanupConfig
- type CleanupRepo
- type CleanupWorker
- type ConsumeOption
- type ConsumeOptions
- type ConversationRef
- type CustomerRegisteredData
- type EmailLogData
- type EmailSendData
- type Enqueuer
- type EnqueuerConfig
- type ExportOperation
- type FailureMonitor
- type FailureMonitorConfig
- type FailureMonitorRepo
- type GenerateProductionScheduleData
- type HubspotSyncCommandData
- type InboxCheckResult
- type InboxConsumer
- type InboxFailure
- type InboxPurger
- type InboxPurgerConfig
- type InboxPurgerRepo
- type InboxRecord
- type InboxRecordInput
- type InboxRepo
- type InboxStatus
- type InvoiceCreatedReportData
- type MessageBroker
- type MessageHandler
- type OutboxEnqueuerRepo
- type OutboxFailure
- type OutboxMessage
- type OutboxMessageInput
- type OutboxNotifier
- type OutboxRepo
- type OutboxStatus
- type RabbitMQConfig
- type RealtimeDeliveryData
- type SalesOrderCreatedData
- type SalesOrderShippingUpdatedData
- type SeatChangeReportData
- type SeatSyncData
Examples ¶
Constants ¶
const ( // NotificationCmdSendEmailQueue carries send-email commands to the notification-service. Messages on this queue contain an EmailSendData payload and trigger an outbound email via SES. NotificationCmdSendEmailQueue = "notification_cmd_send_email" // NotifyEmailStatusQueue carries email delivery status updates (bounces, complaints, delivery confirmations) back from SES via SNS webhook. The notification-service consumes these to update email delivery records. NotifyEmailStatusQueue = "notify_email_status" // NotificationEventEmailLogQueue carries email-logged events emitted by the notification-service after successfully sending an email. Downstream consumers use these to maintain email audit records. NotificationEventEmailLogQueue = "notification_event_email_log" // LoggingEventRequestLogQueue carries request-log events for centralized logging. LoggingEventRequestLogQueue = "logging_event_request_log" // PlatformEventAuditLogQueue carries audit events produced by application services and needs to be persisted by platform-service. PlatformEventAuditLogQueue = "platform_event_audit_log" // CoreCmdPurgeAccountDataQueue carries purge-account-data commands to the core-service. Messages on this queue trigger deletion of all account-scoped data across ~50 tables for a deleted sandbox account. CoreCmdPurgeAccountDataQueue = "core_cmd_purge_account_data" // CoreCmdSeedSandboxQueue carries seed-sandbox commands to the core-service. Messages on this queue trigger population of a sandbox account with tutorial seed data. CoreCmdSeedSandboxQueue = "core_cmd_seed_sandbox" // CoreCmdExecuteProductionStepQueue carries execute-production-step commands to the core-service. Messages on this queue trigger inventory updates and reservation management after batch mutations (initialize, move, merge, split). // // Superseded by CoreEventBatchScannedInventoryQueue. Kept so commands already enqueued when the // switch happens still drain; delete once it has been empty across a deploy. CoreCmdExecuteProductionStepQueue = "core_cmd_execute_production_step" // CoreCmdRecalcItemBurnRateQueue carries recalc-item-burn-rate commands to the core-service consumer that recomputes an item's burn rate from history in its own short transaction, off the long consumption transaction that would otherwise hold the shared rate row's lock. CoreCmdRecalcItemBurnRateQueue = "core_cmd_recalc_item_burn_rate" // CoreCmdAllocateOpenIssuesQueue carries allocate-open-issues commands to the core-service consumer that allocates an item's open demand against available receipts one bounded page at a time, off the scan transaction. CoreCmdAllocateOpenIssuesQueue = "core_cmd_allocate_open_issues" // CoreEventInventoryReceivedAllocationQueue carries inventory-received events to the core-service consumer that offers the new stock to whatever demand went short waiting for it. CoreEventInventoryReceivedAllocationQueue = "core_event_inventory_received_allocation" // CoreEventItemCostBasisChangedQueue carries cost-basis-changed events to the core-service consumer that recomputes the cost of every item downstream of the change. CoreEventItemCostBasisChangedQueue = "core_event_item_cost_basis_changed_costing" // CoreEventBatchScannedInventoryQueue carries batch-scanned events to the core-service consumer that moves inventory: the produced receipt, the reservations seconds and waste release, and the material consumption. // // The queue is named for what its consumer does, not for the event, because a topic exchange gives every bound queue its own copy while consumers sharing a queue compete for messages. A second reaction to the same scan — crediting schedule attainment, say — binds CoreEventBatchScanned on a queue of its own and both run. CoreEventBatchScannedInventoryQueue = "core_event_batch_scanned_inventory" // carries undo-batch-scan commands to the core-service, reversing the receipts, issues, and reservations a scan recorded against a deleted batch. CoreCmdUndoBatchScanQueue = "core_cmd_undo_batch_scan" // CoreCmdSyncStripeCustomerQueue carries sync-stripe-customer commands to the core-service, which creates or updates the customer's counterpart in the account's connected Stripe integration. Messages contain a SyncStripeCustomerEvent payload. CoreCmdSyncStripeCustomerQueue = "core_cmd_sync_stripe_customer" // carries a request to solve and persist one production schedule version. The cadence tick only enqueues onto this queue: a solve takes minutes on a real tenant, and doing it inside the scheduler lease would block every other account behind whichever one is currently solving. CoreCmdGenerateProductionScheduleQueue = "core_cmd_generate_production_schedule" // carries sales-order-created events back to the core-service for out-of-band processing (e.g. CRM sync). Messages on this queue contain a SalesOrderCreatedData payload. CoreEventSalesOrderCreatedQueue = "core_event_sales_order_created" // CoreEventSalesOrderShippingUpdatedQueue carries sales-order shipping-changed events back to the core-service, which re-syncs the order's shipment records' carrier / service level / ship-to. Messages contain a SalesOrderShippingUpdatedData payload. CoreEventSalesOrderShippingUpdatedQueue = "core_event_sales_order_shipping_updated" // CoreEventCustomerRegisteredQueue carries customer-registered events to the notification-service, which notifies the seller's customer-service support-route group. Messages contain a CustomerRegisteredData payload. CoreEventCustomerRegisteredQueue = "core_event_customer_registered" // CoreCmdHubspotSyncQueue carries HubSpot backfill commands (preview and execute) to the core-service, which runs the long-running matching/sync passes out-of-band. Bound to both the preview and execute routing keys; the consumer dispatches on routing key. Messages contain a HubspotSyncCommandData payload. CoreCmdHubspotSyncQueue = "core_cmd_hubspot_sync" // BillingEventStripeWebhookQueue carries verified Stripe webhook events for asynchronous processing by the billing-service. The raw event payload and metadata are enqueued immediately on receipt so the webhook endpoint can return as fast as possible. BillingEventStripeWebhookQueue = "billing_event_stripe_webhook" // AgentCmdExecuteRunQueue carries execute-run commands to the agent-service. Messages trigger an agent run for a specific account and agent configuration. AgentCmdExecuteRunQueue = "agent_cmd_execute_run" // AgentCmdExecuteActionQueue carries execute-action commands to the agent-service. Messages trigger execution of a proposed agent action after optional human review. AgentCmdExecuteActionQueue = "agent_cmd_execute_action" // AgentCmdContinueRunQueue carries continue-run commands to the agent-service. Messages trigger continuation of an agent run that is awaiting user input. AgentCmdContinueRunQueue = "agent_cmd_continue_run" // AgentCmdChatRunQueue carries chat-run commands (from notification-service) to the agent-service: create a chat-linked run and execute it. AgentCmdChatRunQueue = "agent_cmd_chat_run" // AgentEventRunCompletedQueue carries run-completed events emitted by the agent-service after an agent run finishes. It is the durable, shared work queue for billing-service token/usage aggregation (exactly-once across billing replicas). The api-gateway also consumes run-completed events for WebSocket fan-out, but via its own per-instance queue (ConsumeFanout with this base name) so every gateway replica gets a copy — it must not join this shared queue, or billing and the gateway would steal each other's events. AgentEventRunCompletedQueue = "agent_event_run_completed" // AgentEventRunStepQueue is the base name for the queue that carries individual run step events for real-time WebSocket streaming. Each API gateway instance appends a unique suffix to create its own exclusive auto-delete queue so that every instance receives every event via RabbitMQ fanout. AgentEventRunStepQueue = "agent_event_run_step" // BillingCmdSyncSeatsQueue carries sync-seats commands to the billing-service. Messages on this queue trigger a seat count reconciliation with Stripe. BillingCmdSyncSeatsQueue = "billing_cmd_sync_seats" // BillingCmdReportSeatChangeQueue carries report-seat-change commands to the billing-service. Messages on this queue trigger a usage meter report to Stripe. BillingCmdReportSeatChangeQueue = "billing_cmd_report_seat_change" // Carries report-invoice-created commands to the billing-service; each triggers a usage meter report to Stripe. BillingCmdReportInvoiceCreatedQueue = "billing_cmd_report_invoice_created" // NotificationCmdFanoutQueue carries alert/message fan-out intents to notification-service. It is inbox-deduped and bound to NotificationCmdFanout (and NotificationCmdSendMessage). NotificationCmdFanoutQueue = "notification_cmd_fanout" // NotificationCmdAgentReplyQueue carries an agent's chat reply (from agent-service) to notification-service. Inbox-deduped and bound to NotificationCmdAgentReply. NotificationCmdAgentReplyQueue = "notification_cmd_agent_reply" // NotificationCmdAgentReplyPatchQueue carries best-effort partial-body updates for an in-flight streaming agent reply. Not inbox-deduped (patches are idempotent last-write-wins) and bound to NotificationCmdAgentReplyPatch. NotificationCmdAgentReplyPatchQueue = "notification_cmd_agent_reply_patch" // NotificationEventDeliveredQueue is the base name for the realtime push queue consumed by api-gateway. Like AgentEventRunStepQueue, each gateway instance appends a unique suffix to create its own exclusive auto-delete queue (bound to NotificationEventDelivered and NotificationEventConversationUpdated) so every instance receives every event via RabbitMQ fanout. NotificationEventDeliveredQueue = "notification_event_delivered" // DeadLetterQueue is the catch-all queue for messages that could not be processed after exhausting retries. It is bound to the dead-letter exchange ("dlx") so rejected or expired messages from any queue land here for manual inspection. DeadLetterQueue = "dead_letter_queue" )
Queue name constants define the AMQP queue names used across all services. Each queue is bound to the application exchange ("app") with a routing key matching its name. The naming convention is "{service}_{cmd|event}_{action}" to make ownership and intent clear at a glance.
Command queues ("cmd") carry instructions that should trigger side effects (e.g. sending an email). Event queues ("event") carry facts about things that already happened (e.g. an email was sent).
const (
// ApplicationExchange is the primary topic exchange that all services publish to and consume from. Messages are routed by their routing key (message type) to the appropriate queue bindings.
ApplicationExchange = "app"
)
Variables ¶
var BulkOperations = []BulkOperation{ BulkCreateProductionRuns, BulkUpsertProductionSteps, BulkUpsertUnits, BulkUpsertUnitGroups, BulkUpsertLocations, BulkUpsertDepartments, BulkUpsertMachines, BulkUpsertProductLines, BulkUpsertScanningStations, BulkUpsertItemCategories, BulkUpsertParts, BulkUpsertProducts, BulkUpsertMaterials, BulkUpsertProperties, BulkResolveHubspotCompanyReviews, PackPick, }
lists every registered bulk operation; the rabbitmq bindings declare a queue per entry and the core-service wiring pairs each with its executor
var ExportOperations = []ExportOperation{ ExportUnits, ExportUnitGroups, ExportProductLines, ExportItemCategories, ExportDepartments, ExportLocations, ExportMachines, ExportScanningStations, ExportProductionRuns, ExportProductionSteps, ExportParts, ExportProducts, ExportMaterials, ExportProperties, ExportHubspotCompanyReviews, ExportPriceList, }
lists every registered export; the rabbitmq bindings declare a queue per entry and the core-service wiring pairs each with the resource it renders
Functions ¶
func OutboxDBRetryConfig ¶
func OutboxDBRetryConfig(platformMode constants.PlatformMode) *retry.Config
OutboxDBRetryConfig returns the short retry policy used for small outbox database operations that can safely be re-attempted after a lock conflict.
func WithOutboxDBLockRetry ¶
func WithOutboxDBLockRetry(ctx context.Context, cfg *retry.Config, operation string, fn func() error) error
WithOutboxDBLockRetry retries operation only for transient database lock conflicts. Callers should use it for small outbox operations whose retry is idempotent, not for broader business transactions.
Types ¶
type AgentChatRunData ¶
type AgentChatRunData struct {
AccountID string `json:"account_id"`
AgentDefinitionID string `json:"agent_definition_id"`
ConversationID string `json:"conversation_id"`
TriggerMessageID string `json:"trigger_message_id"`
Message string `json:"message"`
// History is the recent thread context preceding the trigger, oldest-first, so the agent can follow the conversation instead of seeing only the triggering message. Role is from this agent's perspective: its own past replies are "assistant", everyone else is "user". Only set when starting a new run — a continued run already carries its own history.
History []ChatHistoryMessage `json:"history,omitempty"`
// ContinueRunID, when set, is the run to continue (the user replied directly to that run's message) instead of starting a new run. Empty means start a fresh run.
ContinueRunID string `json:"continue_run_id,omitempty"`
}
AgentChatRunData is the payload for AgentCmdChatRunQueue: start an agent run from a chat message. agent-service creates a chat-linked run (conversation_id + trigger_message_id, trigger_type=chat, input=Message) for the agent definition and executes it; on completion the run posts its reply back into the conversation. AgentDefinitionID is the participant's agent identifier.
type AgentContinueRunData ¶
type AgentContinueRunData struct {
AgentRunID string `json:"agent_run_id"`
AccountID string `json:"account_id"`
Message string `json:"message"`
ApprovedToolSlugs []string `json:"approved_tool_slugs,omitempty"`
// ApproveAllPending, when true, approves every still-pending review-gated tool on the run (the "Approve all" control). It is the only way an empty ApprovedToolSlugs grants approval — set solely on an explicit approval with no specific slugs. A typed-message continuation or a retry leaves it false, so a blocked tool is never silently let through and re-prompts the next time it is called.
ApproveAllPending bool `json:"approve_all_pending,omitempty"`
// RejectedToolSlugs are the review-gated tools the human denied on this resume. The run continues; the runner answers each with a synthetic "denied by user" tool result so the agent proceeds without them.
RejectedToolSlugs []string `json:"rejected_tool_slugs,omitempty"`
// ApprovedToolCallIDs / RejectedToolCallIDs are per-call decisions: the tool_use_ids of individual blocked
// calls the human approved/denied. Unlike the slug lists (which apply to every pending call of a slug),
// these target one specific call, so two same-slug calls with different inputs are decided independently.
ApprovedToolCallIDs []string `json:"approved_tool_call_ids,omitempty"`
RejectedToolCallIDs []string `json:"rejected_tool_call_ids,omitempty"`
ActorID string `json:"actor_id,omitempty"`
ActorType string `json:"actor_type,omitempty"`
ActorName string `json:"actor_name,omitempty"`
// ReplyToMessageID threads this turn's reply under the message that triggered the continuation (the user's reply to the agent), so a chat thread keeps growing as one. Empty for non-chat continuations (e.g. the agent-run console).
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
}
AgentContinueRunData is the payload for AgentCmdContinueRunQueue messages. It carries the run ID, account ID, and user message for continuing a run.
type AgentExecuteActionData ¶
type AgentExecuteActionData struct {
AgentActionID string `json:"agent_action_id"`
ToolSlug string `json:"tool_slug"`
ProposedPayload json.RawMessage `json:"proposed_payload"`
AccountID string `json:"account_id"`
}
AgentExecuteActionData is the payload for AgentCmdExecuteActionQueue messages. It carries a proposed action for execution after optional human review.
type AgentExecuteRunData ¶
type AgentExecuteRunData struct {
AgentRunID string `json:"agent_run_id"`
AgentConfigID string `json:"agent_config_id"`
AccountID string `json:"account_id"`
TriggerType string `json:"trigger_type"`
}
AgentExecuteRunData is the payload for AgentCmdExecuteRunQueue messages. It identifies which agent config to run for which account.
type AgentReplyData ¶
type AgentReplyData struct {
AccountID string `json:"account_id"`
ConversationID string `json:"conversation_id"`
AgentConfigID string `json:"agent_config_id"`
// AgentName is the agent definition's display name, carried so chat bell notifications can title themselves after the agent (name lives in agent-service, not notification-service).
AgentName string `json:"agent_name,omitempty"`
AgentRunID string `json:"agent_run_id"`
Body string `json:"body"`
ClientMessageID string `json:"client_message_id"`
// MessageID is the agent-generated message row id, shared by the start/patch/final messages of one streaming reply so they address the same record. Empty falls back to a service-generated id.
MessageID string `json:"message_id,omitempty"`
// Phase is "start" | "final" | "" (legacy single-shot create-and-complete).
Phase string `json:"phase,omitempty"`
// Failed marks a "final" reply that resolves an errored run (the body is the user-facing error).
Failed bool `json:"failed,omitempty"`
// ErrorCode is the machine-readable api-error code for a failed reply (e.g. "agent_spending_cap_reached"), carried onto the message so the client can react (e.g. prompt to raise the spending limit). Empty for non-cap or non-failed replies.
ErrorCode string `json:"error_code,omitempty"`
// ReplyToMessageID threads the reply under the message that triggered the run (a mention or keyword), so it renders as a reply. Empty for continuation turns (already in a reply thread).
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
// ApprovalEvent marks this as a tool-approval notice rather than an agent reply: the consumer posts it as a senderless system_event (Body = "{approver} approved {tools}") that renders as a timeline divider, not an agent bubble. AgentConfigID/AgentRunID are not required in this mode.
ApprovalEvent bool `json:"approval_event,omitempty"`
}
AlertFanoutData is the payload for NotificationCmdFanoutQueue messages. A producer (core/agent/billing/notification-service) emits one alert/message intent; the notification-service consumer turns it into a message plus per-recipient notification rows and a realtime push. Content may be templated (TemplateKey/TemplateParams) for i18n, with Title/Body as default-locale fallbacks. AgentReplyData is the payload for NotificationCmdAgentReplyQueue: an agent's reply to post into a conversation. notification-service resolves the agent participant from (ConversationID, AgentConfigID), creates a kind=agent message authored by that participant and linked to AgentRunID, and fans it out. ClientMessageID makes it idempotent across redelivery.
A streaming reply spans two of these: Phase "start" creates the row empty and in streaming_state, then Phase "final" sets the finished body and flips it to complete (and fires the bell). MessageID is the agent-owned row id shared across start/patch/final so they target the same record. Phase "" is the legacy single-shot path (create-and-complete in one message). Failed marks a "final" that resolves a started bubble to an error message.
type AgentReplyPatchData ¶
type AgentReplyPatchData struct {
AccountID string `json:"account_id"`
ConversationID string `json:"conversation_id"`
MessageID string `json:"message_id"`
Body string `json:"body"`
}
AgentReplyPatchData is the payload for NotificationCmdAgentReplyPatchQueue: a best-effort partial-body update for an in-flight streaming agent reply. Body is the full accumulated answer so far (not a delta), so a dropped or reordered patch never corrupts the record — the next patch or the "final" reply reconciles it. notification-service updates the row (without touching edited_at) and pushes a server-only message.updated to the conversation's live subscribers.
type AgentRunCompletedData ¶
type AgentRunCompletedData struct {
AgentRunID string `json:"agent_run_id"`
AccountID string `json:"account_id"`
BillingAccountID string `json:"billing_account_id"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
LLMProvider string `json:"llm_provider"`
LLMModel string `json:"llm_model"`
}
AgentRunCompletedData is the payload for AgentEventRunCompletedQueue messages. It carries token usage and model metadata for billing aggregation.
type AgentRunStepData ¶
type AgentRunStepData struct {
AgentRunID string `json:"agent_run_id"`
AccountID string `json:"account_id"`
EventID string `json:"event_id"`
StepType string `json:"step_type"`
Title string `json:"title"`
Content *string `json:"content,omitempty"`
Sequence int `json:"sequence"`
DurationMs *int32 `json:"duration_ms,omitempty"`
ActionID *string `json:"action_id,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt string `json:"created_at"`
ActorID string `json:"actor_id,omitempty"`
ActorType string `json:"actor_type,omitempty"`
ActorName string `json:"actor_name,omitempty"`
// Terminal marks this step as the run's final event (e.g. the "Run failed" error step). The WS gateway, on seeing it, also emits a terminal run_complete frame on the run topic so the frontend leaves its loading state. Successful/awaiting runs already get that frame from the run-completed event; a failed run only emits this step, so without this flag the live run view stays stuck loading until a hard refresh re-fetches the persisted "failed" status.
Terminal bool `json:"terminal,omitempty"`
}
AgentRunStepData is the payload for AgentEventRunStepQueue messages. It carries a single run step event for real-time WebSocket streaming.
type AlertFanoutData ¶
type AlertFanoutData struct {
AccountID string `json:"account_id"`
Category string `json:"category"`
ConversationRef *ConversationRef `json:"conversation_ref,omitempty"`
Kind string `json:"kind"` // system_event | alert | chat | agent
Title string `json:"title"`
Body string `json:"body,omitempty"`
Preview string `json:"preview,omitempty"`
TemplateKey string `json:"template_key,omitempty"`
TemplateParams json.RawMessage `json:"template_params,omitempty"`
LinkResourceType string `json:"link_resource_type,omitempty"`
LinkResourceID string `json:"link_resource_id,omitempty"`
Priority string `json:"priority,omitempty"`
// Polymorphic sender attribution (user | group | system | agent | apikey).
SenderType string `json:"sender_type,omitempty"`
SenderID string `json:"sender_id,omitempty"`
SenderName string `json:"sender_name,omitempty"`
// RecipientAccountUserIDs is an explicit recipient list (account_user ids); empty + Broadcast=true means all active users in the account.
RecipientAccountUserIDs []string `json:"recipient_account_user_ids,omitempty"`
// RecipientUserIDs are user ids (us_) the fan-out resolves to account_user ids within AccountID — for producers (e.g. agent-service) that hold the user id, not the account_user id.
RecipientUserIDs []string `json:"recipient_user_ids,omitempty"`
Broadcast bool `json:"broadcast,omitempty"`
DedupeKey string `json:"dedupe_key,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
type BulkOperation ¶
type BulkOperation string
names one async bulk operation, and stems its routing key, queue and inbox handler so those cannot drift apart. Persisted in the inbox: renaming one orphans in-flight records.
const ( BulkCreateProductionRuns BulkOperation = "bulk_create_production_runs" BulkUpsertProductionSteps BulkOperation = "bulk_upsert_production_steps" BulkUpsertUnits BulkOperation = "bulk_upsert_units" BulkUpsertUnitGroups BulkOperation = "bulk_upsert_unit_groups" BulkUpsertLocations BulkOperation = "bulk_upsert_locations" BulkUpsertDepartments BulkOperation = "bulk_upsert_departments" BulkUpsertMachines BulkOperation = "bulk_upsert_machines" BulkUpsertProductLines BulkOperation = "bulk_upsert_product_lines" BulkUpsertScanningStations BulkOperation = "bulk_upsert_scanning_stations" BulkUpsertItemCategories BulkOperation = "bulk_upsert_item_categories" BulkUpsertParts BulkOperation = "bulk_upsert_parts" BulkUpsertProducts BulkOperation = "bulk_upsert_products" BulkUpsertMaterials BulkOperation = "bulk_upsert_materials" BulkUpsertProperties BulkOperation = "bulk_upsert_properties" // BulkResolveHubspotCompanyReviews applies many company-match decisions at once, the path a reviewed spreadsheet comes back through. BulkResolveHubspotCompanyReviews BulkOperation = "bulk_resolve_hubspot_company_reviews" // Packs one pick into a shipment — not a row batch, but it carries the same {job_id} payload and rides the same consumer. PackPick BulkOperation = "pack_pick" )
The canonical bulk operations
func (BulkOperation) Handler ¶
func (o BulkOperation) Handler() string
builds the inbox handler key, which is part of the persisted dedup identity
func (BulkOperation) RoutingKey ¶
func (o BulkOperation) RoutingKey() contracts.AmqpRoutingKey
builds the AMQP command routing key
func (BulkOperation) String ¶
func (o BulkOperation) String() string
type ChatHistoryMessage ¶
type ChatHistoryMessage struct {
// Role is "assistant" for the dispatched agent's own earlier replies, "user" for everyone else.
Role string `json:"role"`
// Name is the sender's display name when known (people); empty for agents.
Name string `json:"name,omitempty"`
// AgentConfigID is set when a *different* agent authored this turn. Its display name lives in agent-service (not resolvable in notif-service), so it's carried here and resolved into Name when the run is created. Empty for people and the dispatched agent's own turns.
AgentConfigID string `json:"agent_config_id,omitempty"`
Body string `json:"body"`
}
ChatHistoryMessage is one prior turn of conversation context for a chat-triggered agent run.
type CleanupConfig ¶
type CleanupConfig struct {
// Interval (optional; default: 24h) is how often the cleanup loop fires after the first run. Each tick triggers a full run that processes up to MaxBatchesPerRun batches for each table.
Interval time.Duration
// BatchSize (optional; default: 1000) is the maximum number of expired rows deleted in a single SQL DELETE statement. Keeping this bounded prevents table-lock escalation on MySQL and limits the replication lag each batch introduces.
BatchSize int
// MaxBatchesPerRun (optional; default: 100) caps the total number of sequential DELETE batches executed for each table in a single cleanup run. The effective ceiling per run is BatchSize * MaxBatchesPerRun rows per table.
MaxBatchesPerRun int
// LeaseName (optional; default: "idempotency-cleanup") identifies the distributed lease this worker acquires before each run so only one pod does the DELETE work.
LeaseName string
// LeaseTTL (optional; default: 5m) is how long the lease is held before expiring. A full cleanup run typically completes within a few seconds; the TTL is only a safety net for a crashed holder.
LeaseTTL time.Duration
// ScheduleLocation (optional; default: America/New_York) is the timezone used to determine when "midnight" falls for the first scheduled run. The worker waits until the next midnight in this location before running for the first time, then repeats every Interval thereafter.
ScheduleLocation *time.Location
}
CleanupConfig holds the configuration for the cleanup worker, which deletes expired rows from the idempotency key tables and old deleted_record entries.
func (*CleanupConfig) WithDefaults ¶
func (c *CleanupConfig) WithDefaults() *CleanupConfig
WithDefaults returns a new CleanupConfig with zero-value fields replaced by production defaults.
type CleanupRepo ¶
type CleanupRepo interface {
// DeleteExpiredIdempotencyKeys deletes up to `limit` rows from the idempotency_key table whose expires_at timestamp has passed. These are the api-gateway–level keys used to deduplicate inbound HTTP requests. Returns the number of rows actually deleted; the caller uses this to decide whether another batch is needed (deleted < limit means the table is caught up).
DeleteExpiredIdempotencyKeys(ctx context.Context, limit int) (int64, error)
// DeleteExpiredServiceIdempotencyKeys deletes up to `limit` rows from the service_idempotency_key table whose expires_at timestamp has passed. These are the service-level keys used by individual gRPC handlers to prevent duplicate side effects. Returns the number of rows actually deleted.
DeleteExpiredServiceIdempotencyKeys(ctx context.Context, limit int) (int64, error)
// DeleteExpiredDeletedRecords deletes up to `limit` rows from deleted_record older than the retention window. Returns the number of rows actually deleted.
DeleteExpiredDeletedRecords(ctx context.Context, limit int) (int64, error)
// DeleteExpiredRequestLogs deletes up to `limit` rows from request_log older than 7 years. Returns the number of rows actually deleted.
DeleteExpiredRequestLogs(ctx context.Context, limit int) (int64, error)
// DeleteExpiredAuditEvents deletes up to `limit` rows from audit_event older than 7 years. Returns the number of rows actually deleted.
DeleteExpiredAuditEvents(ctx context.Context, limit int) (int64, error)
}
CleanupRepo defines the persistence interface for deleting expired idempotency keys and old deleted records. The platform-service implements this interface. Methods accept a row limit so the caller can bound DELETE scope and iterate in batches.
type CleanupWorker ¶
type CleanupWorker struct {
// contains filtered or unexported fields
}
CleanupWorker runs a single background goroutine that periodically purges expired idempotency keys and old deleted records. It waits until the next midnight in the configured ScheduleLocation before its first run, then repeats every CleanupConfig.Interval.
The worker uses appctx.WithNoTrace to suppress trace spans for its background operations, and respects context cancellation — calling Stop blocks until the goroutine exits cleanly.
func NewCleanupWorker ¶
func NewCleanupWorker(config *CleanupConfig, repo CleanupRepo, l *lease.Lease) (*CleanupWorker, error)
NewCleanupWorker creates a new cleanup worker. A non-nil lease is required — each run claims the configured lease so that only one pod across the cluster runs the DELETEs per tick.
func (*CleanupWorker) Start ¶
func (w *CleanupWorker) Start(ctx context.Context) error
Start launches the background cleanup goroutine. The provided context is used as the parent for all cleanup operations; cancelling it (or calling Stop) shuts down the loop. Tracing is disabled on the derived context so cleanup polling does not generate trace spans.
func (*CleanupWorker) Stop ¶
func (w *CleanupWorker) Stop()
Stop cancels the background context and blocks until the cleanup goroutine has exited. It is safe to call from a deferred shutdown path.
type ConsumeOption ¶
type ConsumeOption func(*ConsumeOptions)
ConsumeOption mutates ConsumeOptions. Pass options to ConsumeMessages to override the per-queue defaults.
func WithConcurrency ¶
func WithConcurrency(n int) ConsumeOption
WithConcurrency sets the number of worker goroutines for the consumer. See ConsumeOptions.Concurrency for the safety requirements.
type ConsumeOptions ¶
type ConsumeOptions struct {
// Concurrency is the number of worker goroutines processing deliveries for this queue. The default of 1 preserves strict in-order, one-at-a-time processing. Values above 1 are only safe for queues whose messages are independently processable — no cross-message ordering requirements — such as request-log and audit-event persistence, where each message is an independent row and the inbox pattern deduplicates redeliveries.
Concurrency int
}
ConsumeOptions configures a single queue consumer.
type ConversationRef ¶
type ConversationRef struct {
ConversationID string `json:"conversation_id,omitempty"`
TopicResourceType string `json:"topic_resource_type,omitempty"`
TopicResourceID string `json:"topic_resource_id,omitempty"`
}
ConversationRef points a fan-out at a conversation, either directly by ID or by the resource it is anchored to (the system channel is resolved by category when nil).
type CustomerRegisteredData ¶
type CustomerRegisteredData struct {
// SellerAccountID is the seller/vendor account whose portal the buyer registered on. The support route is resolved against this account.
SellerAccountID string `json:"seller_account_id"`
// CustomerAccountID is the buyer's customer account (the notification links to it).
CustomerAccountID string `json:"customer_account_id"`
// CustomerName is the customer's display name (best-effort; may be empty for an existing-customer join, in which case the consumer falls back to the number).
CustomerName string `json:"customer_name,omitempty"`
// CustomerNumber is the seller-facing customer number.
CustomerNumber string `json:"customer_number,omitempty"`
// RegistrantUserID is the user (us_) who registered.
RegistrantUserID string `json:"registrant_user_id,omitempty"`
// IsExistingCustomer is true when the buyer joined an existing customer account rather than creating a new one.
IsExistingCustomer bool `json:"is_existing_customer,omitempty"`
}
CustomerRegisteredData is the payload for CoreEventCustomerRegisteredQueue messages. It identifies a buyer who completed portal registration so the notification-service consumer can notify the seller's customer-service support-route group. All display fields the consumer needs are carried here — the consumer (notification-service) has no core-service client to re-fetch them.
type EmailLogData ¶
type EmailLogData struct {
// SesMessageID is the unique message identifier returned by SES, used to correlate delivery status events (bounces, complaints) back to this email.
SesMessageID string `json:"ses_message_id"`
// To are the recipient addresses, persisted as email_recipient rows so the log lists and searches by who received it.
To []string `json:"to,omitempty"`
// AccountID is the account context for audit logging.
AccountID *string `json:"account_id,omitempty"`
// SentByID is the agent who triggered the email for audit logging.
SentByID *string `json:"sent_by_id,omitempty"`
// Subject is the email subject line, stored in the audit record for quick reference without needing to look up the original template.
Subject string `json:"subject"`
// Filename is the name of any attachment included with the email.
Filename *string `json:"filename,omitempty"`
}
EmailLogData is the payload for NotificationEventEmailLogQueue messages. It carries the metadata needed to create an email audit record after the notification-service has successfully dispatched an email through SES.
type EmailSendData ¶
type EmailSendData struct {
// To is the list of recipient email addresses.
To []string `json:"to"`
// Subject is the email subject line.
Subject string `json:"subject"`
// TemplateID identifies which SES email template to render.
TemplateID constants.EmailTemplate `json:"template_id"`
// Params are key-value pairs passed to the template engine for variable substitution (e.g. user name, verification link).
Params map[string]any `json:"params,omitempty"`
// SendAs overrides the default sender address (e.g. "support@openmrp.ai"). When nil the notification-service uses its configured default sender.
SendAs *string `json:"send_as,omitempty"`
// AccountID is the account context for the email, used for audit logging.
AccountID *string `json:"account_id,omitempty"`
// SentByID is the agent who triggered the email, used for audit logging.
SentByID *string `json:"sent_by_id,omitempty"`
// AttachmentData is the base64-encoded attachment content. When present, the notification-service sends a raw MIME email with the attachment.
AttachmentData *string `json:"attachment_data,omitempty"`
// AttachmentFilename is the filename for the attachment.
AttachmentFilename *string `json:"attachment_filename,omitempty"`
// AttachmentContentType is the MIME content type for the attachment.
AttachmentContentType *string `json:"attachment_content_type,omitempty"`
}
EmailSendData is the payload for NotificationCmdSendEmailQueue messages. It describes a single outbound email: the recipients, subject, template, and any template parameters. It is serialized into the contracts.AmqpMessage.Data field before being written to the outbox table.
type Enqueuer ¶
type Enqueuer struct {
// contains filtered or unexported fields
}
Enqueuer implements the publishing side of the transactional outbox pattern. It runs two background goroutines:
- pollLoop: on each tick, acquires a batch of pending outbox messages (with optimistic locking), publishes each to RabbitMQ, and marks them as published or failed in the database.
- cleanupLoop: on each tick, releases expired locks left by crashed instances so those messages become eligible for re-processing.
Start and Stop control the goroutine lifecycle. Tracing is disabled for outbox operations (via appctx.WithNoTrace) to avoid cluttering the trace backend with high-volume background traffic.
func NewEnqueuer ¶
func NewEnqueuer(config *EnqueuerConfig, repo OutboxEnqueuerRepo, broker MessageBroker, l *lease.Lease) (*Enqueuer, error)
NewEnqueuer creates a new outbox enqueuer. Pass a config with at least ServiceName set; zero-value fields are filled with production defaults.
The poll and cleanup loops continue to run on every pod — they coordinate through per-message optimistic locking, and running them in parallel increases publishing throughput and cross-pod recovery of stuck locks. The purge loop is wrapped in a distributed lease so only one pod per service deletes old rows.
func (*Enqueuer) Notify ¶
func (e *Enqueuer) Notify()
Notify wakes the poll loop to drain the outbox immediately rather than waiting for the next (possibly idle-backed-off) tick. Call it AFTER the transaction that wrote the outbox row commits — the row must be visible to the poll query, so kicking from inside the still-open transaction would race the poll and be wasted. It is non-blocking and coalescing: a kick that lands while one is already pending is dropped (the pending wake-up's drain handles every available row), and kicking before Start or after Stop is harmless. Safe for concurrent callers; the nil receiver guard lets callers hold a possibly-unset OutboxNotifier without nil-checking.
type EnqueuerConfig ¶
type EnqueuerConfig struct {
// ServiceName (required) identifies which service owns this enqueuer instance. Stamped onto rows this service writes and used in log messages; it does not scope the poll, which claims pending rows from every service sharing the database.
ServiceName string
// PlatformMode (optional) is the platform mode. When set to "test", default intervals are minimized so e2e runs observe async side-effects quickly (see WithDefaults).
PlatformMode constants.PlatformMode
// LockOwner (optional; default: "{hostname}-{pid}") is a unique identifier for this process instance, used to claim outbox messages via optimistic locking.
LockOwner string
// PollInterval (optional; default: 250ms in production, 10ms in test) controls how frequently the enqueuer polls the outbox table for pending messages while there is work to do.
PollInterval time.Duration
// MaxPollInterval (optional; default: 30s in production, == PollInterval in test) is the ceiling for idle backoff. When consecutive polls find nothing, the interval doubles from PollInterval up to this value so an empty outbox is not queried at full rate. Any poll that finds work resets the interval to PollInterval, so pickup latency and throughput under load are unchanged; only the steady-state idle poll rate drops. The tradeoff is that the first message after a sustained idle period waits up to MaxPollInterval to be picked up. Must be >= PollInterval (clamped in WithDefaults).
//
// The default is deliberately slow because the poll query is not scoped by service_name: every enqueuer on a given database competes for every pending row, so a service that overrides this to a tighter ceiling (notification-service, agent-service) drains the whole table on behalf of the services that do not. Latency-sensitive producers should call Notify() after commit rather than lowering this, and anything that must publish promptly without a kick belongs on a service that overrides the ceiling.
MaxPollInterval time.Duration
// BatchSize (optional; default: 100) is the maximum number of outbox messages to lock and publish in a single poll cycle.
BatchSize int
// LockDurationSeconds (optional; default: 60) is how long (in seconds) a message remains locked to this enqueuer before the lock expires and the message becomes eligible for another instance to pick up.
LockDurationSeconds int
// CleanupInterval (optional; default: 30s) controls how frequently the enqueuer runs its expired-lock cleanup pass, releasing locks held by crashed processes.
CleanupInterval time.Duration
// RetryBackoff (optional; default: 1s base, 2x multiplier, 1h max, 25% jitter; in test mode 10ms base, 2x multiplier, 2s max, 10% jitter) configures the exponential backoff with jitter used to compute the delay before retrying a failed outbox message.
RetryBackoff *retry.Config
// DBRetryBackoff (optional; default: OutboxDBRetryConfig(PlatformMode) — 3 retries, 25ms initial, 500ms max, 20% jitter in production) configures short retries for transient database lock conflicts while claiming or marking outbox rows.
DBRetryBackoff *retry.Config
// RetentionHours (optional; default: 168 i.e. 7 days) is how long published outbox messages are kept before the purge loop deletes them.
RetentionHours int
// PurgeInterval (optional; default: 1h) controls how frequently the enqueuer runs its purge loop to delete old published messages.
PurgeInterval time.Duration
// PurgeLeaseTTL (optional; default: 5m) bounds how long the purge loop holds its distributed lease. The lease ensures only one pod per service performs the bulk DELETE of published messages each tick.
PurgeLeaseTTL time.Duration
}
EnqueuerConfig holds the configuration for the outbox enqueuer.
func (*EnqueuerConfig) WithDefaults ¶
func (c *EnqueuerConfig) WithDefaults() *EnqueuerConfig
WithDefaults fills zero-value fields with production defaults and returns the config. It computes a unique lock owner from the hostname and process ID when not set.
type ExportOperation ¶
type ExportOperation string
names one async export, and stems its routing key, queue and inbox handler so those cannot drift apart. Persisted in the inbox: renaming one orphans in-flight records.
const ( ExportUnits ExportOperation = "export_units" ExportUnitGroups ExportOperation = "export_unit_groups" ExportProductLines ExportOperation = "export_product_lines" ExportItemCategories ExportOperation = "export_item_categories" ExportDepartments ExportOperation = "export_departments" ExportLocations ExportOperation = "export_locations" ExportMachines ExportOperation = "export_machines" ExportScanningStations ExportOperation = "export_scanning_stations" ExportProductionRuns ExportOperation = "export_production_runs" ExportProductionSteps ExportOperation = "export_production_steps" ExportParts ExportOperation = "export_parts" ExportProducts ExportOperation = "export_products" ExportMaterials ExportOperation = "export_materials" ExportProperties ExportOperation = "export_properties" ExportHubspotCompanyReviews ExportOperation = "export_hubspot_company_reviews" ExportPriceList ExportOperation = "export_price_list" )
The canonical export operations
func ExportOperationFor ¶
func ExportOperationFor(resourceSlug string) (ExportOperation, bool)
finds the export command for a resource slug, an export being named for its resource. The second return is false for an unregistered resource, which is a wiring mistake.
func (ExportOperation) Handler ¶
func (o ExportOperation) Handler() string
builds the inbox handler key, which is part of the persisted dedup identity
func (ExportOperation) Queue ¶
func (o ExportOperation) Queue() string
builds the command queue name
func (ExportOperation) RoutingKey ¶
func (o ExportOperation) RoutingKey() contracts.AmqpRoutingKey
builds the AMQP command routing key
func (ExportOperation) String ¶
func (o ExportOperation) String() string
type FailureMonitor ¶ added in v1.2.0
type FailureMonitor struct {
// contains filtered or unexported fields
}
FailureMonitor runs a background goroutine that periodically scans the message_inbox and message_outbox tables for async work that failed to process and emails a digest to the configured recipient. It is the async-message analogue of the api-gateway's 5xx error alert: the 5xx path is per-request and inline, but inbox/outbox failures (handler errors that dead-letter, publish give-ups, and crash-stuck rows) have no single inline moment, so they are surfaced by this scan instead.
Alerts are deduplicated via the alerted_at column: a row is stamped once it has been included in an email so it is never re-alerted. The scan runs under a distributed lease so only one pod alerts per tick, and is suppressed entirely in development mode.
func NewFailureMonitor ¶ added in v1.2.0
func NewFailureMonitor(config *FailureMonitorConfig, repo FailureMonitorRepo, outbox OutboxRepo, l *lease.Lease) (*FailureMonitor, error)
NewFailureMonitor creates a new message failure monitor. A non-nil lease is required so only one pod scans and alerts each tick. The outbox is used to enqueue the alert email through the same durable outbox → notification-service → SES pipeline as every other transactional email.
func (*FailureMonitor) Start ¶ added in v1.2.0
func (m *FailureMonitor) Start(ctx context.Context) error
Start launches the background scan goroutine. The provided context is used as the parent for all scan operations; cancelling it (or calling Stop) shuts down the loop.
func (*FailureMonitor) Stop ¶ added in v1.2.0
func (m *FailureMonitor) Stop()
Stop cancels the background context and blocks until the scan goroutine has exited.
type FailureMonitorConfig ¶ added in v1.2.0
type FailureMonitorConfig struct {
// ServiceName (required) identifies which service hosts this monitor. It scopes the distributed lease so the monitor runs on a single pod.
ServiceName string
// PlatformMode (optional; default: "") suppresses alert emails in development mode and shortens the default ScanInterval to 1m in test mode.
PlatformMode constants.PlatformMode
// Recipient (optional; default: dev@augno.com) is the email address alerts are sent to.
Recipient string
// ScanInterval (optional; default: 5m, or 1m in test) controls how frequently the monitor scans for new failures.
ScanInterval time.Duration
// CrashStuckMinutes (optional; default: 30) is how long an inbox row may sit unprocessed (no last_error) before the monitor treats it as crash-stuck and alerts on it.
CrashStuckMinutes int
// BatchSize (optional; default: 100) caps how many inbox and outbox failures are pulled into a single alert email per scan.
BatchSize int32
// LeaseTTL (optional; default: 5m) bounds how long the monitor holds its lease before a crashed holder's claim expires.
LeaseTTL time.Duration
}
FailureMonitorConfig holds the configuration for the message failure monitor worker.
func (*FailureMonitorConfig) WithDefaults ¶ added in v1.2.0
func (c *FailureMonitorConfig) WithDefaults() *FailureMonitorConfig
WithDefaults fills zero-value fields with production defaults and returns the config.
type FailureMonitorRepo ¶ added in v1.2.0
type FailureMonitorRepo interface {
// ListUnalertedInboxFailures returns inbox rows still in 'received' status with alerted_at IS NULL that either carry a last_error or have sat unprocessed longer than crashStuckMinutes, up to limit rows.
ListUnalertedInboxFailures(ctx context.Context, crashStuckMinutes int, limit int32) ([]InboxFailure, error)
// ListUnalertedOutboxFailures returns outbox rows in 'failed' status with alerted_at IS NULL, up to limit rows.
ListUnalertedOutboxFailures(ctx context.Context, limit int32) ([]OutboxFailure, error)
// MarkInboxAlerted stamps alerted_at on the given inbox rows so subsequent scans skip them.
MarkInboxAlerted(ctx context.Context, ids []int64) error
// MarkOutboxAlerted stamps alerted_at on the given outbox rows so subsequent scans skip them.
MarkOutboxAlerted(ctx context.Context, ids []int64) error
}
FailureMonitorRepo defines the persistence interface used by the FailureMonitor to find un-alerted failed/stuck messages and mark them alerted. It is backed by the shared message_inbox and message_outbox tables; a single implementation scans the whole MySQL fleet's messages because those services share one database.
type GenerateProductionScheduleData ¶
type GenerateProductionScheduleData struct {
AccountID string `json:"account_id"`
ScheduleID string `json:"schedule_id"`
// PlanningAsOf is stamped by the tick, not read at consume time, so a message that sits in the queue still plans against the moment the cadence fired.
PlanningAsOf time.Time `json:"planning_as_of"`
// AutoPublish publishes the version as soon as it solves, for merchants who want the cadence to be the whole workflow.
AutoPublish bool `json:"auto_publish"`
}
GenerateProductionScheduleData is the payload for CoreCmdGenerateProductionScheduleQueue messages. The schedule row already exists in `generating` status when the message is published, so the consumer solves into a row that is already visible rather than creating one — a tick that enqueued and then died would otherwise leave no trace.
type HubspotSyncCommandData ¶
type HubspotSyncCommandData struct {
// JobID is the type-prefixed HubSpot sync job id (e.g. "igjb_...").
JobID string `json:"job_id"`
// AccountID is the account the job belongs to.
AccountID string `json:"account_id"`
}
HubspotSyncCommandData is the payload for CoreCmdHubspotSyncQueue messages (both preview and execute). It identifies the backfill job to run; the consumer dispatches on the message's routing key.
type InboxCheckResult ¶
type InboxCheckResult struct {
// IsDuplicate is true when an inbox record already exists for this (message_id, handler) pair.
IsDuplicate bool
// AlreadyProcessed is true when the existing record has status "processed", meaning the handler already ran to completion.
AlreadyProcessed bool
// HasPreviousError is true when the existing record has a non-nil LastError, indicating the previous attempt failed.
HasPreviousError bool
// PreviousError contains the error message from the last failed attempt.
PreviousError *string
// ExistingRecord is the full inbox record, available for detailed inspection.
ExistingRecord *InboxRecord
}
InboxCheckResult contains the result of checking for a duplicate message. It is used internally by the InboxConsumer to decide the outcome for a re-delivered message.
type InboxConsumer ¶
type InboxConsumer struct {
// contains filtered or unexported fields
}
InboxConsumer wraps message handlers with inbox-based deduplication to achieve exactly-once processing semantics. For each incoming AMQP delivery it:
- Extracts the message ID and metadata from the delivery and body.
- Attempts to insert an inbox record (status = "received").
- If the insert succeeds (new message), the handler is invoked.
- If the insert fails with a duplicate-key error, handleDuplicate inspects the existing record's status to decide whether to skip (already processed), retry (previously failed), or retry (crash recovery — received but never completed).
This pattern guarantees that a handler is invoked at most once for a given (message_id, handler) pair under normal operation, and provides safe retry semantics for crash-recovery scenarios.
func NewInboxConsumer ¶
func NewInboxConsumer(repo InboxRepo, serviceName string) *InboxConsumer
NewInboxConsumer creates a new InboxConsumer that uses the given repository for persistence and derives a tracer scoped to "{serviceName}.inbox_consumer".
func (*InboxConsumer) Wrap ¶
func (c *InboxConsumer) Wrap(handler string, fn MessageHandler) MessageHandler
Wrap returns a new MessageHandler that guards fn with inbox deduplication. The handler parameter is a human-readable name that scopes the deduplication — the same message ID processed by different handlers (e.g. "notification.send_email" vs "notification.log_email") is treated as distinct and both execute.
Metadata (message ID, request ID, parent message ID) is extracted from the AMQP delivery headers and body. If no message ID can be found, the handler runs without deduplication (with a warning log) to avoid silently dropping messages.
type InboxFailure ¶ added in v1.2.0
type InboxFailure struct {
ID int64
MessageID string
ServiceName string
Handler string
MessageType string
Attempts int
LastError *string
ReceivedAt time.Time
}
InboxFailure describes a message_inbox row the monitor considers failed or stuck: either the handler recorded an error (last_error set) or the row was inserted but never processed within the crash-stuck window.
type InboxPurger ¶
type InboxPurger struct {
// contains filtered or unexported fields
}
InboxPurger runs a background goroutine that periodically deletes processed inbox records older than the configured retention period. This prevents the message_inbox table from growing unboundedly while preserving recent records for debugging and deduplication.
func NewInboxPurger ¶
func NewInboxPurger(config *InboxPurgerConfig, repo InboxPurgerRepo, l *lease.Lease) (*InboxPurger, error)
NewInboxPurger creates a new inbox purger. A non-nil lease is required so that only one pod per service deletes processed inbox rows each tick.
func (*InboxPurger) Start ¶
func (p *InboxPurger) Start(ctx context.Context) error
Start launches the background purge goroutine. The provided context is used as the parent for all purge operations; cancelling it (or calling Stop) shuts down the loop.
func (*InboxPurger) Stop ¶
func (p *InboxPurger) Stop()
Stop cancels the background context and blocks until the purge goroutine has exited.
type InboxPurgerConfig ¶
type InboxPurgerConfig struct {
// ServiceName (required) identifies which service owns this purger. It's included in the lease name so each service scopes its purger independently.
ServiceName string
// PlatformMode (optional; default: "") shortens the default PurgeInterval to 1m when test (see WithDefaults); otherwise the production 1h default applies.
PlatformMode constants.PlatformMode
// RetentionHours (optional; default: 168 i.e. 7 days) is how long processed inbox records are kept before the purge loop deletes them.
RetentionHours int
// PurgeInterval (optional; default: 1h) controls how frequently the purger runs its purge loop to delete old processed records.
PurgeInterval time.Duration
// BatchSize (optional; default: 1000) is the maximum number of processed inbox records to delete in a single SQL DELETE statement.
BatchSize int32
// LeaseTTL (optional; default: 5m) bounds how long the purger holds its lease before a crashed holder's claim expires.
LeaseTTL time.Duration
}
InboxPurgerConfig holds the configuration for the inbox purger worker.
func (*InboxPurgerConfig) WithDefaults ¶
func (c *InboxPurgerConfig) WithDefaults() *InboxPurgerConfig
WithDefaults fills zero-value fields with production defaults and returns the config.
type InboxPurgerRepo ¶
type InboxPurgerRepo interface {
// PurgeProcessed deletes processed inbox records older than retentionHours, up to limit rows per call. Returns the number of rows deleted.
PurgeProcessed(ctx context.Context, retentionHours int, limit int32) (int64, error)
}
InboxPurgerRepo defines the persistence interface used by the InboxPurger to delete processed inbox records that have exceeded the retention period.
type InboxRecord ¶
type InboxRecord struct {
ID int64
MessageID string
ServiceName string
Handler string
MessageType string
RequestID *string
ParentMessageID *string
Status InboxStatus
Attempts int
LastError *string
ReceivedAt time.Time
ProcessedAt *time.Time
}
InboxRecord represents a row in the inbox table. It tracks delivery state, attempt count, and any error from the most recent processing attempt. The InboxConsumer reads this record on duplicate detection to decide whether to skip, retry, or trigger crash recovery.
type InboxRecordInput ¶
type InboxRecordInput struct {
// MessageID is the globally unique identifier from the AMQP MessageId header.
MessageID string
// ServiceName identifies which service is consuming the message.
ServiceName string
// Handler is the logical handler name (e.g. "notification.send_email") used to scope deduplication — the same message delivered to different handlers is processed independently.
Handler string
// MessageType is the AMQP routing key / message type for observability.
MessageType string
// RequestID ties this message back to the originating request for tracing.
RequestID string
// ParentMessageID links to the message that caused this one to be emitted.
ParentMessageID string
}
InboxRecordInput contains the data needed to create an inbox record. It is populated from the AMQP delivery metadata and message body by InboxConsumer.Wrap.
type InboxRepo ¶
type InboxRepo interface {
// TryInsert attempts to insert a new inbox record with status "received". On success it returns the auto-generated record ID. On duplicate (MySQL error 1062 from the unique index on message_id + handler), it returns 0 and the MySQL error so the caller can branch into duplicate-handling logic.
TryInsert(ctx context.Context, input InboxRecordInput) (int64, error)
// GetByMessageAndHandler retrieves the existing inbox record for a given message and handler combination. Used by handleDuplicate to inspect the prior record's status and decide whether to skip, retry, or trigger crash recovery.
GetByMessageAndHandler(ctx context.Context, messageID, handler string) (*InboxRecord, error)
// MarkProcessed transitions the record to "processed" status and sets processed_at. Called after the handler completes successfully.
MarkProcessed(ctx context.Context, id int64) error
// MarkFailed increments the attempt count and stores the error message from the most recent failed attempt. The record stays in "received" status so it can be retried on re-delivery.
MarkFailed(ctx context.Context, id int64, errMsg string) error
}
InboxRepo defines the persistence interface for inbox-based message deduplication. Implementations are provided by each service's repository layer, backed by the shared message_inbox table.
type InboxStatus ¶
type InboxStatus string
InboxStatus represents the lifecycle state of an inbox record as it moves from initial receipt through successful processing or failure.
const ( // InboxStatusReceived means the message was inserted into the inbox table but the handler has not yet completed. If the process crashes at this point, the record stays in "received" and the InboxConsumer treats re-delivery as a crash-recovery retry. InboxStatusReceived InboxStatus = "received" // InboxStatusProcessed means the handler ran to completion and the message should not be processed again. Duplicate deliveries with this status are silently ACKed. InboxStatusProcessed InboxStatus = "processed" )
type InvoiceCreatedReportData ¶
type InvoiceCreatedReportData struct {
// Names the account the invoice was created for.
AccountID string `json:"account_id"`
// Identifies the invoice that triggered the report.
InvoiceID string `json:"invoice_id"`
}
Carries a created invoice to the usage meter. The meter counts one event per message, so the invoice id travels only for traceability.
type MessageBroker ¶
type MessageBroker interface {
// PublishMessage publishes the message to the given exchange with the specified routing key.
PublishMessage(ctx context.Context, exchange, routingKey string, message contracts.AmqpMessage) error
// ConsumeMessages consumes messages from the given queue and invokes the handler for each delivery.
ConsumeMessages(ctx context.Context, queueName string, handler MessageHandler, opts ...ConsumeOption) error
// ConsumeFanout declares a per-instance ephemeral (non-durable, exclusive, auto-delete) queue named "<baseName>.<instance-suffix>", binds it to the given routing keys on the application exchange, and consumes from it. Because every process that calls this gets its OWN queue, each receives a copy of every matching message — true fan-out. This is the correct primitive for realtime WebSocket delivery, where every api-gateway replica holds a distinct set of client sockets and must therefore see every event. Contrast with ConsumeMessages, whose callers share one durable queue and thus compete for deliveries (work-queue semantics). The per-instance queue dies with the process, so undelivered realtime events are simply dropped — acceptable because the persisted rows remain the source of truth.
ConsumeFanout(ctx context.Context, baseName string, routingKeys []string, handler MessageHandler, opts ...ConsumeOption) error
// IsReady reports whether the broker connection and channel are ready for use.
IsReady() bool
// Close shuts down the AMQP channel and connection. Safe to call multiple times.
Close()
}
MessageBroker defines the interface for publishing and consuming AMQP messages.
func NewRabbitMQ ¶
func NewRabbitMQ(ctx context.Context, config *RabbitMQConfig) (MessageBroker, error)
NewRabbitMQ creates a new rabbitMQ client connected to the given AMQP URI. It dials the broker with exponential backoff, declares the full exchange/queue topology, and verifies the connection is ready. On success the returned client is guaranteed to have an open connection and channel.
Example ¶
ExampleNewRabbitMQ shows the minimal configuration for connecting to the broker: only URI is required; all other fields receive production defaults.
package main
import (
"context"
"github.com/open-mrp/api/shared/messaging"
)
func main() {
broker, err := messaging.NewRabbitMQ(context.Background(), &messaging.RabbitMQConfig{
URI: "amqp://guest:guest@rabbitmq:5672/",
})
if err != nil {
panic(err)
}
_ = broker
}
Output:
type MessageHandler ¶
MessageHandler is the callback signature for processing a single AMQP delivery. Implementations should return nil on success (the message will be ACKed) or an error on failure (the message will be rejected to the dead-letter queue after retry exhaustion).
type OutboxEnqueuerRepo ¶
type OutboxEnqueuerRepo interface {
// AcquireAndLock atomically selects up to `limit` pending messages whose next_run_at has passed and locks them to the given lockOwner for lockDurationSeconds. Returns the locked messages for publishing.
AcquireAndLock(ctx context.Context, lockOwner string, limit int, lockDurationSeconds int) ([]*OutboxMessage, error)
// MarkPublished updates the given messages' status to 'published' with a timestamp, preserving the records for audit and debugging purposes. The enqueuer calls this once per batch with every id it published, so the implementation should issue a single set-based UPDATE.
MarkPublished(ctx context.Context, ids []int64) error
// MarkFailed increments the message's attempt count, records the error message, and schedules the next retry after retryDelaySecs seconds. If MaxAttempts is exceeded the message remains in "failed" status for manual investigation.
MarkFailed(ctx context.Context, id int64, errorMsg string, retryDelaySecs int) error
// CleanupExpiredLocks releases locks held by enqueuer instances that have crashed or stalled (lock_expires_at < now), making those messages eligible for re-acquisition by any healthy enqueuer.
CleanupExpiredLocks(ctx context.Context, limit int32) (int64, error)
// PurgePublished deletes published outbox messages older than retentionHours, up to limit rows per call. Returns the number of rows deleted.
PurgePublished(ctx context.Context, retentionHours int, limit int32) (int64, error)
}
OutboxEnqueuerRepo defines the read/update interface used exclusively by the Enqueuer to process outbox messages. It is kept separate from OutboxRepo because the enqueuer operates outside of business transactions and needs different operations (locking, bulk fetch, status updates).
type OutboxFailure ¶ added in v1.2.0
type OutboxFailure struct {
ID int64
MessageID string
ServiceName string
MessageType string
Destination string
RoutingKey string
Attempts int
MaxAttempts int
LastError *string
CreatedAt time.Time
}
OutboxFailure describes a message_outbox row the enqueuer gave up on: status = 'failed' after exhausting max_attempts publish attempts.
type OutboxMessage ¶
type OutboxMessage struct {
ID int64
MessageID string
ServiceName string
MessageType string
Destination string
RoutingKey string
Headers map[string]any
Payload contracts.AmqpMessage
Status OutboxStatus
Attempts int
MaxAttempts int
NextRunAt time.Time
LockedAt *time.Time
LockOwner *string
LockExpiresAt *time.Time
LastError *string
PublishedAt *time.Time
RequestID *string
ParentMessageID *string
CreatedAt time.Time
UpdatedAt time.Time
}
OutboxMessage represents a row in the outbox table as read by the Enqueuer. It includes all columns needed for locking, publishing, retry scheduling, and failure tracking.
type OutboxMessageInput ¶
type OutboxMessageInput struct {
// MessageID is the globally unique identifier for this message (e.g. mg_abc123).
MessageID string
// ServiceName identifies the service that created the message (e.g. "auth-service").
ServiceName string
// MessageType categorizes the message for routing and consumer dispatch (e.g. "notification.cmd.send_email").
MessageType string
// Destination is the AMQP exchange the message should be published to.
Destination string
// RoutingKey is the AMQP routing key used for topic-based queue binding.
RoutingKey string
// Payload is the structured message body that will be JSON-serialized and published.
Payload contracts.AmqpMessage
// MaxAttempts caps how many times the enqueuer will retry publishing before giving up.
MaxAttempts int
// DelaySeconds (optional; default 0 = available immediately) defers first delivery by scheduling next_run_at that many seconds into the future. Used for backoff on re-enqueue (e.g. bounded auto-retry of a transient failure) so the message is not republished into a still-failing dependency. Not every service's outbox Create honors this; today only agent-service does.
DelaySeconds int
}
OutboxMessageInput contains the data needed to insert a new outbox message. It is passed to OutboxRepo.Create inside the same database transaction as the business operation, ensuring atomicity between the domain write and the intent to publish.
type OutboxNotifier ¶
type OutboxNotifier interface {
Notify()
}
OutboxNotifier is the producer-facing handle for waking an Enqueuer after an outbox write commits. *Enqueuer satisfies it. Inject it into services that write latency-sensitive outbox rows (e.g. starting an agent chat run) so the row is picked up on the next instant rather than on the next idle poll, which can be as long as MaxPollInterval away.
type OutboxRepo ¶
type OutboxRepo interface {
// Create inserts a new outbox message within the current transaction. The message starts in "pending" status and will be picked up by the Enqueuer's poll loop.
Create(ctx context.Context, input OutboxMessageInput) (int64, error)
}
OutboxRepo defines the write-side interface for outbox message persistence. It is used by service-layer code to enqueue a message inside the same database transaction as the business operation (transactional outbox pattern).
type OutboxStatus ¶
type OutboxStatus string
OutboxStatus represents the lifecycle state of an outbox message as it moves from creation through publishing or failure.
const ( // OutboxStatusPending means the message has been written to the outbox table (inside the service's business transaction) and is waiting for the Enqueuer to pick it up. OutboxStatusPending OutboxStatus = "pending" // OutboxStatusPublished means the Enqueuer successfully published the message to RabbitMQ and the outbox record has been deleted (or marked) as complete. OutboxStatusPublished OutboxStatus = "published" // OutboxStatusFailed means the Enqueuer attempted to publish but encountered an error. The attempt count is incremented and the message is scheduled for retry with exponential backoff until MaxAttempts is reached. OutboxStatusFailed OutboxStatus = "failed" )
type RabbitMQConfig ¶
type RabbitMQConfig struct {
// URI (required) is the rabbitMQ connection URI.
URI string
// ConnectionTimeout (optional; default: 2m) is the overall timeout for the initial connection attempt.
ConnectionTimeout time.Duration
// MaxRetries (optional; default: 10) is the maximum number of connection dial retries.
MaxRetries int
// InitialRetryWait (optional; default: 1s) is the starting backoff interval between connection retries.
InitialRetryWait time.Duration
// MaxRetryWait (optional; default: 10s) is the maximum backoff interval between connection retries.
MaxRetryWait time.Duration
// PrefetchCount (optional; default: 1) is the QoS prefetch limit per consumer.
PrefetchCount int
// ReconnectDelay (optional; default: 5s) is how long to wait before retrying after a consumer failure.
ReconnectDelay time.Duration
}
RabbitMQConfig represents the configuration for the rabbitMQ client.
func (*RabbitMQConfig) WithDefaults ¶
func (c *RabbitMQConfig) WithDefaults() *RabbitMQConfig
WithDefaults returns a new RabbitMQConfig with all zero-value optional fields replaced by production defaults. It is safe to call on a nil receiver. The original config is not mutated; a copy is always returned.
type RealtimeDeliveryData ¶
type RealtimeDeliveryData struct {
AccountID string `json:"account_id"`
// RecipientUserID is the user id (us_) used as the WS user-topic key (the gateway subscribes user:<user_id> from the validated identity's actor id).
RecipientUserID string `json:"recipient_user_id,omitempty"`
// RecipientAccountUserID is the per-account recipient (acus_) the notification belongs to.
RecipientAccountUserID string `json:"recipient_account_user_id,omitempty"`
// ConversationID targets the per-conversation topic (live chat).
ConversationID string `json:"conversation_id,omitempty"`
// AnnouncementID targets the per-account broadcast topic (account:<account_id>).
AnnouncementID string `json:"announcement_id,omitempty"`
// Event is the client-facing event name: notification.created | announcement.created | message.created | conversation.updated | unread.changed | account.unread_hint.
Event string `json:"event"`
// Visibility is the audience of a message-bearing frame (internal | external | system); empty for non-message events. SAFETY: a customer-subscribed conversation socket must drop frames whose Visibility is "internal" so an internal note is never delivered to the customer. The authoritative guarantee is the visibility-filtered read path; this lets the realtime layer enforce the same.
Visibility string `json:"visibility,omitempty"`
NotificationID string `json:"notification_id,omitempty"`
MessageID string `json:"message_id,omitempty"`
Sequence int64 `json:"sequence,omitempty"`
UnreadCount *int64 `json:"unread_count,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
RealtimeDeliveryData is the payload for NotificationEventDeliveredQueue messages. notification-service emits it; every api-gateway instance consumes it and fans it out to the matching Hub topic (user:<account_user_id> for the bell, conv:<conversation_id> for live chat). Best-effort: the persisted rows remain the source of truth.
type SalesOrderCreatedData ¶
type SalesOrderCreatedData struct {
// SalesOrderID is the type-prefixed ID of the created order (e.g. "so_...").
SalesOrderID string `json:"sales_order_id"`
// AccountID is the owner/seller account the order belongs to.
AccountID string `json:"account_id"`
// BuyerAccountID is the customer account the order was created for.
BuyerAccountID string `json:"buyer_account_id"`
// Number is the human-facing order number.
Number string `json:"number"`
// StatusCode is the order's status at creation (e.g. "estimate").
StatusCode string `json:"status_code"`
}
SalesOrderCreatedData is the payload for CoreEventSalesOrderCreatedQueue messages. It identifies a newly created sales order so consumers can run out-of-band side effects (e.g. CRM sync). Consumers re-fetch the full order by ID when they need more than these identifiers.
type SalesOrderShippingUpdatedData ¶
type SalesOrderShippingUpdatedData struct {
// SalesOrderID is the type-prefixed ID of the updated order.
SalesOrderID string `json:"sales_order_id"`
// AccountID is the owner/seller account the order belongs to.
AccountID string `json:"account_id"`
}
SalesOrderShippingUpdatedData is the payload for CoreEventSalesOrderShippingUpdatedQueue messages. It identifies an order whose carrier / service level / ship-to changed; the consumer re-fetches the order and syncs its shipment records to the order's current shipping fields.
type SeatChangeReportData ¶
type SeatChangeReportData struct {
// AccountID is the account whose seat count changed.
AccountID string `json:"account_id"`
}
SeatChangeReportData is the payload for BillingCmdReportSeatChangeQueue messages. It identifies the account whose seat count change should be reported to the billing provider's usage meters.
type SeatSyncData ¶
type SeatSyncData struct {
// AccountID is the account whose seat count changed.
AccountID string `json:"account_id"`
}
SeatSyncData is the payload for BillingCmdSyncSeatsQueue messages. It identifies the account whose seat count should be reconciled with the billing provider.