Files
multica/server/internal/integrations/channel/engine/batcher_test.go
Bohan Jiang 3e21e58df0 feat(channel): channel-agnostic engine (Supervisor + Router); Feishu as channel.Channel (MUL-3620) (#4512)
* feat(channel): add channel-agnostic engine Supervisor (MUL-3620)

Stage-1 (MUL-3515) shipped the channel abstraction but nothing drove it.
Add the generic engine that does:

- channel.InboundHandler + Config.Handler: the single shared inbound entry
  the engine injects into every adapter (Hermes set_message_handler model).
- channel.Channel.Connect now blocks for the connection lifetime (doc), so
  the supervisor can tie lease renewal to connection liveness.
- new package channel/engine: Supervisor, generalized out of lark.Hub. It
  enumerates active installations across ALL channel types (no hard-coded
  feishu), fences each behind the WS lease CAS, builds the platform Channel
  via channel.Registry, drives Connect/Disconnect with backoff+jitter, and
  restarts on credential rotation. Knows nothing about any platform.

channel.Channel is now driven by an engine; integrations/channel has an
external consumer. Feishu adapter + boot cutover follow next.

Tests: supervisor_test.go covers lease CAS, reclaim, reap-on-revoke,
rotation restart + token fencing, backoff on build error, lease-loss
teardown, bounded release, shutdown timeout. Race-clean.

Co-authored-by: multica-agent <github@multica.ai>

* feat(lark): drive Feishu through the channel engine; remove lark.Hub (MUL-3620)

Refactor Feishu into the first channel.Channel and cut boot over to the
channel-agnostic engine.Supervisor, removing the Feishu-only Hub.

- feishuChannel implements channel.Channel: Connect runs the existing
  WS long-conn connector for one installation; Send posts a text reply
  via the Lark IM API; Capabilities declares Feishu's feature set.
  RegisterFeishu wires it to channel.TypeFeishu — adding a platform is
  now 'register a Factory', no engine edit.
- FeishuRuntime extracts the former Hub.handleEvent / scheduleReply:
  runs the Dispatcher and drives the detached typing indicator +
  OutcomeReplier off the connector ACK path. main.go drains it on
  shutdown after the supervisor stops delivering events.
- channelInstallationStore (engine.InstallationStore) enumerates active
  installations across ALL channel types via the new de-hardcoded query
  ListAllActiveChannelInstallations; the Supervisor routes each row to
  its registered Factory by channel_type. Generic per-row fingerprint
  replaces the feishu-specific one.
- boot: engine.Supervisor replaces lark.Hub.Run; MULTICA_LARK_HUB_DISABLED
  keeps its name for runbook compatibility.
- delete hub.go / hub_pgx.go / hub_test.go; relocate the connector
  contract (EventConnector/EventEmitter), uuidString, and the reply-path
  tests (-> feishu_runtime_test.go) so coverage is preserved.

No channel_* schema change. Feishu behaviour unchanged; lark + channel +
engine tests green under -race; go build/vet ./... clean.

Remaining (follow-up): lift the Dispatcher pipeline into a channel-
agnostic engine.Router over channel.InboundMessage + resolver interfaces,
so the inbound core stops being Lark-shaped and adding a channel needs
zero core edits (validated by Slack, MUL-3516).

Co-authored-by: multica-agent <github@multica.ai>

* feat(channel): add channel-agnostic engine.Router (inbound pipeline) (MUL-3620)

Generalize lark.Dispatcher's inbound pipeline into engine.Router: the single
shared channel.InboundHandler the Supervisor injects into every Channel. It
routes by ChannelType to a registered ResolverSet and runs the same ordered
pipeline for every platform (install route -> two-phase dedup -> group @bot
filter -> identity+membership -> ensure session -> append+mark -> /issue ->
debounced run), then drives the detached OutboundReplier + typing indicator.

Platform specifics live behind resolver interfaces (InstallationResolver,
IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier,
TypingNotifier) + shared services (IssueCreator/TaskEnqueuer/SessionReader).
Adding a platform is 'register a ResolverSet', not 'edit the Router'. Outcome
/ DropReason values match the legacy lark ones 1:1.

Additive: lark.Dispatcher untouched and still wired; the feishu ResolverSet,
the cutover, and the old-path removal land next. channel.InboundMessage gains
ForceFresh (the normalized /fresh affordance). Batcher moved into engine.

router_test.go covers the pipeline invariants (routing, dedup finalize
states, group filter, identity, membership, ensure/append, /issue, debounce,
flush offline, force-fresh, drain) with generic fakes; race-clean.

Co-authored-by: multica-agent <github@multica.ai>

* feat(lark): cut Feishu over to engine.Router; remove lark.Dispatcher; core no longer Lark-shaped (MUL-3620)

Wire the channel-agnostic engine.Router (added in the prior commit) as the
shared inbound handler and refactor Feishu into a ResolverSet, completing the
generic-engine cutover. The inbound core (engine.Router) now contains zero
platform specifics.

- Feishu ResolverSet (feishu_resolvers.go): InstallationResolver,
  IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier,
  TypingNotifier — each backed by the existing ChannelStore / ChatSessionService
  / OutcomeReplier / typing indicator, translating at the channel.InboundMessage
  boundary (platform fields read from Raw). origin_type stays 'lark_chat'.
- feishuChannel now produces a normalized channel.InboundMessage and hands it to
  the engine handler via channel.Config.Handler; the old Raw round-trip through
  lark.Dispatcher is gone.
- Remove lark.Dispatcher, FeishuRuntime, and lark's pending_batcher (the engine
  owns the pipeline + batcher now); their behavioural coverage moved to
  engine.Router tests. Surviving native types (InboundMessage / Outcome /
  DispatchResult) relocated to feishu_types.go.

