feat(dispatch): queue async events durably
This commit is contained in:
@@ -17,9 +17,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc
|
||||
## Current Baseline
|
||||
|
||||
- Plan freeze checkpoint: 2026-06-05 docs-only tracker landing after `13cb750 feat(captain): align task payload persistence`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(worker): add durable background jobs`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(dispatch): queue async events durably`.
|
||||
- Latest documentation checkpoint before this freeze: `3263ed9 docs: land copilot task execution plan`; this document now carries the active follow-up plan directly.
|
||||
- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests. Next active implementation slice is P5.2/P5.4 job integration.
|
||||
- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests. Next active implementation slice is P5.4/P5.5 automation and CSAT job integration.
|
||||
- `go test ./...` passes.
|
||||
- Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
@@ -78,7 +78,7 @@ Open work after the current checkpoint:
|
||||
| Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback |
|
||||
| Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based |
|
||||
| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, B10, and B11 are in Review; B12 reused frontend smoke is the next broad verification gate |
|
||||
| Phase 5 | Background jobs and integrations | Doing | P5.1 durable worker core is in Review; dispatcher/automation/search/SLA/Captain job integration remains open |
|
||||
| Phase 5 | Background jobs and integrations | Doing | P5.1/P5.2 durable worker and event dispatch cores are in Review; automation/search/SLA/Captain job integration remains open |
|
||||
| Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad |
|
||||
| Phase 7 | Verification harness | Review | B12.1 boot/readiness, B12.2a API assertions, B12.2b browser smoke harness, B12.3a enterprise API assertions, and B12.3b enterprise browser route navigation exist; optional live Meilisearch/full-browser runs remain environment-dependent |
|
||||
|
||||
@@ -101,6 +101,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(dispatch): queue async events durably` | Advances P5.2 with durable async event dispatch. `channel.Dispatcher.DispatchAsync` now enqueues `event:dispatch_async` jobs when a WorkerPool is configured, and `dispatch.EventDispatcher` now enqueues per-listener `event:listener_dispatch` jobs for async listener replay while preserving immediate sync listener behavior. | `go test ./internal/channel ./internal/dispatch ./internal/worker -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.2 section. | Move P5.2 to Review; continue P5.4/P5.5 automation webhook/transcript and CSAT durable job integration. |
|
||||
| `feat(worker): add durable background jobs` | Advances P5.1 with a persistent `background_jobs` model and migration plus `internal/worker.WorkerPool` enqueue, scheduled claim, handler registry, retry/backoff, dead-letter, idempotency-key reuse, queue filtering, PostgreSQL `SKIP LOCKED` claim support, stale lock recovery, and graceful start/stop loop. The model is registered in app auto-migration and test DB defaults. | `go test ./internal/worker -count=1`; `go test ./...`; `git diff --check`; full verification recorded in the P5.1 section. | Move P5.1 to Review; continue P5.2 dispatcher async routing and P5.4/P5.5 automation/CSAT job integration on top of the durable core. |
|
||||
| `test(parity): add enterprise browser smoke coverage` | Advances B12.3b with `scripts/parity_frontend_smoke.sh --enterprise-browser-smoke`. The browser driver now supports `--enterprise`, creates route-specific reused Chatwoot dashboard shells, and asserts browser-origin requests for SLA reports, CSAT reports, automation rules, macros, audit logs, custom roles, agent capacity list/edit, Captain settings/assistants, and Copilot thread requests. The smoke seed now enables the Chatwoot enterprise feature flags required by those route guards. | `bash -n scripts/parity_frontend_smoke.sh`; `node --check scripts/parity_frontend_browser_smoke.mjs`; `scripts/parity_frontend_smoke.sh --print`; `scripts/parity_frontend_smoke.sh --check`; `go test ./cmd/gochat -count=1`; full verification recorded in the B12.3b section. Live enterprise browser smoke requires running GoChat and Chatwoot Vite. | Move B12 verification harness to Review; continue Phase 5 job parity and convert any optional live-smoke failures into named slices. |
|
||||
| `test(parity): add enterprise api smoke coverage` | Advances B12.3a with `scripts/parity_frontend_smoke.sh --enterprise-smoke`. The command composes the core API smoke with enterprise API assertions for SLA reports/metrics/download, public and account CSAT reports/metrics/download, automation-rule create/list, macro create/execute, audit-log list, custom-role list, agent-capacity list/users, Captain preferences/assistants, and Copilot thread/message payloads. | `bash -n scripts/parity_frontend_smoke.sh`; `scripts/parity_frontend_smoke.sh --print`; `scripts/parity_frontend_smoke.sh --check`; focused verification recorded in the B12.3a section. Live enterprise smoke requires running GoChat with PostgreSQL/Redis/Meilisearch. | Continue B12.3b with reused-frontend browser navigation for the same enterprise screens. |
|
||||
@@ -1604,7 +1605,7 @@ Tracking table:
|
||||
| ID | Task | Reference source | Current Go hotspot | Done boundary | Status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| P5.1 | Implement durable worker core and job model. | `reference/chatwoot/app/jobs/application_job.rb`, `mutex_application_job.rb` | `internal/worker/worker.go`, `internal/model/background_job.go`, `migrations/000026_add_background_jobs.*.sql` | Job table, worker persistence API, enqueue API, worker loop, retry/backoff, scheduled jobs, mutex/idempotency keys, dead-letter state, and restart pickup tests exist. | Review by `feat(worker): add durable background jobs` |
|
||||
| P5.2 | Route async dispatcher events through durable jobs. | `event_dispatcher_job.rb`, Chatwoot async dispatcher listeners | `internal/dispatch/dispatcher.go`, `internal/channel/dispatcher.go` | Heavy listeners can enqueue durable jobs without changing sync listener behavior; tests cover sync vs async routing and replay. | Todo |
|
||||
| P5.2 | Route async dispatcher events through durable jobs. | `event_dispatcher_job.rb`, Chatwoot async dispatcher listeners | `internal/dispatch/dispatcher.go`, `internal/channel/dispatcher.go` | Heavy listeners can enqueue durable jobs without changing sync listener behavior; tests cover sync vs async routing and replay. | Review by `feat(dispatch): queue async events durably` |
|
||||
| P5.3 | Move Meilisearch indexing and reindex fan-out into retryable jobs. | Meilisearch plan plus Chatwoot callbacks/jobs that index searchable records | search services, contact/company/conversation indexing hooks | Create/update/delete indexing survives handler success, retries on Meilisearch failure, and optional live Meilisearch gate remains green. | Todo |
|
||||
| P5.4 | Queue automation webhook and transcript delivery. | `webhook_job.rb`, automation action execution services | `internal/automation/action_delivery.go`, `internal/automation/action_service.go` | Existing timeout/retry fakeable delivery is invoked by durable jobs; logs preserve attempt metadata and idempotency. | Todo |
|
||||
| P5.5 | Queue delayed automation actions and macro execution. | `trigger_scheduled_items_job.rb`, `macros_execution_job.rb` | automation rule listener, macro service | Delayed actions execute after schedule time, macro execute supports multi-conversation job fan-out, and repeated workers do not duplicate side effects. | Todo |
|
||||
@@ -1633,6 +1634,22 @@ env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache
|
||||
git diff --check
|
||||
```
|
||||
|
||||
P5.2 current checkpoint:
|
||||
|
||||
- `channel.Dispatcher` can now be constructed or later wired with a `WorkerPool`; `DispatchAsync` enqueues `event:dispatch_async` jobs on the `events` queue and falls back to current synchronous behavior when no worker is configured.
|
||||
- `dispatch.EventDispatcher` can now attach a `WorkerPool`; sync listeners still execute inline, while async listeners and `DispatchAsync` subscribers enqueue durable per-listener `event:listener_dispatch` jobs.
|
||||
- Worker replay resolves the listener by name and invokes `OnEvent` with the serialized event payload. Missing listeners or malformed payloads fail the job, preserving retry/dead-letter visibility.
|
||||
- `WorkerPool` default queue handling was broadened to process all queues unless explicitly filtered, so `events` jobs are processed by the default worker.
|
||||
- Remaining integration work is feature-specific: automation webhook/transcript actions, delayed automation/macros, CSAT sends/templates, Meilisearch indexing, SLA scans, Captain/Copilot, contact exports, and provider delivery must register producers/handlers on this durable path.
|
||||
|
||||
P5.2 verification:
|
||||
|
||||
```bash
|
||||
env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./internal/channel ./internal/dispatch ./internal/worker -count=1
|
||||
env TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./...
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Phase 6: Core Product Placeholder Burn-down
|
||||
|
||||
Status: doing.
|
||||
@@ -1825,3 +1842,4 @@ Verification milestone gates:
|
||||
- 2026-06-05: B12.3a enterprise API smoke checkpoint prepared as `test(parity): add enterprise api smoke coverage`; `scripts/parity_frontend_smoke.sh --enterprise-smoke` now composes the core API smoke with enterprise checks for SLA reports/download, CSAT public submit plus reports/download, automation/macros, audit/custom roles, capacity, Captain preferences/assistants, and Copilot thread/message payloads. Default verification keeps the live enterprise run optional because it needs PostgreSQL, Redis, Meilisearch, and GoChat. Verification: `bash -n`, `--print`, and `--check` passed locally. Next slice is B12.3b reused-frontend enterprise browser navigation.
|
||||
- 2026-06-05: B12.3b enterprise browser smoke checkpoint prepared as `test(parity): add enterprise browser smoke coverage`; `scripts/parity_frontend_smoke.sh --enterprise-browser-smoke` now runs enterprise API smoke, verifies Chatwoot Vite, and drives reused Chatwoot enterprise dashboard routes through the Chrome DevTools browser harness. Browser assertions cover SLA, CSAT, automation, macros, audit logs, custom roles, agent capacity, Captain settings/assistants, and Copilot thread request paths. Seed now enables the corresponding enterprise feature flags for route guards. Verification: `bash -n`, `node --check`, `--print`, `--check`, `go test ./cmd/gochat`, full `go test ./...`, and `git diff --check` passed locally. Next slice is Phase 5 job parity.
|
||||
- 2026-06-05: P5.1 durable worker checkpoint prepared as `feat(worker): add durable background jobs`; the `background_jobs` table/model and WorkerPool core now cover enqueueing, scheduled jobs, queue filtering, idempotency keys, PostgreSQL `SKIP LOCKED` claims, handler execution, retry/backoff, dead-letter state, stale-lock recovery, and graceful start/stop. Focused worker tests cover idempotent enqueue, completion, retries, schedule/queue gating, and restart pickup. Next slice is P5.2/P5.4 integration of dispatcher and automation jobs onto the durable core.
|
||||
- 2026-06-05: P5.2 durable event dispatch checkpoint prepared as `feat(dispatch): queue async events durably`; `channel.Dispatcher.DispatchAsync` now enqueues event replay jobs when a WorkerPool is configured, and `dispatch.EventDispatcher` enqueues per-listener async jobs while preserving inline sync listeners. Focused channel/dispatch/worker tests prove durable enqueue and worker replay. Next slice is P5.4/P5.5 automation webhook/transcript and CSAT job integration.
|
||||
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
// TaskTypeEventDispatch is the async task type for event dispatch.
|
||||
// Reference: Chatwoot EventDispatcherJob (Sidekiq worker)
|
||||
// TODO: asynq integration in P10 (async task processing)
|
||||
const TaskTypeEventDispatch = "event:dispatch_async"
|
||||
|
||||
// EventListener is the interface that all event listeners must implement.
|
||||
@@ -36,13 +37,30 @@ type EventListener interface {
|
||||
type Dispatcher struct {
|
||||
mu sync.RWMutex
|
||||
listeners map[string]EventListener // name → listener (prevents duplicates)
|
||||
worker *worker.WorkerPool
|
||||
}
|
||||
|
||||
// NewDispatcher creates a Dispatcher with empty listener registry.
|
||||
func NewDispatcher() *Dispatcher {
|
||||
return &Dispatcher{
|
||||
func NewDispatcher(workers ...*worker.WorkerPool) *Dispatcher {
|
||||
d := &Dispatcher{
|
||||
listeners: make(map[string]EventListener),
|
||||
}
|
||||
if len(workers) > 0 {
|
||||
d.SetWorkerPool(workers[0])
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// SetWorkerPool wires DispatchAsync to the durable background job worker.
|
||||
func (d *Dispatcher) SetWorkerPool(wp *worker.WorkerPool) {
|
||||
d.mu.Lock()
|
||||
d.worker = wp
|
||||
d.mu.Unlock()
|
||||
if wp != nil {
|
||||
wp.Register(TaskTypeEventDispatch, func(ctx context.Context, job *model.BackgroundJob) error {
|
||||
return d.handleAsyncJob(ctx, job.Payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a listener. Duplicate names overwrite.
|
||||
@@ -88,25 +106,42 @@ func (d *Dispatcher) Dispatch(ctx context.Context, event *ChannelEvent) error {
|
||||
}
|
||||
|
||||
// DispatchAsync enqueues an event for async processing.
|
||||
// TODO: Implement with asynq in P10. Currently falls back to synchronous dispatch.
|
||||
// When no durable worker is configured, it preserves the legacy synchronous fallback.
|
||||
func (d *Dispatcher) DispatchAsync(ctx context.Context, event *ChannelEvent) error {
|
||||
// For now, fall back to synchronous dispatch.
|
||||
// P10 will implement asynq task enqueue:
|
||||
// payload, _ := json.Marshal(event)
|
||||
// task := asynq.NewTask(TaskTypeEventDispatch, payload)
|
||||
// _, err = d.taskClient.Enqueue(task, asynq.Queue("events"), asynq.MaxRetry(3))
|
||||
wp := d.workerPool()
|
||||
if wp != nil {
|
||||
_, err := wp.Enqueue(ctx, TaskTypeEventDispatch, event, worker.WithQueue("events"), worker.WithMaxAttempts(3))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applogger.L().Infof("async dispatch enqueued event %s", event.Type)
|
||||
return nil
|
||||
}
|
||||
applogger.L().Infof("async dispatch (sync fallback) event %s", event.Type)
|
||||
return d.Dispatch(ctx, event)
|
||||
}
|
||||
|
||||
// HandleAsyncTask processes an asynq task for async event dispatch.
|
||||
// TODO: Implement with asynq.Server handler in P10.
|
||||
func (d *Dispatcher) workerPool() *worker.WorkerPool {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.worker
|
||||
}
|
||||
|
||||
func (d *Dispatcher) handleAsyncJob(ctx context.Context, payload []byte) error {
|
||||
var event ChannelEvent
|
||||
if err := json.Unmarshal(payload, &event); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal event payload: %w", err)
|
||||
}
|
||||
return d.Dispatch(ctx, &event)
|
||||
}
|
||||
|
||||
// HandleAsyncTask processes a durable async event payload for compatibility with
|
||||
// older callers that only need payload validation.
|
||||
func HandleAsyncTask(ctx context.Context, payload []byte) error {
|
||||
var event ChannelEvent
|
||||
if err := json.Unmarshal(payload, &event); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal event payload: %w", err)
|
||||
}
|
||||
applogger.L().Infof("handling async task for event %s", event.Type)
|
||||
// TODO: Call dispatcher.Dispatch(ctx, &event) after asynq server setup in P10
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type workerDispatchListener struct {
|
||||
name string
|
||||
count atomic.Int32
|
||||
}
|
||||
|
||||
func (l *workerDispatchListener) Name() string { return l.name }
|
||||
|
||||
func (l *workerDispatchListener) OnEvent(ctx context.Context, event *ChannelEvent) error {
|
||||
l.count.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newChannelWorkerDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:channel-dispatcher-worker?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite db handle: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err := db.AutoMigrate(&model.BackgroundJob{}); err != nil {
|
||||
t.Fatalf("migrate background jobs: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM background_jobs")
|
||||
sqlDB.Close()
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDispatcherDispatchAsyncEnqueuesDurableJob(t *testing.T) {
|
||||
db := newChannelWorkerDB(t)
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC) }))
|
||||
dispatcher := NewDispatcher(wp)
|
||||
listener := &workerDispatchListener{name: "capture"}
|
||||
dispatcher.Register(listener)
|
||||
|
||||
event := NewChannelEvent(EventConversationCreated, ChannelWebWidget, 1, 2)
|
||||
if err := dispatcher.DispatchAsync(context.Background(), event); err != nil {
|
||||
t.Fatalf("dispatch async: %v", err)
|
||||
}
|
||||
if listener.count.Load() != 0 {
|
||||
t.Fatalf("listener ran synchronously before worker processed job")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", TaskTypeEventDispatch, model.BackgroundJobStatusQueued).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count jobs: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one queued dispatch job, got %d", count)
|
||||
}
|
||||
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
if err != nil || !processed {
|
||||
t.Fatalf("process dispatch job: processed=%v err=%v", processed, err)
|
||||
}
|
||||
if listener.count.Load() != 1 {
|
||||
t.Fatalf("listener was not called by durable dispatch job")
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,18 @@ package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
const TaskTypeEventListenerDispatch = "event:listener_dispatch"
|
||||
|
||||
// EventDispatcher wraps the channel.Dispatcher and adds:
|
||||
// - Sync/async split: sync listeners run immediately; async listeners are queued
|
||||
// - Event name routing: listeners subscribe to specific event names
|
||||
@@ -53,6 +59,7 @@ type EventDispatcher struct {
|
||||
channelDispatcher *channel.Dispatcher
|
||||
registry *ListenerRegistry
|
||||
entries map[string]listenerEntry // listener name → entry
|
||||
worker *worker.WorkerPool
|
||||
}
|
||||
|
||||
// NewEventDispatcher creates a new EventDispatcher wrapping the given
|
||||
@@ -65,6 +72,13 @@ func NewEventDispatcher(cd *channel.Dispatcher) *EventDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
func (ed *EventDispatcher) SetWorkerPool(wp *worker.WorkerPool) {
|
||||
ed.worker = wp
|
||||
if wp != nil {
|
||||
wp.Register(TaskTypeEventListenerDispatch, ed.performListenerJob)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterSync adds a sync-mode listener for the given event names.
|
||||
// If eventNames is empty, the listener receives all events.
|
||||
func (ed *EventDispatcher) RegisterSync(listener channel.EventListener, eventNames ...string) {
|
||||
@@ -112,14 +126,7 @@ func (ed *EventDispatcher) Dispatch(ctx context.Context, event *channel.ChannelE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Async: run in background goroutine
|
||||
go func(l channel.EventListener, e *channel.ChannelEvent) {
|
||||
asyncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := l.OnEvent(asyncCtx, e); err != nil {
|
||||
applogger.L().Errorf("async listener %s error on event %s: %v", l.Name(), e.Type, err)
|
||||
}
|
||||
}(entry.listener, event)
|
||||
ed.dispatchListenerAsync(ctx, entry.listener.Name(), event, "async listener")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,16 +143,48 @@ func (ed *EventDispatcher) DispatchAsync(ctx context.Context, event *channel.Cha
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
go func(l channel.EventListener, e *channel.ChannelEvent) {
|
||||
asyncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := l.OnEvent(asyncCtx, e); err != nil {
|
||||
applogger.L().Errorf("async dispatch: listener %s error on event %s: %v", l.Name(), e.Type, err)
|
||||
}
|
||||
}(entry.listener, event)
|
||||
ed.dispatchListenerAsync(ctx, entry.listener.Name(), event, "async dispatch")
|
||||
}
|
||||
}
|
||||
|
||||
type listenerJobPayload struct {
|
||||
ListenerName string `json:"listener_name"`
|
||||
Event *channel.ChannelEvent `json:"event"`
|
||||
}
|
||||
|
||||
func (ed *EventDispatcher) dispatchListenerAsync(ctx context.Context, listenerName string, event *channel.ChannelEvent, logPrefix string) {
|
||||
if ed.worker != nil {
|
||||
payload := listenerJobPayload{ListenerName: listenerName, Event: event}
|
||||
if _, err := ed.worker.Enqueue(ctx, TaskTypeEventListenerDispatch, payload, worker.WithQueue("events"), worker.WithMaxAttempts(3)); err != nil {
|
||||
applogger.L().Errorf("%s %s enqueue error on event %s: %v", logPrefix, listenerName, event.Type, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
entry := ed.entries[listenerName]
|
||||
go func(l channel.EventListener, e *channel.ChannelEvent) {
|
||||
asyncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := l.OnEvent(asyncCtx, e); err != nil {
|
||||
applogger.L().Errorf("%s: listener %s error on event %s: %v", logPrefix, l.Name(), e.Type, err)
|
||||
}
|
||||
}(entry.listener, event)
|
||||
}
|
||||
|
||||
func (ed *EventDispatcher) performListenerJob(ctx context.Context, job *model.BackgroundJob) error {
|
||||
var payload listenerJobPayload
|
||||
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal listener dispatch payload: %w", err)
|
||||
}
|
||||
entry, ok := ed.entries[payload.ListenerName]
|
||||
if !ok {
|
||||
return fmt.Errorf("listener %q not registered", payload.ListenerName)
|
||||
}
|
||||
if payload.Event == nil {
|
||||
return fmt.Errorf("listener %q job missing event", payload.ListenerName)
|
||||
}
|
||||
return entry.listener.OnEvent(ctx, payload.Event)
|
||||
}
|
||||
|
||||
// ChannelDispatcher returns the underlying channel.Dispatcher for direct access
|
||||
// if needed (e.g. for channel-level dispatch without the enhanced routing).
|
||||
func (ed *EventDispatcher) ChannelDispatcher() *channel.Dispatcher {
|
||||
@@ -155,4 +194,4 @@ func (ed *EventDispatcher) ChannelDispatcher() *channel.Dispatcher {
|
||||
// Registry returns the listener registry for inspection/testing.
|
||||
func (ed *EventDispatcher) Registry() *ListenerRegistry {
|
||||
return ed.registry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/channel"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type dispatchWorkerListener struct {
|
||||
name string
|
||||
count atomic.Int32
|
||||
}
|
||||
|
||||
func (l *dispatchWorkerListener) Name() string { return l.name }
|
||||
|
||||
func (l *dispatchWorkerListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error {
|
||||
l.count.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newDispatchWorkerDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:dispatch-worker?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite db handle: %v", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err := db.AutoMigrate(&model.BackgroundJob{}); err != nil {
|
||||
t.Fatalf("migrate background jobs: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DELETE FROM background_jobs")
|
||||
sqlDB.Close()
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestEventDispatcherQueuesAsyncListenersDurably(t *testing.T) {
|
||||
db := newDispatchWorkerDB(t)
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 11, 30, 0, 0, time.UTC) }))
|
||||
ed := NewEventDispatcher(channel.NewDispatcher())
|
||||
ed.SetWorkerPool(wp)
|
||||
syncListener := &dispatchWorkerListener{name: "sync-listener"}
|
||||
asyncListener := &dispatchWorkerListener{name: "async-listener"}
|
||||
ed.RegisterSync(syncListener, string(channel.EventConversationCreated))
|
||||
ed.RegisterAsync(asyncListener, string(channel.EventConversationCreated))
|
||||
|
||||
event := channel.NewChannelEvent(channel.EventConversationCreated, channel.ChannelWebWidget, 1, 2)
|
||||
if err := ed.Dispatch(context.Background(), event); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if syncListener.count.Load() != 1 {
|
||||
t.Fatalf("sync listener should run immediately")
|
||||
}
|
||||
if asyncListener.count.Load() != 0 {
|
||||
t.Fatalf("async listener should wait for durable worker")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", TaskTypeEventListenerDispatch, model.BackgroundJobStatusQueued).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count jobs: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected one queued listener dispatch job, got %d", count)
|
||||
}
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
if err != nil || !processed {
|
||||
t.Fatalf("process listener job: processed=%v err=%v", processed, err)
|
||||
}
|
||||
if asyncListener.count.Load() != 1 {
|
||||
t.Fatalf("async listener was not called by worker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventDispatcherDispatchAsyncQueuesAllMatchingListeners(t *testing.T) {
|
||||
db := newDispatchWorkerDB(t)
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return time.Date(2026, 6, 5, 11, 45, 0, 0, time.UTC) }))
|
||||
ed := NewEventDispatcher(channel.NewDispatcher())
|
||||
ed.SetWorkerPool(wp)
|
||||
one := &dispatchWorkerListener{name: "one"}
|
||||
two := &dispatchWorkerListener{name: "two"}
|
||||
ed.RegisterSync(one, string(channel.EventMessageCreated))
|
||||
ed.RegisterAsync(two, string(channel.EventMessageCreated))
|
||||
|
||||
ed.DispatchAsync(context.Background(), channel.NewChannelEvent(channel.EventMessageCreated, channel.ChannelAPI, 1, 2))
|
||||
for i := 0; i < 2; i++ {
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
if err != nil || !processed {
|
||||
t.Fatalf("process listener job %d: processed=%v err=%v", i, processed, err)
|
||||
}
|
||||
}
|
||||
if one.count.Load() != 1 || two.count.Load() != 1 {
|
||||
t.Fatalf("expected both listeners via durable jobs, got one=%d two=%d", one.count.Load(), two.count.Load())
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,6 @@ type Option func(*WorkerPool)
|
||||
func NewWorkerPool(db ...*gorm.DB) *WorkerPool {
|
||||
wp := &WorkerPool{
|
||||
handlers: make(map[string]JobHandler),
|
||||
queues: []string{model.DefaultBackgroundJobQueue},
|
||||
workerID: fmt.Sprintf("worker-%d", time.Now().UnixNano()),
|
||||
workerCount: 1,
|
||||
pollInterval: 500 * time.Millisecond,
|
||||
|
||||
Reference in New Issue
Block a user