Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
148 lines
4.7 KiB
Go
148 lines
4.7 KiB
Go
package channel
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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)
|
|
const TaskTypeEventDispatch = "event:dispatch_async"
|
|
|
|
// EventListener is the interface that all event listeners must implement.
|
|
// Reference: Chatwoot's Wisper listener pattern — each listener subscribes to specific event names
|
|
// and runs its logic when the event is broadcast.
|
|
type EventListener interface {
|
|
// Name returns the unique identifier for this listener.
|
|
Name() string
|
|
|
|
// OnEvent processes the given event.
|
|
// Implementations should be idempotent and handle errors gracefully.
|
|
OnEvent(ctx context.Context, event *ChannelEvent) error
|
|
}
|
|
|
|
// Dispatcher is the central event dispatch hub.
|
|
// Reference: Chatwoot Dispatcher + Listener pattern — replaces both SyncDispatcher and AsyncDispatcher.
|
|
//
|
|
// Design:
|
|
// - Dispatch calls registered listeners synchronously.
|
|
// - DispatchAsync will be implemented via asynq in P10.
|
|
// - Listeners are registered once (startup) and called for every matching event type.
|
|
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(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.
|
|
func (d *Dispatcher) Register(listener EventListener) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
d.listeners[listener.Name()] = listener
|
|
applogger.L().Infof("registered event listener: %s", listener.Name())
|
|
}
|
|
|
|
// Unregister removes a listener by name.
|
|
func (d *Dispatcher) Unregister(name string) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
delete(d.listeners, name)
|
|
applogger.L().Infof("unregistered event listener: %s", name)
|
|
}
|
|
|
|
// Dispatch broadcasts an event to all registered listeners synchronously.
|
|
// Reference: Chatwoot SyncDispatcher — Wisper.broadcast(event_name, *args)
|
|
// Errors are logged but do not stop propagation to other listeners.
|
|
func (d *Dispatcher) Dispatch(ctx context.Context, event *ChannelEvent) error {
|
|
d.mu.RLock()
|
|
listeners := make([]EventListener, 0, len(d.listeners))
|
|
for _, l := range d.listeners {
|
|
listeners = append(listeners, l)
|
|
}
|
|
d.mu.RUnlock()
|
|
|
|
applogger.L().Infof("dispatching event %s to %d listeners", event.Type, len(listeners))
|
|
|
|
var firstErr error
|
|
for _, listener := range listeners {
|
|
if err := listener.OnEvent(ctx, event); err != nil {
|
|
applogger.L().Errorf("listener %s failed on event %s: %v", listener.Name(), event.Type, err)
|
|
if firstErr == nil {
|
|
firstErr = err
|
|
}
|
|
// Continue dispatching to remaining listeners (Chatwoot Wisper pattern)
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
// DispatchAsync enqueues an event for async processing.
|
|
// When no durable worker is configured, it preserves the legacy synchronous fallback.
|
|
func (d *Dispatcher) DispatchAsync(ctx context.Context, event *ChannelEvent) error {
|
|
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)
|
|
}
|
|
|
|
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)
|
|
return nil
|
|
}
|