Fall back to local artifacts when the blob store is a typed nil
ci / vet-and-test (pull_request) Successful in 46s

Why: an unset S3 client passed through the Store interface is not a nil interface, so uploads panic instead of using the local artifact directory.
Evidence: TestIsNil and TestFinishOpenTypedNilS3UsesLocalArtifacts failed before this change and pass after it. The PostgreSQL case skips unless AGENT_PG_TEST_DSN is set.
Impact: only an unset blob store falls back; a constructed S3 or local store is unchanged.
Refs: internal/blobstore/blobstore.go internal/store/store.go
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 01:22:57 +00:00
co-authored by Cursor
parent c6fb13016a
commit 46073d30a5
4 changed files with 168 additions and 1 deletions
+16
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"sync" "sync"
) )
@@ -19,6 +20,21 @@ type Store interface {
Delete(ref string) error Delete(ref string) error
} }
// IsNil 判断对象存储是否未配置。nil 接口,以及放进接口后的带类型空指针
// (例如未设置端点时的 (*S3)(nil)),都算未配置。已构造的实现返回 false。
func IsNil(s Store) bool {
if s == nil {
return true
}
v := reflect.ValueOf(s)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// Local 是本地目录实现(data/artifacts;多实例时把目录指向共享卷即可)。 // Local 是本地目录实现(data/artifacts;多实例时把目录指向共享卷即可)。
type Local struct { type Local struct {
dir string dir string
+29
View File
@@ -0,0 +1,29 @@
package blobstore
import "testing"
func TestIsNil(t *testing.T) {
var unset Store
if !IsNil(unset) {
t.Fatal("nil interface must count as unset")
}
var typed *S3
var asStore Store = typed
if !IsNil(asStore) {
t.Fatal("typed nil *S3 must count as unset")
}
local, err := NewLocal(t.TempDir())
if err != nil {
t.Fatal(err)
}
if IsNil(local) {
t.Fatal("local store must count as configured")
}
s3, err := NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/")
if err != nil {
t.Fatal(err)
}
if IsNil(s3) {
t.Fatal("configured S3 must count as configured")
}
}
+2 -1
View File
@@ -212,6 +212,7 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St
// OpenPostgresWithOptions 按 KTD12 连接预算打开 PG 档;QueryTimeoutSec>0 时 // OpenPostgresWithOptions 按 KTD12 连接预算打开 PG 档;QueryTimeoutSec>0 时
// 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。 // 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。
// blobs 为 nil(含带类型的空指针,例如未配置端点时的 (*blobstore.S3)(nil))时回落本地目录。
func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) { func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) {
return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true) return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true)
} }
@@ -263,7 +264,7 @@ func AppendPGRuntimeParam(dsn, key, value string) string {
} }
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) { func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) {
if blobs == nil { if blobstore.IsNil(blobs) {
var err error var err error
if createArtifacts { if createArtifacts {
blobs, err = blobstore.NewLocal(artifactsDir) blobs, err = blobstore.NewLocal(artifactsDir)
+121
View File
@@ -0,0 +1,121 @@
package store
import (
"bytes"
"database/sql"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"agent-system-backend/internal/blobstore"
core "agent-system-core"
)
func TestFinishOpenTypedNilS3UsesLocalArtifacts(t *testing.T) {
dir := t.TempDir()
st := openSQLiteWithBlobs(t, dir, (*blobstore.S3)(nil))
assertLocalArtifactRoundTrip(t, st, filepath.Join(dir, "artifacts"))
}
func TestFinishOpenKeepsConfiguredBlobStore(t *testing.T) {
dir := t.TempDir()
s3, err := blobstore.NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/")
if err != nil {
t.Fatal(err)
}
st := openSQLiteWithBlobs(t, dir, s3)
if st.BlobStore() != s3 {
t.Fatalf("configured blob store was replaced: %T", st.BlobStore())
}
}
func TestOpenPostgresTypedNilS3UsesLocalArtifacts(t *testing.T) {
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真实 PG 门禁由 CI 的必跑 job 覆盖")
}
schema := "t_" + strings.ToLower(newID("s")) + "_" + strconv.Itoa(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
ensurePGTrgm(t, admin)
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
arts := filepath.Join(t.TempDir(), "artifacts")
st, err := OpenPostgresWithOptions(withTestSearchPath(dsn, schema), arts, PoolOptions{}, (*blobstore.S3)(nil))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
assertLocalArtifactRoundTrip(t, st, arts)
}
func openSQLiteWithBlobs(t *testing.T, dir string, blobs blobstore.Store) *Store {
t.Helper()
dbPath := filepath.Join(dir, "test.db")
dsn := "file:" + dbPath + "?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
st, err := finishOpen(db, sqliteDialect{}, filepath.Join(dir, "artifacts"), blobs, PoolOptions{}, true)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func assertLocalArtifactRoundTrip(t *testing.T, st *Store, arts string) {
t.Helper()
if _, ok := st.BlobStore().(*blobstore.Local); !ok {
t.Fatalf("unset blob store must fall back to local, got %T", st.BlobStore())
}
proj, err := st.CreateProject("k4", "k4")
if err != nil {
t.Fatal(err)
}
art := core.Artifact{Owner: "k4", ProjectID: proj.ID, Name: "a.txt", Kind: "data"}
if err := st.CreateArtifact(&art, []byte("hello")); err != nil {
t.Fatal(err)
}
got, err := st.ArtifactContentByID(art.ID)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, []byte("hello")) {
t.Fatalf("read back %q", got)
}
if _, err := os.Stat(filepath.Join(arts, art.ID)); err != nil {
t.Fatalf("artifact file missing under local dir: %v", err)
}
if err := st.UpdateArtifactContent("k4", art.ID, []byte("next")); err != nil {
t.Fatal(err)
}
got, err = st.ArtifactContent("k4", art.ID)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, []byte("next")) {
t.Fatalf("overwrite read back %q", got)
}
if err := st.DeleteArtifact("k4", art.ID); err != nil {
t.Fatal(err)
}
if _, err := st.ArtifactContentByID(art.ID); err != ErrNotFound {
t.Fatalf("deleted artifact content: %v", err)
}
if _, err := os.Stat(filepath.Join(arts, art.ID)); !os.IsNotExist(err) {
t.Fatalf("deleted artifact file still present: %v", err)
}
}