イベントバス

イベントバスは、キューに入った pub/sub アクションを 1 つの dispatcher goroutine で処理し、一致するイベントを subscriber channel へ配信します。

Go のスニペットは実装および拡張の断片です。既存のコンポーネント context、logger、handler、アプリケーションイベント型を前提としています。

イベント構造

type Event struct {
    System string  // Component/module (e.g., "registry", "process")
    Kind   string  // Event type (e.g., "create", "update", "exit")
    Path   string  // Entity identifier
    Data   any     // Payload
    Aux    any     // In-process dispatcher context; not propagated to processes
}

バスアーキテクチャ

flowchart LR
    subgraph Publishers
        P1[Component]
        P2[Component]
    end

    subgraph Bus
        Q[actionQueue]
        D[dispatcher goroutine]
        S[subscribers map]
    end

    subgraph Subscribers
        S1[chan Event]
        S2[chan Event]
    end

    P1 & P2 -->|enqueue| Q
    Q -->|signal| D
    D -->|match & deliver| S1 & S2
    D <-->|manage| S

バスは単純な構造体に状態を格納します。

type Bus struct {
    subscribers       map[SubscriberID]sub
    subscriberCounter uint64
    maxSubscribers    int

    actionQueue []action
    spareQueue  []action
    actionMu    sync.Mutex
    actionReady chan struct{}  // buffered=1

    closed atomic.Bool
}

すべての変更は dispatcher goroutine を経由するため、複雑なロックなしで競合状態を排除できます。

アクション

キューには 4 種類のアクションが流れます。

アクション 動作
Subscribe subscriber をマップへ追加し、done channel へ応答
Unsubscribe subscriber を削除し、done channel へ応答
Send 一致する subscriber へイベントを配信
Stop subscriber を消去し、キューを drain してループを終了

Subscribe と Unsubscribe は dispatcher の確認までブロックします。Send は fire-and-forget です。バスは最大 DefaultMaxSubscribers 件(デフォルト 4096)の subscription を受け付けます。上限を超えた subscription は ErrSubscribersCapReached で失敗します。

バスがDefaultMaxSubscribers(4096)個のアクティブなサブスクリプションを保持すると、SubscribeはErrSubscribersCapReachedで拒否される。

Subscribeは、サブスクリプションのコンテキストが既にキャンセルされている場合は即座に失敗し、所有権の判断が下される前にキャンセルされた場合はディスパッチャ側で再度失敗する。バスは自身がインストールしていないチャネルを決して受け取らない。

Unsubscribeはベストエフォートのヒントではなく、所有権のバリアである。ディスパッチャが確認応答した後にのみ戻るため、呼び出し側はバスが送信中の参照を保持していないことを前提にチャネルを解放できる。Stopの後に到着した場合、確認応答はディスパッチャが既にドレイン済みのバッチの配信を終えるまで待機する。

Stopも同様に終端的である。並行する2回目のStopは、クローズ済みフラグを見て早期に戻ることはなく、ディスパッチャがドレインして終了するまで待機する。

キュースワッピング

dispatcher は、定常状態での allocation を避けるためスライスを交換します。

func (b *Bus) processActions() bool {
    b.actionMu.Lock()
    actions := b.actionQueue
    b.actionQueue = b.spareQueue[:0]
    b.spareQueue = nil
    b.actionMu.Unlock()

    for i := range actions {
        // process action
    }

    clear(actions)
    b.actionMu.Lock()
    b.spareQueue = actions[:0]
    b.actionMu.Unlock()
    return true
}

2 つのスライスが交互に使われます。一方は処理用、もう一方は新規到着用です。actionReady channel の buffer は 1 なので、signal はブロックせず、複数の enqueue は 1 回の wakeup にまとめられます。

パターンマッチング

subscription は subscribe 時に一度だけパターンをコンパイルします。

type sub struct {
    subID   SubscriberID
    ctx     context.Context
    system  *wildcard.Wildcard
    kind    *wildcard.Wildcard
    eventCh chan<- Event
}

wildcard パッケージは 4 種類のパターンに対応します。

パターン 一致対象
registry 完全一致のみ
* 任意の 1 セグメント
** 0 個以上のセグメント
(a|b) セグメント内の選択肢

パターンは . で分割されるため、registry.* は registry.create に一致しますが、registry.entry.create には一致しません。パターン registry.** は registry、registry.create、registry.entry.create のすべてに一致します。

