Files
gochat/backend/internal/channel/dispatcher.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
2026-07-07 14:44:12 +08:00

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
}