Files
dingtalk-workspace-cli/internal/event/bus/hub.go

473 lines
14 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package bus
import (
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// DefaultSendBuffer is the per-consumer channel capacity used when the Hub
// is constructed via NewHub. Sized to absorb a short event burst (~100ms at
// ~1k evt/s) without backpressure. Overridable via DWS_EVENT_CONSUMER_BUFFER
// at daemon start (plan §15 已决但暴露方式).
const DefaultSendBuffer = 100
// Consumer represents one registered IPC connection to the bus. Wire-side
// reader/writer goroutines are owned by daemon.go; the Hub holds the
// metadata + sendCh.
type Consumer struct {
ID int // monotonic, assigned by Hub
PID int // from Hello.ConsumerPID
EventTypes []string // raw wildcard patterns from Hello
Filter string // raw regex from Hello (for status display)
SubscribeID string // optional personal subscription label and local isolation key
SubscribedAt time.Time
SendCh chan any // bus → consume frames (Event/SourceState/Heartbeat/Bye)
StopCh chan string // targeted local stop reason; consumed by daemon writer
matcher consumerMatcher
sendMu sync.Mutex // serialises Deliver/Broadcast with SendCh close
closed bool // guarded by sendMu
seq atomic.Uint64 // monotonic per-consumer sequence, starts at 1
received atomic.Uint64
dropped atomic.Uint64
}
// consumerMatcher pre-compiles EventTypes wildcard patterns and the optional
// Filter regex into a fast checker invoked once per delivered event per
// consumer. Empty EventTypes means catch-all (everything matches except
// what Filter excludes). Non-empty SubscribeID is an additional exact-match
// constraint used by personal_stream consumers so same event_type subscriptions
// do not fan out to each other.
type consumerMatcher struct {
catchAll bool
exact map[string]struct{} // patterns without '*'
prefixes []string // patterns ending in ".*" or "*" — store the prefix only
filter *regexp.Regexp // nil if no filter
subscribeID string // empty = no subscribe_id filtering
}
func compileMatcher(eventTypes []string, filter string, subscribeID string) (consumerMatcher, error) {
m := consumerMatcher{
exact: make(map[string]struct{}),
subscribeID: strings.TrimSpace(subscribeID),
}
if len(eventTypes) == 0 {
m.catchAll = true
}
for _, raw := range eventTypes {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
// Treat trailing '*' or '.*' as a prefix wildcard (e.g. "im.*",
// "im.message.*"). Middle/leading wildcards are rare for event_type
// strings — if a real use case appears we can switch to regex
// compilation here without breaking the wire format.
switch {
case raw == "*":
m.catchAll = true
case strings.HasSuffix(raw, ".*"):
m.prefixes = append(m.prefixes, raw[:len(raw)-1]) // keep the dot, drop the *
case strings.HasSuffix(raw, "*"):
m.prefixes = append(m.prefixes, raw[:len(raw)-1])
default:
m.exact[raw] = struct{}{}
}
}
if filter != "" {
re, err := regexp.Compile(filter)
if err != nil {
return consumerMatcher{}, err
}
m.filter = re
}
return m, nil
}
func (m *consumerMatcher) matches(raw *dwsevent.RawEvent) bool {
if raw == nil {
return false
}
if m.subscribeID != "" && raw.SubscribeID != m.subscribeID {
return false
}
eventType := raw.EventType
// First the include rules: catchAll OR exact-list OR prefix-list.
included := m.catchAll
if !included {
if _, ok := m.exact[eventType]; ok {
included = true
}
}
if !included {
for _, p := range m.prefixes {
if strings.HasPrefix(eventType, p) {
included = true
break
}
}
}
if !included {
return false
}
// Filter is an additional AND constraint (regex on the event_type
// string). Empty filter is no-op.
if m.filter != nil && !m.filter.MatchString(eventType) {
return false
}
return true
}
// Hub is the bus's fan-out engine. It owns the set of registered consumers
// and the bus-wide per-event-type counters. The Hub is concurrency-safe
// across Register/Unregister/Deliver/Snapshot; Deliver is the hot path and
// is RLock-only.
type Hub struct {
mu sync.RWMutex
consumers map[int]*Consumer
nextID int
bufferSize int
counters *PerTypeCounters
}
// NewHub returns a Hub with the given per-consumer channel buffer size.
// Zero or negative uses DefaultSendBuffer.
func NewHub(bufferSize int) *Hub {
if bufferSize <= 0 {
bufferSize = DefaultSendBuffer
}
return &Hub{
consumers: make(map[int]*Consumer),
bufferSize: bufferSize,
counters: NewPerTypeCounters(),
}
}
// Counters exposes the bus-wide per-event-type counter set for daemon-side
// rendering (status RPC, drop-rate warning).
func (h *Hub) Counters() *PerTypeCounters { return h.counters }
// RegisterError wraps the matcher compile error so the daemon can refuse
// the Hello and return a clean error to the consume client (instead of
// silently accepting a bad filter regex).
type RegisterError struct{ Err error }
func (e *RegisterError) Error() string { return "bus: register consumer: " + e.Err.Error() }
func (e *RegisterError) Unwrap() error { return e.Err }
// Register adds a consumer derived from a Hello frame. Returns a new
// Consumer with the populated ID + sendCh ready to use, or a RegisterError
// if the Hello's Filter regex is invalid.
func (h *Hub) Register(hello transport.Hello) (*Consumer, error) {
m, err := compileMatcher(hello.EventTypes, hello.Filter, hello.SubscribeID)
if err != nil {
return nil, &RegisterError{Err: err}
}
h.mu.Lock()
defer h.mu.Unlock()
h.nextID++
c := &Consumer{
ID: h.nextID,
PID: hello.ConsumerPID,
EventTypes: append([]string(nil), hello.EventTypes...),
Filter: hello.Filter,
SubscribeID: strings.TrimSpace(hello.SubscribeID),
SubscribedAt: time.Now().UTC(),
SendCh: make(chan any, h.bufferSize),
StopCh: make(chan string, 1),
matcher: m,
}
h.consumers[c.ID] = c
return c, nil
}
// StopConsumers requests a graceful close for every consumer whose exact
// SubscribeID appears in subscribeIDs. The daemon writer owns the wire, so
// this method signals StopCh instead of writing or closing SendCh directly.
func (h *Hub) StopConsumers(subscribeIDs []string, reason string) []string {
targets := make(map[string]struct{}, len(subscribeIDs))
for _, id := range subscribeIDs {
if id = strings.TrimSpace(id); id != "" {
targets[id] = struct{}{}
}
}
if len(targets) == 0 {
return nil
}
if strings.TrimSpace(reason) == "" {
reason = transport.ByeReasonSubscriptionStopped
}
matched := make(map[string]struct{}, len(targets))
stopTargets := make([]*Consumer, 0, len(targets))
h.mu.RLock()
for _, c := range h.consumers {
id := strings.TrimSpace(c.SubscribeID)
if _, ok := targets[id]; !ok {
continue
}
matched[id] = struct{}{}
stopTargets = append(stopTargets, c)
}
h.mu.RUnlock()
for _, target := range stopTargets {
select {
case target.StopCh <- reason:
default:
// A stop is already queued for this consumer.
}
}
out := make([]string, 0, len(matched))
for id := range matched {
out = append(out, id)
}
sort.Strings(out)
return out
}
// StopAll requests a high-priority graceful close for every live consumer.
// Unlike Broadcast(Bye), this uses the writer's priority StopCh and therefore
// cannot sit behind a full event buffer during a terminal source failure.
func (h *Hub) StopAll(reason string) int {
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "shutdown"
}
h.mu.RLock()
consumers := make([]*Consumer, 0, len(h.consumers))
for _, consumer := range h.consumers {
consumers = append(consumers, consumer)
}
h.mu.RUnlock()
stopped := 0
for _, consumer := range consumers {
select {
case consumer.StopCh <- reason:
stopped++
default:
}
}
return stopped
}
// Unregister removes a consumer by ID and closes its sendCh. Idempotent —
// calling twice or on an unknown ID is a no-op. closeSend shares the same
// per-consumer lock as Deliver/Broadcast, so a stale Hub snapshot cannot send
// to the channel after it has been closed.
func (h *Hub) Unregister(id int) {
h.mu.Lock()
c, ok := h.consumers[id]
if !ok {
h.mu.Unlock()
return
}
delete(h.consumers, id)
h.mu.Unlock()
c.closeSend()
}
// Deliver fans the raw event out to every matching consumer. Updates
// bus-wide and per-consumer counters. Always non-blocking — drops the
// oldest entry in any full sendCh (plan invariant #1).
//
// Called from the bus daemon's main loop after dedup; safe for concurrent
// callers (Hub uses an RLock, Consumer drop-oldest is single-producer-safe
// because the daemon serialises Deliver per event).
func (h *Hub) Deliver(raw *dwsevent.RawEvent) {
if raw == nil {
return
}
h.counters.AddReceived(raw.EventType)
h.mu.RLock()
matched := make([]*Consumer, 0, len(h.consumers))
for _, c := range h.consumers {
if c.matcher.matches(raw) {
matched = append(matched, c)
}
}
h.mu.RUnlock()
for _, c := range matched {
c.deliver(raw, h.counters)
}
}
// deliver builds the per-consumer Event frame (assigning seq) and pushes
// it onto sendCh with drop-oldest semantics. Updates per-consumer and
// bus-wide drop counters.
func (c *Consumer) deliver(raw *dwsevent.RawEvent, hubCounters *PerTypeCounters) {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if c.closed {
return
}
seq := c.seq.Add(1)
frame := transport.Event{
Type: transport.FrameTypeEvent,
Seq: seq,
EventID: raw.EventID,
EventBornTime: raw.EventBornTime,
EventCorpID: raw.EventCorpID,
EventType: raw.EventType,
EventUnifiedAppID: raw.EventUnifiedAppID,
EventScope: raw.EventScope,
SubscribeID: raw.SubscribeID,
SourceID: raw.SourceID,
RuleType: raw.RuleType,
Data: raw.Data,
Headers: raw.Headers,
ReceivedAtUnixMS: raw.ReceivedAt.UnixMilli(),
}
success, evicted := c.tryPushOrDropOldestLocked(frame)
if evicted {
// An older event we had previously enqueued is gone.
c.dropped.Add(1)
c.received.Add(^uint64(0)) // -1
hubCounters.AddDropped(raw.EventType)
}
if success {
c.received.Add(1)
} else {
// New event also didn't make it (rare: lost the race after eviction)
c.dropped.Add(1)
if !evicted {
// No eviction happened but push still failed → unexpected; only
// reached via concurrent reader-then-something. Count the bus-wide
// drop too so the metric matches per-consumer drops.
hubCounters.AddDropped(raw.EventType)
}
}
}
// tryPushOrDropOldestLocked tries to push frame to SendCh non-blockingly. If
// full, it pops the oldest entry to make room and tries once more. Caller must
// hold c.sendMu, which makes drop-oldest a true single-producer operation even
// when Deliver and Broadcast run concurrently.
//
// Returns:
//
// success: true if the new frame is now in the channel
// evicted: true if we removed an older queued frame to make room
//
// "received" semantics in deliver() interpret these:
//
// success=true, evicted=false → +1 received (normal push)
// success=true, evicted=true → net 0 (lost 1, gained 1); +1 dropped
// success=false, evicted=true → -1 received, +2 dropped (both lost; rare)
// success=false, evicted=false → only possible if reader drained between
// try and we still missed (extremely rare); +1 dropped only.
//
// The per-consumer send lock makes this block the sole SendCh producer, so the
// second push cannot race another producer. The transport writer may receive
// between our two operations, which only makes more room — never less.
func (c *Consumer) tryPushOrDropOldestLocked(frame any) (success bool, evicted bool) {
select {
case c.SendCh <- frame:
return true, false
default:
}
// Drop oldest, then retry.
select {
case <-c.SendCh:
evicted = true
default:
// Reader took it, room is back.
}
select {
case c.SendCh <- frame:
return true, evicted
default:
return false, evicted
}
}
func (c *Consumer) enqueue(frame any) bool {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if c.closed {
return false
}
success, _ := c.tryPushOrDropOldestLocked(frame)
return success
}
func (c *Consumer) closeSend() {
c.sendMu.Lock()
defer c.sendMu.Unlock()
if c.closed {
return
}
c.closed = true
close(c.SendCh)
}
// Broadcast sends the same frame (e.g. SourceState change, Bye on shutdown)
// to every consumer using drop-oldest semantics. Returns the number of
// consumers the frame was successfully enqueued for.
func (h *Hub) Broadcast(frame any) int {
h.mu.RLock()
cs := make([]*Consumer, 0, len(h.consumers))
for _, c := range h.consumers {
cs = append(cs, c)
}
h.mu.RUnlock()
ok := 0
for _, c := range cs {
if c.enqueue(frame) {
ok++
}
}
return ok
}
// Snapshot returns a deterministic StatusConsumer slice (sorted by PID)
// for status RPC encoding. Caller MUST NOT mutate the returned slice.
func (h *Hub) Snapshot() []transport.StatusConsumer {
h.mu.RLock()
defer h.mu.RUnlock()
out := make([]transport.StatusConsumer, 0, len(h.consumers))
for _, c := range h.consumers {
out = append(out, transport.StatusConsumer{
PID: c.PID,
EventTypes: append([]string(nil), c.EventTypes...),
Filter: c.Filter,
SubscribeID: c.SubscribeID,
SubscribedAtMS: c.SubscribedAt.UnixMilli(),
Received: c.received.Load(),
Dropped: c.dropped.Load(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// Len returns the current number of registered consumers.
func (h *Hub) Len() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.consumers)
}