イベント配信

Send の処理中、dispatcher は subscriber を反復処理します。

for id, s := range b.subscribers {
    if s.system != nil && !s.system.Match(a.event.System) {
        continue
    }
    if s.kind != nil && !s.kind.Match(a.event.Kind) {
        continue
    }

    select {
    case <-a.ctx.Done():
        goto cleanup
    case <-s.ctx.Done():
        expiredSubs = append(expiredSubs, id)
    case s.eventCh <- a.event:
    }
}

subscriber の context が cancel されている場合、その配信 pass 中に削除対象としてマークされます。イベントの context によって反復処理の途中で配信を cancel することもできます。

Lua プロセスブリッジ

events dispatcher は Go イベントを Lua プロセスへ bridge します。すべてのイベント("**")を一度 subscribe し、プロセスの subscription に基づいて内部ルーティングします。

type Dispatcher struct {
    bus    event.Bus
    node   relay.Node
    subID  SubscriberID
    eventC chan event.Event

    mu   sync.RWMutex
    subs map[string]*subscription  // topic -> subscription
}

Lua プロセスが events.subscribe() を介して subscribe すると、dispatcher はパターンと対象 PID を格納します。一致するイベントは package 化され、relay 経由で送信されます。

func (d *Dispatcher) routeEvent(evt event.Event) {
    d.mu.RLock()
    defer d.mu.RUnlock()

    for _, sub := range d.subs {
        if !matchPattern(sub.system, evt.System) {
            continue
        }
        if sub.kind != "" && sub.kind != "*" && !matchPattern(sub.kind, evt.Kind) {
            continue
        }

        data := map[string]any{
            "system": evt.System,
            "kind":   evt.Kind,
            "path":   evt.Path,
        }
        if evt.Data != nil {
            data["data"] = evt.Data
        }

        pkg := relay.NewPackage(pid.PID{}, sub.pid, sub.topic, payload.New(data))
        d.node.Send(pkg)
    }
}

ヘルパー型

Subscriber

channel subscription を callback でラップします。

handler, err := eventbus.NewSubscriber(ctx, bus, "registry", "entry.*",
    func(evt Event) {
        // handle
    })
if err != nil {
    return err
}
defer handler.Close()

2 つの goroutine を生成します。1 つはイベントを読み取って handler を呼び出し、もう 1 つは context の cancel を待って unsubscribe します。

EventRouter

一元化されたライフサイクルで複数の handler を管理します。

router, err := eventbus.StartRouter(ctx, bus,
    WithHandlers(handler1, handler2),
    WithLogger(log))
if err != nil {
    return err
}
defer router.Stop()

各 handler は Pattern() と Handle() を実装します。router は handler ごとに Subscriber を作成し、Stop 時にすべて閉じます。

AwaitService

pub/sub上でのリクエスト・レスポンス。(system, kind)ペアごとに単一のサブスクリプションを保持し、Pathによってイベントをwaiterにルーティング:

svc := eventbus.NewAwaitService(bus)
svc.Start(ctx)
defer svc.Stop()

waiter, _ := svc.Prepare(ctx, "test", "response.(accept|reject)", "test/path", 5*time.Second)
defer waiter.Close()

bus.Send(ctx, triggeringEvent)

result := waiter.Wait()  // AwaitResult{Event, Accepted, Error}を返す

Prepareはトリガーとなるイベントを送信する前にwaiterを登録し、待機の登録前にレスポンスが到着する競合状態を回避する。WaitはPathがマッチするイベントの到着、またはタイムアウト(非正の値の場合はデフォルトのDefaultAwaitTimeout、30秒)の満了までブロック。Acceptedはイベント種別がaccept、*.accept、*.acceptedのいずれかの場合にtrueとなり、それ以外の種別は拒否として扱われ、Data内のerrorはErrorとして返される。便宜的なAwait(ctx, system, kind, path, timeout)はPrepareとWaitを組み合わせたもの。ブートインフラストラクチャはAwaitServiceをコンテキストに登録する(event.GetAwaitService)。

シャットダウン

  1. Stop() が closed フラグを atomic に設定し、Stop アクションを enqueue
  2. Dispatcher が subscriber map を消去
  3. 残りのキュー済みアクションを drain
    • Subscribe リクエストは「bus is closed」エラーを受信
    • Unsubscribe リクエストは即座に完了
    • Send イベントは破棄
  4. WaitGroup が完了

関連項目