elon review nits addressed:
- The channel engine (Registry + Router + Supervisor) is now built
  UNCONDITIONALLY, outside the MULTICA_LARK_SECRET_KEY gate, so a non-Lark
  deployment runs it; Feishu registers its Factory + ResolverSet only when its
  key is present.
- channel.Config.Raw is now genuinely the platform config JSONB
  (channel_installation.config): the feishu factory builds a credentials-only
  Installation from it, and the workspace/agent identity is resolved per message
  by the Router — no full-db-row marshaling.
- feishuChannel gains direct unit tests: factory config decode, Send text +
  reply-target mapping, Capabilities, inbound normalization + Raw round-trip,
  msg-type + result mapping.

No channel_* schema change. go build/vet ./... clean; channel + engine + lark
green under -race. Feishu behaviour preserved (pipeline logic lifted verbatim,
only generalized).

Co-authored-by: multica-agent <github@multica.ai>

* docs(channel): fix stale comments on the channel engine boot (MUL-3620)

Address Elon's review nit: three comments still described the pre-cutover
behavior.

- handler.go: ChannelSupervisor is built UNCONDITIONALLY now, not nil when
  MULTICA_LARK_SECRET_KEY is unset.
- main.go: same — the supervisor always exists; only MULTICA_LARK_HUB_DISABLED
  parks it.
- router.go: with no platform registered the store still lists active rows;
  Registry.Build returns ErrUnknownType and the supervisor backs off (it does
  not 'find no installations').

Comment-only; no behavior change.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 17:01:33 +08:00

184 lines
4.4 KiB
Go

