473 lines
14 KiB
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)
|
|
}
|