流式输出/双向流的 handler(Python 生成器)如果执行过程中抛异常, Invoke[chan T] 本身的 err 只描述"调用有没有发起成功",跟这个异常 无关(永远是 nil),channel 只会静默提前关闭,调用方原本完全无法 感知。新增 WithStreamErrors(ctx) 返回一个包过的 ctx 和一个查询函数 streamErr,opt-in 之后可以查到具体错误。 错误记录挂在 WithStreamErrors 返回的 ctx 的对象图里(context.WithValue), 不是全局表——调用方不再引用 ctx/channel 时会被 GC 自然回收,不需要 任何显式清理逻辑,也不依赖 ctx.Done(),即使用 context.Background() 也能正常释放;ctx 之后被别的 context.With*(包括 StickyCtx)再包一层 也不影响查询。 同时补充完整的自动化测试覆盖 example/main.go 里演示过的所有功能: 四种调用模式 × int/struct/slice/[]byte 的组合(client_test.go)、 WithHandlers/call_go 全双工(handlers_test.go)、NewSession 隔离性 与 StickyCtx 路由(session_test.go),之前这些只能靠人肉跑 go run 看输出,现在都有真实断言。
150 lines
5.3 KiB
Go
150 lines
5.3 KiB
Go
package gobridge
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"reflect"
|
||
"testing"
|
||
)
|
||
|
||
// sessionResult 对应 example/worker.py 里 session_result 返回的
|
||
// {"value": int, "steps": [int, ...]}。
|
||
type sessionResult struct {
|
||
Value int `json:"value"`
|
||
Steps []int `json:"steps"`
|
||
}
|
||
|
||
// parseWorkerID 从 "[worker N] ..." 格式的字符串里解析出 worker 编号,
|
||
// example/worker.py 里 session_init/global_increment/global_get 都是这个格式。
|
||
func parseWorkerID(t *testing.T, msg string) int {
|
||
t.Helper()
|
||
var id int
|
||
if _, err := fmt.Sscanf(msg, "[worker %d]", &id); err != nil {
|
||
t.Fatalf("parse worker id from %q: %v", msg, err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
// TestNewSessionIsolation 覆盖 example/main.go 的 demoSession 演示过的
|
||
// NewSession 功能:不同 session 之间状态互相隔离;同一个 worker 进程内的
|
||
// session 共享进程级全局变量,不同 worker 之间完全独立。
|
||
func TestNewSessionIsolation(t *testing.T) {
|
||
pool := newTestPool(t, WithWorkers(2))
|
||
ctx := context.Background()
|
||
|
||
// NewSession 内部按 pool 共享的原子计数器轮询:第一次 idx=1%2=1,
|
||
// 第二次 idx=2%2=0,第三次 idx=3%2=1——sessA 和 sessC 落在同一个 worker,
|
||
// sessB 落在另一个(用全新 pool,不掺杂其它调用,保证这个顺序确定)。
|
||
sessA := NewSession(pool)
|
||
sessB := NewSession(pool)
|
||
sessC := NewSession(pool)
|
||
|
||
if _, err := Invoke[string](ctx, sessA, "session_init", "A", 100); err != nil {
|
||
t.Fatalf("sessA init: %v", err)
|
||
}
|
||
if _, err := Invoke[string](ctx, sessB, "session_init", "B", 200); err != nil {
|
||
t.Fatalf("sessB init: %v", err)
|
||
}
|
||
if _, err := Invoke[string](ctx, sessC, "session_init", "C", 300); err != nil {
|
||
t.Fatalf("sessC init: %v", err)
|
||
}
|
||
|
||
// 各自 step,验证会话状态互不干扰
|
||
if v, err := Invoke[int](ctx, sessA, "session_step", "A", 10); err != nil || v != 110 {
|
||
t.Fatalf("sessA step(+10): v=%d err=%v, want 110", v, err)
|
||
}
|
||
if v, err := Invoke[int](ctx, sessA, "session_step", "A", 5); err != nil || v != 115 {
|
||
t.Fatalf("sessA step(+5): v=%d err=%v, want 115", v, err)
|
||
}
|
||
if v, err := Invoke[int](ctx, sessB, "session_step", "B", 50); err != nil || v != 250 {
|
||
t.Fatalf("sessB step(+50): v=%d err=%v, want 250", v, err)
|
||
}
|
||
if v, err := Invoke[int](ctx, sessC, "session_step", "C", 99); err != nil || v != 399 {
|
||
t.Fatalf("sessC step(+99): v=%d err=%v, want 399(与 sessA 同 worker 但状态独立)", v, err)
|
||
}
|
||
|
||
rA, err := Invoke[sessionResult](ctx, sessA, "session_result", "A")
|
||
if err != nil {
|
||
t.Fatalf("sessA result: %v", err)
|
||
}
|
||
if want := (sessionResult{Value: 115, Steps: []int{10, 5}}); !reflect.DeepEqual(rA, want) {
|
||
t.Fatalf("sessA result: want %+v, got %+v", want, rA)
|
||
}
|
||
|
||
rB, err := Invoke[sessionResult](ctx, sessB, "session_result", "B")
|
||
if err != nil {
|
||
t.Fatalf("sessB result: %v", err)
|
||
}
|
||
if want := (sessionResult{Value: 250, Steps: []int{50}}); !reflect.DeepEqual(rB, want) {
|
||
t.Fatalf("sessB result: want %+v, got %+v", want, rB)
|
||
}
|
||
|
||
rC, err := Invoke[sessionResult](ctx, sessC, "session_result", "C")
|
||
if err != nil {
|
||
t.Fatalf("sessC result: %v", err)
|
||
}
|
||
if want := (sessionResult{Value: 399, Steps: []int{99}}); !reflect.DeepEqual(rC, want) {
|
||
t.Fatalf("sessC result: want %+v, got %+v", want, rC)
|
||
}
|
||
|
||
// 进程级全局变量:sessA 和 sessC 同一个 worker 进程,应该共享 _global_counter;
|
||
// sessB 是独立进程,从 0 开始,不受影响。
|
||
rawA, err := Invoke[string](ctx, sessA, "global_increment", 10)
|
||
if err != nil {
|
||
t.Fatalf("sessA global_increment: %v", err)
|
||
}
|
||
workerA := parseWorkerID(t, rawA)
|
||
|
||
rawC, err := Invoke[string](ctx, sessC, "global_increment", 5)
|
||
if err != nil {
|
||
t.Fatalf("sessC global_increment: %v", err)
|
||
}
|
||
workerC := parseWorkerID(t, rawC)
|
||
|
||
rawB, err := Invoke[string](ctx, sessB, "global_increment", 99)
|
||
if err != nil {
|
||
t.Fatalf("sessB global_increment: %v", err)
|
||
}
|
||
workerB := parseWorkerID(t, rawB)
|
||
|
||
if workerA != workerC {
|
||
t.Fatalf("want sessA/sessC 落在同一个 worker,got worker %d / %d", workerA, workerC)
|
||
}
|
||
if workerB == workerA {
|
||
t.Fatalf("want sessB 落在独立的 worker,got 跟 sessA/sessC 一样都是 worker %d", workerB)
|
||
}
|
||
|
||
wantA := fmt.Sprintf("[worker %d] counter = 15", workerA) // sessA(+10) + sessC(+5) 共享
|
||
if rawA2, err := Invoke[string](ctx, sessA, "global_get"); err != nil || rawA2 != wantA {
|
||
t.Fatalf("sessA global_get: want %q, got %q (err=%v)", wantA, rawA2, err)
|
||
}
|
||
|
||
wantB := fmt.Sprintf("[worker %d] counter = 99", workerB) // 独立进程,只有自己的 +99
|
||
if rawB != wantB {
|
||
t.Fatalf("sessB global_increment: want %q, got %q", wantB, rawB)
|
||
}
|
||
}
|
||
|
||
// TestStickyCtx 覆盖 example/main.go 的 demoSession 演示过的 StickyCtx 功能:
|
||
// 相同的亲和 key 无论调用多少次,都应该稳定路由到同一个 worker 进程。
|
||
func TestStickyCtx(t *testing.T) {
|
||
pool := newTestPool(t, WithWorkers(2))
|
||
ctx := StickyCtx(context.Background(), "sticky-key")
|
||
|
||
var firstWorker int
|
||
for i := 0; i < 4; i++ {
|
||
msg, err := Invoke[string](ctx, pool, "session_init", fmt.Sprintf("aff-%d", i), i)
|
||
if err != nil {
|
||
t.Fatalf("Invoke #%d: %v", i, err)
|
||
}
|
||
worker := parseWorkerID(t, msg)
|
||
if i == 0 {
|
||
firstWorker = worker
|
||
continue
|
||
}
|
||
if worker != firstWorker {
|
||
t.Fatalf("StickyCtx 路由不稳定:第 1 次落在 worker %d,第 %d 次落在 worker %d", firstWorker, i+1, worker)
|
||
}
|
||
}
|
||
}
|