package engine
import (
"sync"
"testing"
"time"
)
// fakeBatchTimer is a manually-fired stand-in for *time.Timer so debounce
// behaviour can be asserted without sleeping real wall-clock seconds.
type fakeBatchTimer struct {
fn func()
stopped bool
fired bool
}
func (t *fakeBatchTimer) Stop() bool {
if t.stopped || t.fired {
return false
}
t.stopped = true
return true
}
// fakeTimerFactory hands out fakeBatchTimers and lets a test fire whichever are
// currently armed — modelling the wall clock advancing past the window.
type fakeTimerFactory struct {
mu sync.Mutex
all []*fakeBatchTimer
}
func (f *fakeTimerFactory) after(_ time.Duration, fn func()) stoppableTimer {
f.mu.Lock()
defer f.mu.Unlock()
t := &fakeBatchTimer{fn: fn}
f.all = append(f.all, t)
return t
}
func (f *fakeTimerFactory) fireArmed() {
f.mu.Lock()
armed := make([]*fakeBatchTimer, 0, len(f.all))
for _, t := range f.all {
if !t.stopped && !t.fired {
t.fired = true
armed = append(armed, t)
}
}
f.mu.Unlock()
for _, t := range armed {
t.fn()
}
}
func (f *fakeTimerFactory) armedCount() int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, t := range f.all {
if !t.stopped && !t.fired {
n++
}
}
return n
}
// newTestBatcher builds a batcher whose timers are driven by f. Shared with
// router_test.go (same package) to drive the debounce coalescing test.
func newTestBatcher(f *fakeTimerFactory) *pendingBatcher {
return &pendingBatcher{
window: DefaultChatRunBatchWindow,
afterFunc: f.after,
pending: make(map[string]*pendingEntry),
}
}
func TestPendingBatcher_DebounceCoalesces(t *testing.T) {
f := &fakeTimerFactory{}
b := newTestBatcher(f)
calls := 0
flush := func() { calls++ }
b.Schedule("s", flush)
b.Schedule("s", flush)
b.Schedule("s", flush)
if got := b.pendingCount(); got != 1 {
t.Fatalf("three Schedules on one session must keep a single pending entry; got %d", got)
}
if got := f.armedCount(); got != 1 {
t.Fatalf("each reschedule must cancel the prior timer, leaving one armed; got %d", got)
}
f.fireArmed()
if calls != 1 {
t.Fatalf("a debounced burst must flush exactly once; got %d", calls)
}
if got := b.pendingCount(); got != 0 {
t.Fatalf("the session entry must be cleaned up after flush; pending=%d", got)
}
}
func TestPendingBatcher_MultiSessionIndependent(t *testing.T) {
f := &fakeTimerFactory{}
b := newTestBatcher(f)
var a, c int
b.Schedule("a", func() { a++ })
b.Schedule("c", func() { c++ })
if got := b.pendingCount(); got != 2 {
t.Fatalf("two distinct sessions must hold two windows; got %d", got)
}
f.fireArmed()
if a != 1 || c != 1 {
t.Fatalf("each session must flush once and not cross-talk; a=%d c=%d", a, c)
}
}
func TestPendingBatcher_StaleTimerFireIsNoop(t *testing.T) {
f := &fakeTimerFactory{}
b := newTestBatcher(f)
calls := 0
b.Schedule("s", func() { calls++ })
first := f.all[0]
b.Schedule("s", func() { calls++ }) // resets: cancels first, arms a new timer
first.fired = true
first.fn()
if calls != 0 {
t.Fatalf("a superseded timer firing must not flush; got %d", calls)
}
f.fireArmed()
if calls != 1 {
t.Fatalf("the live timer must still flush exactly once; got %d", calls)
}
}
func TestPendingBatcher_FlushAllDrainsPending(t *testing.T) {
f := &fakeTimerFactory{}
b := newTestBatcher(f)
var a, c int
b.Schedule("a", func() { a++ })
b.Schedule("c", func() { c++ })
b.FlushAll()
if a != 1 || c != 1 {
t.Fatalf("FlushAll must flush every pending session once; a=%d c=%d", a, c)
}
if got := b.pendingCount(); got != 0 {
t.Fatalf("FlushAll must clear pending state; got %d", got)
}
ran := false
b.Schedule("d", func() { ran = true })
if !ran {
t.Fatalf("Schedule after FlushAll must run inline")
}
}
func TestNewPendingBatcher_DefaultsWindow(t *testing.T) {
if b := newPendingBatcher(0); b.window != DefaultChatRunBatchWindow {
t.Fatalf("non-positive window must default to %v; got %v", DefaultChatRunBatchWindow, b.window)
}
if b := newPendingBatcher(500 * time.Millisecond); b.window != 500*time.Millisecond {
t.Fatalf("explicit window must be honoured; got %v", b.window)
}
}
func TestPendingBatcher_RealTimerFlushes(t *testing.T) {
b := newPendingBatcher(15 * time.Millisecond)
done := make(chan struct{})
b.Schedule("s", func() { close(done) })
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("real-timer flush did not fire within 2s")
}
}