mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Framework support for resource-scoped event subscriptions, so one EventKey can fan out into independent per-resource subscription scopes: - KeyDefinition gains SubscriptionKey / NormalizeParams / Match hooks - ComputeSubscriptionID derives a dedup identity from (EventKey, sub-key params); plumbed through bus Hub, consume loop, and the Hello / PreShutdownCheck / ConsumerInfo protocol messages - add a synchronous Match filter stage before Process - change PreConsume cleanup to func() error and surface cleanup (unsubscribe) failures as WARN with an idempotency note - adapt minutes/vc/whiteboard PreConsume to the new cleanup signature - render SubscriptionID / SubscriptionKey in event status & schema output No domain wires these hooks yet; covered by unit tests using bus/protocol doubles. (Mail, the original exerciser, is intentionally not included.) Change-Id: Ifc743f1aa0bc4dff0c8a1e35da24883694fe7699
308 lines
8.4 KiB
Go
308 lines
8.4 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package consume
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/event"
|
|
"github.com/larksuite/cli/internal/event/protocol"
|
|
)
|
|
|
|
func echoKeyDef(key string) *event.KeyDefinition {
|
|
return &event.KeyDefinition{
|
|
Key: key,
|
|
EventType: key,
|
|
BufferSize: 32,
|
|
Workers: 1,
|
|
Process: func(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
|
return raw.Payload, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func busSide(t *testing.T, server net.Conn, events []*protocol.Event, ackLast bool) {
|
|
t.Helper()
|
|
for _, evt := range events {
|
|
if err := protocol.Encode(server, evt); err != nil {
|
|
return
|
|
}
|
|
}
|
|
br := bufio.NewReader(server)
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
_ = server.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
|
line, err := protocol.ReadFrame(br)
|
|
if err != nil {
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
continue
|
|
}
|
|
return
|
|
}
|
|
msg, decErr := protocol.Decode(bytes.TrimRight(line, "\n"))
|
|
if decErr != nil {
|
|
continue
|
|
}
|
|
if _, ok := msg.(*protocol.PreShutdownCheck); ok {
|
|
_ = protocol.Encode(server, protocol.NewPreShutdownAck(ackLast))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
events := []*protocol.Event{
|
|
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)),
|
|
protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"n":2}`)),
|
|
}
|
|
go busSide(t, server, events, true)
|
|
|
|
var stdout bytes.Buffer
|
|
opts := Options{
|
|
EventKey: "test.key",
|
|
Out: &stdout,
|
|
ErrOut: io.Discard,
|
|
Quiet: true,
|
|
MaxEvents: 2,
|
|
}
|
|
|
|
var lastForKey bool
|
|
var emitted atomic.Int64
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
|
if err != nil {
|
|
t.Fatalf("consumeLoop: %v", err)
|
|
}
|
|
if got := emitted.Load(); got != 2 {
|
|
t.Errorf("emitted = %d, want 2", got)
|
|
}
|
|
if !lastForKey {
|
|
t.Error("lastForKey = false, want true (bus acked LastForKey=true)")
|
|
}
|
|
out := stdout.String()
|
|
for _, want := range []string{`{"n":1}`, `{"n":2}`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("stdout missing %q; full:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
events := []*protocol.Event{
|
|
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"n":1}`)),
|
|
protocol.NewEvent("test.evt", "e5", "", 5, json.RawMessage(`{"n":5}`)),
|
|
}
|
|
go busSide(t, server, events, true)
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
opts := Options{
|
|
EventKey: "test.key",
|
|
Out: &stdout,
|
|
ErrOut: &stderr,
|
|
Quiet: false,
|
|
MaxEvents: 2,
|
|
}
|
|
|
|
var lastForKey bool
|
|
var emitted atomic.Int64
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
|
t.Fatalf("consumeLoop: %v", err)
|
|
}
|
|
if got := emitted.Load(); got != 2 {
|
|
t.Errorf("emitted = %d, want 2", got)
|
|
}
|
|
if !strings.Contains(stderr.String(), "WARN: event seq gap 1->5") {
|
|
t.Errorf("stderr missing seq-gap warning; got:\n%s", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
events := []*protocol.Event{
|
|
protocol.NewEvent("test.evt", "e1", "", 1, json.RawMessage(`{"keep":true,"n":1}`)),
|
|
protocol.NewEvent("test.evt", "e2", "", 2, json.RawMessage(`{"keep":false,"n":2}`)),
|
|
}
|
|
go busSide(t, server, events, true)
|
|
|
|
var stdout bytes.Buffer
|
|
opts := Options{
|
|
EventKey: "test.key",
|
|
Out: &stdout,
|
|
ErrOut: io.Discard,
|
|
Quiet: true,
|
|
JQExpr: "select(.keep) | .n",
|
|
MaxEvents: 1,
|
|
}
|
|
|
|
var lastForKey bool
|
|
var emitted atomic.Int64
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil {
|
|
t.Fatalf("consumeLoop: %v", err)
|
|
}
|
|
if got := emitted.Load(); got != 1 {
|
|
t.Errorf("emitted = %d, want 1", got)
|
|
}
|
|
out := strings.TrimSpace(stdout.String())
|
|
if out != "1" {
|
|
t.Errorf("stdout = %q, want %q", out, "1")
|
|
}
|
|
}
|
|
|
|
func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) {
|
|
client, server := net.Pipe()
|
|
defer client.Close()
|
|
defer server.Close()
|
|
|
|
opts := Options{
|
|
EventKey: "test.key",
|
|
Out: io.Discard,
|
|
ErrOut: io.Discard,
|
|
Quiet: true,
|
|
JQExpr: "not a valid jq expression (((",
|
|
}
|
|
|
|
var lastForKey bool
|
|
var emitted atomic.Int64
|
|
err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted)
|
|
if err == nil {
|
|
t.Fatal("consumeLoop should fail immediately on bad jq expression")
|
|
}
|
|
}
|
|
|
|
// captureSink is a minimal Sink for unit-testing processAndOutput directly.
|
|
type captureSink struct {
|
|
written []json.RawMessage
|
|
}
|
|
|
|
func (s *captureSink) Write(data json.RawMessage) error {
|
|
s.written = append(s.written, data)
|
|
return nil
|
|
}
|
|
|
|
func TestProcessAndOutput_Match_DropsEvent(t *testing.T) {
|
|
calledProcess := false
|
|
keyDef := &event.KeyDefinition{
|
|
Key: "test.evt",
|
|
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
|
return false
|
|
},
|
|
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
|
calledProcess = true
|
|
return json.RawMessage(`{}`), nil
|
|
},
|
|
}
|
|
sink := &captureSink{}
|
|
wrote, err := processAndOutput(context.Background(), keyDef,
|
|
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
|
Options{}, sink, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wrote {
|
|
t.Error("Match returned false but event was written")
|
|
}
|
|
if calledProcess {
|
|
t.Error("Process was called even though Match returned false")
|
|
}
|
|
if len(sink.written) != 0 {
|
|
t.Errorf("sink received %d events, want 0", len(sink.written))
|
|
}
|
|
}
|
|
|
|
func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) {
|
|
keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process
|
|
sink := &captureSink{}
|
|
wrote, err := processAndOutput(context.Background(), keyDef,
|
|
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)},
|
|
Options{}, sink, nil)
|
|
if err != nil || !wrote {
|
|
t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err)
|
|
}
|
|
if len(sink.written) != 1 {
|
|
t.Errorf("sink received %d events, want 1", len(sink.written))
|
|
}
|
|
}
|
|
|
|
func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) {
|
|
// Record the actual call sequence — a bare call-count check would still
|
|
// pass if Process ran before Match.
|
|
var order []string
|
|
keyDef := &event.KeyDefinition{
|
|
Key: "test.evt",
|
|
Match: func(raw *event.RawEvent, params map[string]string) bool {
|
|
order = append(order, "match")
|
|
return true
|
|
},
|
|
Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) {
|
|
order = append(order, "process")
|
|
return raw.Payload, nil
|
|
},
|
|
}
|
|
sink := &captureSink{}
|
|
wrote, err := processAndOutput(context.Background(), keyDef,
|
|
&protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)},
|
|
Options{}, sink, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !wrote {
|
|
t.Error("expected wrote=true")
|
|
}
|
|
if len(order) != 2 || order[0] != "match" || order[1] != "process" {
|
|
t.Errorf("call order = %v, want [match process]", order)
|
|
}
|
|
}
|
|
|
|
func TestIsTerminalSinkError(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"EPIPE raw", syscall.EPIPE, true},
|
|
{"EPIPE wrapped", fmt.Errorf("write: %w", syscall.EPIPE), true},
|
|
{"ErrClosed", io.ErrClosedPipe, false},
|
|
{"transient disk full", errors.New("no space left on device"), false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isTerminalSinkError(tc.err); got != tc.want {
|
|
t.Errorf("isTerminalSinkError(%v) = %v, want %v", tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|