Add multi-instance groundwork: blobstore seam for artifacts
Details (original message): Add multi-instance groundwork: blobstore seam for artifacts, PG LISTEN/NOTIFY event fanout, and per-tenant rate limiting with native Redis backend
This commit is contained in:
@@ -13,6 +13,8 @@ require (
|
||||
require (
|
||||
agent-squad v0.0.0-00010101000000-000000000000 // indirect
|
||||
error-code-system v0.0.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -21,6 +23,7 @@ require (
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.14.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
@@ -22,6 +26,8 @@ github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
||||
+52
-6
@@ -11,28 +11,31 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/pgbus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/rag"
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Addr string
|
||||
DataDir string
|
||||
PluginsDir string
|
||||
ForceMock bool
|
||||
Addr string
|
||||
DataDir string
|
||||
PluginsDir string
|
||||
ForceMock bool
|
||||
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
|
||||
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
|
||||
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
|
||||
@@ -101,6 +104,22 @@ func Run(opt Options) error {
|
||||
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
|
||||
srv.Retriever = pipe
|
||||
srv.Indexer = pipe
|
||||
|
||||
// 限流(阶段 3):默认进程内档;设置 runtime.redisAddr 切原生 Redis 共享档。
|
||||
var limiter ratelimit.Limiter = ratelimit.NewMemory()
|
||||
if addr := setSvc.Effective(store.OwnerDefault, "").GetString("runtime.redisAddr", ""); addr != "" {
|
||||
rl := ratelimit.NewRedis(addr, "", 0)
|
||||
if err := rl.Ping(context.Background()); err != nil {
|
||||
return fmt.Errorf("redis %s: %w", addr, err)
|
||||
}
|
||||
defer rl.Close()
|
||||
limiter = rl
|
||||
log.Printf("rate limit: shared via redis %s", addr)
|
||||
}
|
||||
srv.Limiter = limiter
|
||||
srv.LimitLookup = cachedInt(5*time.Second, func() int {
|
||||
return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0))
|
||||
})
|
||||
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
|
||||
authOn := opt.AuthMode == "on" || (opt.AuthMode != "off" && opt.StorageDSN != "")
|
||||
srv.Auth = server.NewAuthenticator(st, authOn)
|
||||
@@ -120,6 +139,17 @@ func Run(opt Options) error {
|
||||
}
|
||||
}()
|
||||
|
||||
// PG 档:事件跨实例扇出(NOTIFY/LISTEN;SQLite 档不需要)。
|
||||
if st.Dialect() == "postgres" {
|
||||
mgr.SetEventNotifier(pgbus.NewNotifier(st).Notify)
|
||||
listener, err := pgbus.Start(context.Background(), opt.StorageDSN, st, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgbus listen: %w", err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
log.Printf("pgbus: LISTEN agent_events(跨实例事件扇出)")
|
||||
}
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
@@ -184,3 +214,19 @@ func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
|
||||
defer st.Close()
|
||||
return st.RevokeAPIKeyByName(owner, name)
|
||||
}
|
||||
|
||||
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
|
||||
func cachedInt(ttl time.Duration, fn func() int) func() int {
|
||||
var mu sync.Mutex
|
||||
var val int
|
||||
var at time.Time
|
||||
return func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if time.Since(at) > ttl {
|
||||
val = fn()
|
||||
at = time.Now()
|
||||
}
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package blobstore 是工件对象存储抽象(阶段 3:多实例前置)。
|
||||
// 默认本地目录档(单实例,零外部服务);多实例部署切共享卷路径或按本接口补
|
||||
// 对象存储实现(S3 兼容等)——上层(store 工件方法)只依赖本接口。
|
||||
package blobstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Store 是对象存储接口:Put 原子落盘并返回记录进库的引用(本地档=绝对路径,
|
||||
// 对象存储档=对象键);Get/Delete 按该引用操作。
|
||||
type Store interface {
|
||||
Put(id string, content []byte) (ref string, err error)
|
||||
Get(ref string) ([]byte, error)
|
||||
Delete(ref string) error
|
||||
}
|
||||
|
||||
// Local 是本地目录实现(data/artifacts;多实例时把目录指向共享卷即可)。
|
||||
type Local struct {
|
||||
dir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewLocal(dir string) (*Local, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Local{dir: dir}, nil
|
||||
}
|
||||
|
||||
// Put 临时文件 → rename 原子写:崩溃只可能残留 .tmp 孤立文件,不产生半写对象。
|
||||
func (l *Local) Put(id string, content []byte) (string, error) {
|
||||
final := filepath.Join(l.dir, id)
|
||||
tmp := final + ".tmp-" + id
|
||||
if err := os.WriteFile(tmp, content, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if err := os.Rename(tmp, final); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
return final, nil
|
||||
}
|
||||
|
||||
func (l *Local) Get(ref string) ([]byte, error) {
|
||||
b, err := os.ReadFile(ref)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("blobstore: object %s not found", filepath.Base(ref))
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (l *Local) Delete(ref string) error {
|
||||
err := os.Remove(ref)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Package pgbus 是 PG 档的事件跨实例扇出(阶段 3):
|
||||
// 落盘事务提交后 NOTIFY agent_events '<conv>|<lastSeq>';每实例一个 LISTEN 连接,
|
||||
// 收到通知按 seq 从库取回事件并转发本地 bus(SSE 以 seq 去重,双投无害)。
|
||||
// 多实例部署契约:run 在其归属实例内存中执行,会话流量需按会话亲和路由
|
||||
//(网关/客户端择点);本包只解决“其他实例的订阅者”可见性。
|
||||
package pgbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/store"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const channel = "agent_events"
|
||||
|
||||
// Notifier 落盘后通知(任意连接池连接可用)。
|
||||
type Notifier struct{ st *store.Store }
|
||||
|
||||
func NewNotifier(st *store.Store) *Notifier { return &Notifier{st: st} }
|
||||
|
||||
// Notify 在 AppendEvents 成功后调用:一批事件一条通知(载荷=会话|末 seq)。
|
||||
func (n *Notifier) Notify(conversationID string, evs []core.StreamEvent) {
|
||||
if len(evs) == 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
payload := conversationID + "|" + strconv.FormatInt(evs[len(evs)-1].Seq, 10)
|
||||
if _, err := n.st.NotifyPG(ctx, channel, payload); err != nil {
|
||||
log.Printf("[pgbus] notify %s: %v", conversationID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Listener 每实例一个:LISTEN → 按 seq 取回 → 本地 bus 转发。
|
||||
type Listener struct {
|
||||
conn *pgx.Conn
|
||||
st *store.Store
|
||||
bus *bus.Bus
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Start 建立监听连接并开始转发循环;返回后即可用,随 ctx 取消停机。
|
||||
func Start(ctx context.Context, dsn string, st *store.Store, b *bus.Bus) (*Listener, error) {
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Exec(ctx, "LISTEN "+channel); err != nil {
|
||||
conn.Close(ctx)
|
||||
return nil, err
|
||||
}
|
||||
l := &Listener{conn: conn, st: st, bus: b, done: make(chan struct{})}
|
||||
go l.loop(ctx)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *Listener) loop(ctx context.Context) {
|
||||
defer close(l.done)
|
||||
for {
|
||||
n, err := l.conn.WaitForNotification(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return // 停机
|
||||
}
|
||||
log.Printf("[pgbus] wait: %v(1s 后重连)", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
conv, seq, ok := parsePayload(n.Payload)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ev, err := l.st.EventAt(conv, seq)
|
||||
if err != nil {
|
||||
continue // 事件不可见(极早期窗口)——SSE 重放兜底
|
||||
}
|
||||
l.bus.Publish(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePayload(p string) (string, int64, bool) {
|
||||
i := strings.LastIndex(p, "|")
|
||||
if i < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
seq, err := strconv.ParseInt(p[i+1:], 10, 64)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return p[:i], seq, true
|
||||
}
|
||||
|
||||
// Close 关闭监听连接。
|
||||
func (l *Listener) Close(ctx context.Context) {
|
||||
l.conn.Close(ctx)
|
||||
select {
|
||||
case <-l.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package pgbus
|
||||
|
||||
// PG 档事件扇出集成测试(环境门控:AGENT_PG_TEST_DSN,原生 PostgreSQL 实例)。
|
||||
// 验证:实例 A 落盘+NOTIFY → 实例 B 的 LISTEN 取回并转发到其本地 bus。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func pgTestStore(t *testing.T) (*store.Store, string) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN not set")
|
||||
}
|
||||
schema := "pgbus_" + strconv.Itoa(os.Getpid()) + "_" + fmt.Sprint(time.Now().UnixNano()%1e6)
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
full := dsn + "&search_path=" + schema
|
||||
st, err := store.OpenPostgres(full, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st, full
|
||||
}
|
||||
|
||||
func TestNotifyListenerFanout(t *testing.T) {
|
||||
st, dsn := pgTestStore(t)
|
||||
|
||||
// “实例 B”:独立 bus + 监听连接。
|
||||
b2 := bus.New()
|
||||
listener, err := Start(context.Background(), dsn, st, b2)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer listener.Close(context.Background())
|
||||
|
||||
ch, stop := b2.Subscribe("conv_x")
|
||||
defer stop()
|
||||
|
||||
// “实例 A”:批落盘 + NOTIFY(Notifier 与 listener 共库不同连接)。
|
||||
notifier := NewNotifier(st)
|
||||
evs := []core.StreamEvent{
|
||||
{ConversationID: "conv_x", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunPhase, Payload: []byte(`{"phase":"thinking"}`)},
|
||||
{ConversationID: "conv_x", RunID: "r1", TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1, Type: core.EvRunCompleted, Payload: []byte(`{}`)},
|
||||
}
|
||||
if _, err := st.AppendEvents("conv_x", evs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier.Notify("conv_x", evs)
|
||||
|
||||
select {
|
||||
case e := <-ch:
|
||||
if e.ConversationID != "conv_x" || e.Seq == 0 {
|
||||
t.Fatalf("fanout event malformed: %+v", e)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("no fanout within 3s")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package ratelimit 提供按租户的限流(阶段 3):
|
||||
// - Memory:进程内固定窗口(默认,单实例档,零外部服务);
|
||||
// - Redis:多实例共享固定窗口(原生 Redis,设置 runtime.redisAddr 启用)。
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Limiter interface {
|
||||
// Allow 判定 key(租户 owner)在窗口内第 N 次请求是否放行。
|
||||
Allow(ctx context.Context, key string, limit int, window time.Duration) bool
|
||||
}
|
||||
|
||||
// ---- 进程内固定窗口 ----
|
||||
|
||||
type memWindow struct {
|
||||
n int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
windows map[string]*memWindow
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{windows: map[string]*memWindow{}, now: time.Now}
|
||||
}
|
||||
|
||||
func (m *Memory) Allow(_ context.Context, key string, limit int, window time.Duration) bool {
|
||||
if limit <= 0 {
|
||||
return true
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
now := m.now()
|
||||
w, ok := m.windows[key]
|
||||
if !ok || !now.Before(w.reset) {
|
||||
m.windows[key] = &memWindow{n: 1, reset: now.Add(window)}
|
||||
return true
|
||||
}
|
||||
if w.n >= limit {
|
||||
return false
|
||||
}
|
||||
w.n++
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Redis 固定窗口(INCR + EXPIRE;首击设窗)----
|
||||
|
||||
type Redis struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func NewRedis(addr, password string, db int) *Redis {
|
||||
return &Redis{rdb: redis.NewClient(&redis.Options{Addr: addr, Password: password, DB: db})}
|
||||
}
|
||||
|
||||
func (r *Redis) Allow(ctx context.Context, key string, limit int, window time.Duration) bool {
|
||||
if limit <= 0 {
|
||||
return true
|
||||
}
|
||||
rkey := "rl:" + key + ":" + time.Now().UTC().Truncate(window).Format("150405")
|
||||
n, err := r.rdb.Incr(ctx, rkey).Result()
|
||||
if err != nil {
|
||||
return true // Redis 故障不阻断请求(限流是柔性行为,放行并记日志于调用方)
|
||||
}
|
||||
if n == 1 {
|
||||
r.rdb.Expire(ctx, rkey, window+time.Second)
|
||||
}
|
||||
return n <= int64(limit)
|
||||
}
|
||||
|
||||
func (r *Redis) Ping(ctx context.Context) error { return r.rdb.Ping(ctx).Err() }
|
||||
|
||||
func (r *Redis) Close() error { return r.rdb.Close() }
|
||||
@@ -0,0 +1,57 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryWindow(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
for i := 1; i <= 3; i++ {
|
||||
if !m.Allow(ctx, "o", 3, time.Minute) {
|
||||
t.Fatalf("request %d should pass", i)
|
||||
}
|
||||
}
|
||||
if m.Allow(ctx, "o", 3, time.Minute) {
|
||||
t.Fatal("4th request must be limited")
|
||||
}
|
||||
// 其他租户互不影响。
|
||||
if !m.Allow(ctx, "p", 3, time.Minute) {
|
||||
t.Fatal("other tenant must pass")
|
||||
}
|
||||
// 窗口滚动后恢复。
|
||||
m.now = func() time.Time { return time.Now().Add(2 * time.Minute) }
|
||||
if !m.Allow(ctx, "o", 3, time.Minute) {
|
||||
t.Fatal("new window must reset")
|
||||
}
|
||||
// limit<=0 = 不限。
|
||||
if !m.Allow(ctx, "x", 0, time.Minute) {
|
||||
t.Fatal("limit 0 means unlimited")
|
||||
}
|
||||
}
|
||||
|
||||
// Redis 共享档(环境门控:AGENT_REDIS_TEST_ADDR,本机原生实例)。
|
||||
func TestRedisWindow(t *testing.T) {
|
||||
addr := os.Getenv("AGENT_REDIS_TEST_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("AGENT_REDIS_TEST_ADDR not set")
|
||||
}
|
||||
r := NewRedis(addr, "", 15) // 独立 db 避免污染
|
||||
defer r.Close()
|
||||
if err := r.Ping(context.Background()); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
key := "test-" + time.Now().Format("150405.000000000")
|
||||
for i := 1; i <= 2; i++ {
|
||||
if !r.Allow(ctx, key, 2, time.Minute) {
|
||||
t.Fatalf("request %d should pass", i)
|
||||
}
|
||||
}
|
||||
if r.Allow(ctx, key, 2, time.Minute) {
|
||||
t.Fatal("3rd request must be limited")
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type eventBuffer struct {
|
||||
bus *bus.Bus
|
||||
interval time.Duration // 定时冲盘窗口
|
||||
maxBuf int // 单会话缓冲条数上限(触发立即冲盘)
|
||||
// onPersisted 落盘+本地发布后的回调(PG 档 NOTIFY 跨实例扇出;nil = 无)。
|
||||
onPersisted func(conversationID string, evs []core.StreamEvent)
|
||||
|
||||
mu sync.Mutex
|
||||
buf map[string][]core.StreamEvent
|
||||
@@ -119,6 +121,9 @@ func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) err
|
||||
for i := range evs {
|
||||
eb.bus.Publish(evs[i])
|
||||
}
|
||||
if eb.onPersisted != nil {
|
||||
eb.onPersisted(conversationID, evs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,11 @@ func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service,
|
||||
// Shutdown 停机清空事件缓冲(在 store.Close 之前调用)。
|
||||
func (m *Manager) Shutdown() { m.events.stop() }
|
||||
|
||||
// SetEventNotifier 注入落盘后回调(PG 档 pgbus.Notifier;SQLite 档不注入)。
|
||||
func (m *Manager) SetEventNotifier(fn func(conversationID string, evs []core.StreamEvent)) {
|
||||
m.events.onPersisted = fn
|
||||
}
|
||||
|
||||
// IntentGate 是可选意图闸门端口(intentgate.Gate 实现;nil = 未启用,行为回退)。
|
||||
type IntentGate interface {
|
||||
Match(ctx context.Context, input string) (branch, topID string)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
)
|
||||
|
||||
// 限流中间件(阶段 3):按租户 owner 固定窗口;limit<=0(默认)直接放行。
|
||||
// 位于认证之后(key=owner);Limiter/LimitLookup 由组合根注入。
|
||||
func (s *Server) withRateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Limiter != nil && s.LimitLookup != nil {
|
||||
if limit := s.LimitLookup(); limit > 0 {
|
||||
if !s.Limiter.Allow(r.Context(), "owner:"+s.owner(r), limit, time.Minute) {
|
||||
http.Error(w, "rate limit exceeded(runtime.rateLimitPerMin)", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
var _ = ratelimit.Memory{} // 接口形状锚点(Limiter 注入自组合根)
|
||||
@@ -0,0 +1,40 @@
|
||||
package server
|
||||
|
||||
// 限流 e2e:limit=3 时第 4 个请求 429;其他租户不受影响。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
)
|
||||
|
||||
func TestRateLimitMiddleware(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
srv.Limiter = ratelimit.NewMemory()
|
||||
n := 3
|
||||
srv.LimitLookup = func() int { return n }
|
||||
h := srv.Handler()
|
||||
|
||||
get := func() int {
|
||||
req := httptest.NewRequest("GET", "/api/projects", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
for i := 1; i <= n; i++ {
|
||||
if c := get(); c != http.StatusOK {
|
||||
t.Fatalf("request %d: %d", i, c)
|
||||
}
|
||||
}
|
||||
if c := get(); c != http.StatusTooManyRequests {
|
||||
t.Fatalf("4th request: %d (want 429)", c)
|
||||
}
|
||||
// lookup<=0 时放行(默认不限)。
|
||||
n = 0
|
||||
srv.Limiter = ratelimit.NewMemory()
|
||||
if c := get(); c != http.StatusOK {
|
||||
t.Fatalf("unlimited: %d", c)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"agent-system-backend/internal/ingest"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/ratelimit"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
@@ -31,6 +32,8 @@ type Server struct {
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
@@ -113,10 +116,11 @@ func (s *Server) Handler() http.Handler {
|
||||
// API 未匹配路径一律 404(不回落 SPA,避免 /api/* 返回 HTML 200)。
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
|
||||
|
||||
// 静态前端(embed);API 全链路经认证中间件(未启用 = 默认租户)。
|
||||
var h http.Handler = mux
|
||||
// 静态前端(embed);API 全链路:认证(未启用 = 默认租户)→ 租户限流。
|
||||
inner := s.withRateLimit(mux)
|
||||
var h http.Handler = inner
|
||||
if s.Auth != nil {
|
||||
h = s.Auth.Middleware(mux)
|
||||
h = s.Auth.Middleware(inner)
|
||||
}
|
||||
mux.HandleFunc("/", s.serveStatic)
|
||||
return h
|
||||
|
||||
@@ -29,6 +29,8 @@ func SystemDefaults() map[string]any {
|
||||
"runtime": map[string]any{
|
||||
"mock": false, // true = 全部会话走 mock runner(演示/测试)
|
||||
"squad": true, // 小队编排(Agent-Squad 引擎)可用
|
||||
"rateLimitPerMin": 0, // 租户限流(次/分;0 = 不限)
|
||||
"redisAddr": "", // 限流共享后端(原生 Redis addr;空 = 进程内)
|
||||
},
|
||||
"storage": map[string]any{
|
||||
"dataDir": "./data", // 启动注入实际值(--data)
|
||||
@@ -60,6 +62,8 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
|
||||
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
|
||||
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
|
||||
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
|
||||
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
|
||||
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
|
||||
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
||||
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
|
||||
|
||||
+11
-27
@@ -5,8 +5,6 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
@@ -16,18 +14,6 @@ import (
|
||||
// 文件写走 临时文件 → rename → 落库:崩溃残留只可能产生无主孤立文件,
|
||||
// 不产生库里有行、对象缺失的悬空引用。
|
||||
|
||||
func (s *Store) writeBlobAtomic(path string, content []byte) error {
|
||||
tmp := path + ".tmp-" + newID("x")
|
||||
if err := os.WriteFile(tmp, content, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error {
|
||||
if a.ID == "" {
|
||||
a.ID = newID("art")
|
||||
@@ -35,14 +21,14 @@ func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error {
|
||||
now := time.Now().UTC()
|
||||
a.CreatedAt, a.UpdatedAt = now, now
|
||||
a.Size = int64(len(content))
|
||||
path := filepath.Join(s.dataDir, a.ID)
|
||||
if err := s.writeBlobAtomic(path, content); err != nil {
|
||||
ref, err := s.blobs.Put(a.ID, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
a.ID, a.ProjectID, a.Owner, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, path, nowISO(), nowISO())
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
a.ID, a.ProjectID, a.Owner, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, ref, nowISO(), nowISO())
|
||||
if err != nil {
|
||||
os.Remove(path) // 落库失败:回收对象,不留悬空
|
||||
_ = s.blobs.Delete(ref) // 落库失败:回收对象,不留悬空
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -57,7 +43,8 @@ func (s *Store) UpdateArtifactContent(owner, id string, content []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.writeBlobAtomic(path, content); err != nil {
|
||||
// 同 ID 原子覆写(临时文件+rename);行内引用不变,避免行/对象漂移。
|
||||
if _, err := s.blobs.Put(id, content); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.Exec(s.q(`UPDATE artifacts SET size=?, updated_at=? WHERE id=? AND owner=?`), len(content), nowISO(), id, owner)
|
||||
@@ -96,15 +83,15 @@ func (s *Store) GetArtifact(owner, id string) (core.Artifact, error) {
|
||||
}
|
||||
|
||||
func (s *Store) ArtifactContent(owner, id string) ([]byte, error) {
|
||||
var path string
|
||||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path)
|
||||
var ref string
|
||||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&ref)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
return s.blobs.Get(ref)
|
||||
}
|
||||
|
||||
func (s *Store) ListArtifacts(owner, projectID string) ([]core.Artifact, error) {
|
||||
@@ -140,10 +127,7 @@ func (s *Store) DeleteArtifact(owner, id string) error {
|
||||
// 联动清理检索索引(知识行 + 向量;失败不阻断删除,残留可由重建索引收敛)。
|
||||
_ = s.DeleteKnowledgeByRef(owner, id)
|
||||
_ = s.DeleteKnowledgeVectors(context.Background(), owner, id)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return s.blobs.Delete(path)
|
||||
}
|
||||
|
||||
// ---- HITL 卡片(resolve 原子化:条件更新消灭先查后改窗口)----
|
||||
|
||||
+41
-5
@@ -9,6 +9,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/blobstore"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // PG 档驱动(注册名 "pgx")
|
||||
_ "modernc.org/sqlite" // 嵌入式档驱动(注册名 "sqlite",CGO-free)
|
||||
)
|
||||
@@ -29,7 +32,8 @@ const OwnerDefault = "local"
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
dialect dialect
|
||||
dataDir string // 工件对象目录(<data>/artifacts 或配置的共享目录)
|
||||
dataDir string // 工件对象目录(备份路径等诊断用)
|
||||
blobs blobstore.Store // 工件对象存储(本地档;多实例切共享卷/对象存储)
|
||||
}
|
||||
|
||||
// OpenSQLite 打开(必要时创建)嵌入式存储并执行迁移。
|
||||
@@ -44,7 +48,7 @@ func OpenSQLite(path string) (*Store, error) {
|
||||
}
|
||||
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
|
||||
db.SetMaxOpenConns(1)
|
||||
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"))
|
||||
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil)
|
||||
}
|
||||
|
||||
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移。
|
||||
@@ -58,11 +62,31 @@ func OpenPostgres(dsn, artifactsDir string) (*Store, error) {
|
||||
db.SetMaxOpenConns(16)
|
||||
db.SetMaxIdleConns(8)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
return finishOpen(db, postgresDialect{}, artifactsDir)
|
||||
return finishOpen(db, postgresDialect{}, artifactsDir, nil)
|
||||
}
|
||||
|
||||
func finishOpen(db *sql.DB, d dialect, artifactsDir string) (*Store, error) {
|
||||
s := &Store{db: db, dialect: d, dataDir: artifactsDir}
|
||||
// OpenPostgresWithBlobs 是注入自定义对象存储的 PG 档入口(多实例共享卷/对象存储)。
|
||||
func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*Store, error) {
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(16)
|
||||
db.SetMaxIdleConns(8)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
return finishOpen(db, postgresDialect{}, artifactsDir, blobs)
|
||||
}
|
||||
|
||||
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store) (*Store, error) {
|
||||
if blobs == nil {
|
||||
var err error
|
||||
blobs, err = blobstore.NewLocal(artifactsDir)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -82,6 +106,18 @@ func (s *Store) Dialect() string { return s.dialect.name() }
|
||||
// DataDir 返回工件对象目录。
|
||||
func (s *Store) DataDir() string { return s.dataDir }
|
||||
|
||||
// BlobStore 返回工件对象存储(诊断/运维)。
|
||||
func (s *Store) BlobStore() blobstore.Store { return s.blobs }
|
||||
|
||||
// NotifyPG 发送 PostgreSQL NOTIFY(PG 档事件扇出用;SQLite 档返回未执行)。
|
||||
func (s *Store) NotifyPG(ctx context.Context, channel, payload string) (bool, error) {
|
||||
if s.dialect.name() != "postgres" {
|
||||
return false, nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `SELECT pg_notify($1, $2)`, channel, payload)
|
||||
return true, err
|
||||
}
|
||||
|
||||
// q 按方言重绑占位符(查询统一用 ? 书写,PG 执行前转 $n)。
|
||||
// 本包 SQL 不含字符串字面量 "?",重绑安全。
|
||||
func (s *Store) q(query string) string { return s.dialect.rebind(query) }
|
||||
|
||||
Reference in New Issue
Block a user