diff --git a/common/cf-v2-storage.c b/common/cf-v2-storage.c index 7a8ec56d..d1ab5134 100644 --- a/common/cf-v2-storage.c +++ b/common/cf-v2-storage.c @@ -92,6 +92,111 @@ typedef struct { gboolean native_repo_present; } Route; + +/* Only verified positive physical routes are cached (not ACLs or failures). + * Bounds protect other libraries' memory; never hold this mutex over I/O. */ +#define ROUTE_CACHE_MAX 1024 +#define ROUTE_CACHE_TTL_USEC (10 * G_USEC_PER_SEC) +#define ROUTE_CACHE_NATIVE_TTL_USEC (1 * G_USEC_PER_SEC) + +typedef struct { + char *repo_id; + Route route; + gint64 expires_at; + GList *link; +} CachedRoute; + +static GMutex route_cache_mutex; +static GHashTable *route_cache_index; +static GQueue route_cache_lru; + +static void +route_clear (Route *route) +{ + g_free (route->storage_id); + g_free (route->legacy_key); + g_free (route->legacy_state); + memset (route, 0, sizeof(*route)); +} + +static void +route_copy (Route *dst, const Route *src) +{ + dst->storage_id = g_strdup (src->storage_id); + dst->legacy_key = g_strdup (src->legacy_key); + dst->legacy_state = g_strdup (src->legacy_state); + dst->route_row_present = src->route_row_present; + dst->native_repo_present = src->native_repo_present; +} + +/* Caller holds route_cache_mutex. */ +static void +route_cache_remove (CachedRoute *entry) +{ + g_hash_table_remove (route_cache_index, entry->repo_id); + g_queue_delete_link (&route_cache_lru, entry->link); + route_clear (&entry->route); + g_free (entry->repo_id); + g_free (entry); +} + +static gboolean +route_cache_get (const char *repo_id, Route *route) +{ + gboolean hit = FALSE; + g_mutex_lock (&route_cache_mutex); + CachedRoute *entry = route_cache_index ? + g_hash_table_lookup (route_cache_index, repo_id) : NULL; + if (entry && g_get_monotonic_time () >= entry->expires_at) { + route_cache_remove (entry); + entry = NULL; + } + if (entry) { + route_copy (route, &entry->route); + g_queue_unlink (&route_cache_lru, entry->link); + g_queue_push_head_link (&route_cache_lru, entry->link); + hit = TRUE; + } + g_mutex_unlock (&route_cache_mutex); + return hit; +} + +static void +route_cache_put (const char *repo_id, const Route *route) +{ + g_mutex_lock (&route_cache_mutex); + if (!route_cache_index) + route_cache_index = g_hash_table_new (g_str_hash, g_str_equal); + CachedRoute *previous = g_hash_table_lookup (route_cache_index, repo_id); + if (previous) + route_cache_remove (previous); + if (g_hash_table_size (route_cache_index) >= ROUTE_CACHE_MAX) + route_cache_remove (g_queue_peek_tail (&route_cache_lru)); + CachedRoute *entry = g_new0 (CachedRoute, 1); + entry->repo_id = g_strdup (repo_id); + route_copy (&entry->route, route); + /* Native CE/VirtualRepo lifecycle can change more often than immutable + * V2 RepoStorageId; recheck these short-lived positive facts sooner. */ + entry->expires_at = g_get_monotonic_time () + + (route->route_row_present ? ROUTE_CACHE_TTL_USEC : ROUTE_CACHE_NATIVE_TTL_USEC); + entry->link = g_list_alloc (); + entry->link->data = entry; + g_queue_push_head_link (&route_cache_lru, entry->link); + g_hash_table_insert (route_cache_index, entry->repo_id, entry); + g_mutex_unlock (&route_cache_mutex); +} + +static void +route_cache_forget (const char *repo_id) +{ + g_mutex_lock (&route_cache_mutex); + CachedRoute *entry = route_cache_index ? + g_hash_table_lookup (route_cache_index, repo_id) : NULL; + if (entry) + route_cache_remove (entry); + g_mutex_unlock (&route_cache_mutex); +} + static gboolean read_route (SeafDBRow *row, void *data) { @@ -124,7 +229,8 @@ cf_v2_store_dir (const char *native_obj_dir, const char *repo_id) * before its initial commit, while the Repo row is written afterwards. * Without that fact a folder-share (sub-library) commit would be refused * even though it belongs in the native tree, exactly like CE. */ - if (seaf_db_statement_foreach_row (seaf->db, + gboolean cached = route_cache_get (repo_id, &route); + if (!cached && seaf_db_statement_foreach_row (seaf->db, "SELECT s.storage_id,s.repo_id,c.repo_key,c.state," "COALESCE(r.repo_id,t.repo_id,g.repo_id,v.repo_id) " "FROM (SELECT ? AS repo_id) i " @@ -169,9 +275,9 @@ cf_v2_store_dir (const char *native_obj_dir, const char *repo_id) out: if (!result) seaf_warning ("V2 refused unresolved storage for %.36s.\n", repo_id); - g_free (route.storage_id); - g_free (route.legacy_key); - g_free (route.legacy_state); + else if (!cached) + route_cache_put (repo_id, &route); + route_clear (&route); g_free (root); g_free (key_dir); g_free (obj_type); @@ -229,6 +335,7 @@ cf_v2_storage_reserve (const char *repo_id) goto close; } ret = 0; + route_cache_forget (repo_id); goto close; rollback: if (trans) @@ -252,9 +359,12 @@ cf_v2_storage_retire (const char *repo_id) { if (!cf_v2_storage_enabled ()) return 0; - return seaf_db_statement_query (seaf->db, + int ret = seaf_db_statement_query (seaf->db, "UPDATE cf_library_storage SET state='retired' WHERE repo_id=?", 1, "string", repo_id); + if (ret >= 0) + route_cache_forget (repo_id); + return ret; } /* Preserve native hardlinks on one volume; stage on the destination volume diff --git a/doc/target-single-library-10m.md b/doc/target-single-library-10m.md new file mode 100644 index 00000000..8ff754e7 --- /dev/null +++ b/doc/target-single-library-10m.md @@ -0,0 +1,37 @@ +# CloudFile 单库千万条目性能目标(研发验收口径) + +> 状态:**研发目标,尚未通过 1000 万条目真实压测,不属于当前产品性能承诺**。 +> 基线:CloudFile V2 / Seafile CE14;本文件不调整数据库 Schema、物理存储格式或 CE 同步上限。 + +## 目标 + +单个资料库(Repo)包含 **1000 万文件与目录条目**时,仍具有良好的 **交互、授权搜索与并发写入**性能; +并且一个大库的导入、GC、重建索引、批量读写不应让同一部署的其他库明显失去响应。 +“可以存下 1000 万条目”本身不算达标。 + +## 四类最小验收负载 + +1. **均衡目录**:约 1 万个目录,每个约 1000 个直接子项,总计约 1000 万条目。 +2. **真实热点目录**:总量 1000 万,包含 8000 项直接子目录;验证单页 200 项和按需树展开。 +3. **极端目录(找拐点,不预设必须通过)**:直接子项达到 10 万的单目录;检查 SeafDir 完整解压/解析、内存及目录修改放大。 +4. **多库隔离**:一个 1000 万库持续批量上传、索引或 GC,同时另一个库执行分页、搜索和小文件读写;对比同条件空闲基线。 + +每类负载覆盖实际中文文件名、长路径、目录 ACL/共享根、原生 Tag、不同文件大小、历史版本、多个用户; +不要仅用 1000 万个空文件证明容量。 + +## 性能与正确性指标 + +- **交互**:EAP/Hub 分页与懒加载,200 项一页;记录 P50/P95/P99、SQL 次数、完整目录解压/解析次数、CPU/RSS、HTTP 响应大小。200 项分页 P95 小于 1 秒仅为初步测量目标,需根据环境实测校准。 +- **搜索**:完整初始索引、增量追赶、跨目录/Tag 搜索、准确的 ACL/共享权限、索引延迟以及范围分页;不能以跳过受控子树的搜索结果宣称能力完成。 +- **写入**:多目录并发上传/修改、CAS/Branch 冲突重试、提交等待与尾延迟、历史版本/Guard 正确性;不能用降低授权或丢失提交换速度。 +- **多库**:对比普通库在大库负载前后的延迟/错误率,同时记录共享 DB 连接、IOPS、CPU、内存、Redis 和搜索工作线程;优化限制后台资源争用,而不是额外部署一套库级服务。 +- **运维**:GC、fsck、历史处理与初始索引必须避免无限制内存增长及长时间饥饿交互操作。 + +## 设计边界和交付顺序 + +1. **已发现的路径**:C/Go 本地对象访问重复查存储路由。先做 repo_id 级、有界进程内物理路由缓存;不缓存权限与文件内容。此项见同分支 `cf-v2-storage.c` 和 Go `cf_v2_storage.go`。 +2. **已存在的机制**:Hub 的有界 ACL 分页与 EAP 的按需分页继续复用,不恢复整层拉取兜底。再验证底层 SeafDir 是否仍完整解压和解析单目录对象。 +3. **待量化**:Branch 竞争与批量写入、Meilisearch 增量/权限完整性、GC/索引任务对其他资料库的影响;每项以真实负载数据和独立小 PR 收敛。 + +默认不做数据库改表/分库、全量文件 SQL 索引、第二套树元数据、Redis 目录全量缓存、对象格式重写、 +每库微服务或新配置开关。单库服务端 1000 万条目目标**不等于**所有文件必须支持客户端全量同步。 diff --git a/fileserver/objstore/cf_v2_route_wiring_test.go b/fileserver/objstore/cf_v2_route_wiring_test.go index 58135856..afa9c851 100644 --- a/fileserver/objstore/cf_v2_route_wiring_test.go +++ b/fileserver/objstore/cf_v2_route_wiring_test.go @@ -48,6 +48,9 @@ func TestV2ObjectStoreSelectsManagedRoute(t *testing.T) { routed.lookup = func(string) (string, string, string, error) { return "auto-local:invalid", repoID, "active", nil } + // The fake route registry changed out of band: clear the prior + // positive route as an offline maintenance/restart would do. + routed.invalidateRoute(repoID) if err := backend.write(repoID, objID, strings.NewReader("incorrect"), false); err == nil { t.Fatal("invalid binding was not rejected") } diff --git a/fileserver/objstore/cf_v2_storage.go b/fileserver/objstore/cf_v2_storage.go index 9b08f4d5..78b3d158 100644 --- a/fileserver/objstore/cf_v2_storage.go +++ b/fileserver/objstore/cf_v2_storage.go @@ -1,6 +1,7 @@ package objstore import ( + "container/list" "context" "database/sql" "fmt" @@ -8,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/google/uuid" @@ -19,10 +21,80 @@ var v2StorageDB *sql.DB func InitV2Storage(db *sql.DB) { v2StorageDB = db } +// Repository storage routes are immutable during normal service life. Keep only +// verified positive mappings, with a short TTL for offline repair/GC changes. +// This is a physical-path cache, never an authorization or object-content cache. +const ( + v2RouteCacheCapacity = 1024 + v2RouteCacheTTL = 10 * time.Second + v2NativeRouteCacheTTL = time.Second +) + +type v2CachedRoute struct { + repoID string + route string + legacyKey string + legacyState string + expires time.Time +} + type v2StorageBackend struct { native *fsBackend root string lookup func(string) (string, string, string, error) + + cacheMu sync.Mutex + cacheIndex map[string]*list.Element + cacheLRU list.List +} + +func (b *v2StorageBackend) cachedRoute(repoID string) (string, string, string, bool) { + b.cacheMu.Lock() + defer b.cacheMu.Unlock() + if elem := b.cacheIndex[repoID]; elem != nil { + entry := elem.Value.(*v2CachedRoute) + if time.Now().Before(entry.expires) { + b.cacheLRU.MoveToFront(elem) + return entry.route, entry.legacyKey, entry.legacyState, true + } + b.cacheLRU.Remove(elem) + delete(b.cacheIndex, repoID) + } + return "", "", "", false +} + +func (b *v2StorageBackend) rememberRoute(repoID, route, legacyKey, legacyState string) { + b.cacheMu.Lock() + defer b.cacheMu.Unlock() + if b.cacheIndex == nil { + b.cacheIndex = make(map[string]*list.Element) + } + if elem := b.cacheIndex[repoID]; elem != nil { + b.cacheLRU.Remove(elem) + } + ttl := v2RouteCacheTTL + if route == "" { // CE/VirtualRepo lifecycle facts are not immutable. + ttl = v2NativeRouteCacheTTL + } + entry := &v2CachedRoute{repoID, route, legacyKey, legacyState, time.Now().Add(ttl)} + b.cacheIndex[repoID] = b.cacheLRU.PushFront(entry) + if b.cacheLRU.Len() > v2RouteCacheCapacity { + oldest := b.cacheLRU.Remove(b.cacheLRU.Back()).(*v2CachedRoute) + delete(b.cacheIndex, oldest.repoID) + } +} + +// Test seam for an injected out-of-band route change. No production +// management API invokes this: live physical routes are immutable, and offline +// repair requires stopped writers and process restart. The TTL only limits +// incidental staleness; it is not an online migration protocol. +func (b *v2StorageBackend) invalidateRoute(repoID string) { + b.cacheMu.Lock() + defer b.cacheMu.Unlock() + if elem := b.cacheIndex[repoID]; elem != nil { + b.cacheLRU.Remove(elem) + delete(b.cacheIndex, repoID) + } } func v2KeyValid(key string) bool { @@ -126,13 +198,28 @@ func (b *v2StorageBackend) resolve(repoID string) (*fsBackend, error) { if _, err := uuid.Parse(repoID); err != nil || len(repoID) != 36 { return nil, fmt.Errorf("invalid V2 storage repository ID") } - route, legacyKey, legacyState, err := b.lookup(repoID) + route, legacyKey, legacyState, cached := b.cachedRoute(repoID) + if !cached { + var err error + route, legacyKey, legacyState, err = b.lookup(repoID) + if err != nil { + return nil, err + } + } + child, err := b.resolveRoute(repoID, route, legacyKey, legacyState) if err != nil { return nil, err } + if !cached { + b.rememberRoute(repoID, route, legacyKey, legacyState) + } + return child, nil +} + +func (b *v2StorageBackend) resolveRoute(repoID, route, legacyKey, legacyState string) (*fsBackend, error) { if route == "" { - // Existing CE repos (including Trash/GC) remain on their original - // filesystem. Production lookup has already checked real CE facts. + // Native CE and virtual libraries remain on their original filesystem. + // A lost managed route must never turn into a native fallback. if legacyKey == "" || (legacyKey == "native-"+repoID && (legacyState == "active" || legacyState == "retired")) { return b.native, nil @@ -146,11 +233,11 @@ func (b *v2StorageBackend) resolve(repoID string) (*fsBackend, error) { if !v2KeyValid(key) { return nil, fmt.Errorf("invalid V2 storage directory key") } + // Validate the mount on every object operation, including cache hits. + // A missing or replaced mount must not redirect writes into a new tree. if err := v2RealDirectory(b.root); err != nil { return nil, err } - // Current V2 writes UUID as the key. A previously created V2 repo may - // still have a custom physical key encoded in its immutable native route. keyDir := filepath.Join(b.root, key) if err := v2RealDirectory(keyDir); err != nil { return nil, err diff --git a/fileserver/objstore/cf_v2_storage_test.go b/fileserver/objstore/cf_v2_storage_test.go index 3a2d2d9e..f7f6c517 100644 --- a/fileserver/objstore/cf_v2_storage_test.go +++ b/fileserver/objstore/cf_v2_storage_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func v2Fixture(t *testing.T, objType string) (*v2StorageBackend, string) { @@ -50,6 +51,7 @@ func TestV2StorageBytesAndNativeIsolation(t *testing.T) { t.Fatal("routed bytes leaked into native storage") } b.lookup = func(string) (string, string, string, error) { return "", "", "", nil } + b.invalidateRoute(repoID) // Simulate an explicit offline route change. if err := b.write(repoID, objID, strings.NewReader("native"), true); err != nil { t.Fatal(err) } @@ -151,6 +153,7 @@ func TestV2RegisteredNativeLibraryUsesOriginalObjects(t *testing.T) { b.lookup = func(string) (string, string, string, error) { return broken[0], broken[1], broken[2], nil } + b.invalidateRoute(repoID) if _, err := b.resolve(repoID); err == nil { t.Fatalf("accepted inconsistent historical binding: %v", broken) } @@ -198,3 +201,107 @@ func TestV2RetiredNativeRemainsAddressableForColdGC(t *testing.T) { t.Fatalf("retired native object path must remain for GC: %v", err) } } + +func TestV2StorageRouteCachePerLibrary(t *testing.T) { + b, root := v2Fixture(t, "fs") + other := "a2222222-2222-4222-8222-222222222222" + if err := os.Mkdir(filepath.Join(root, other), 0700); err != nil { + t.Fatal(err) + } + calls := map[string]int{} + b.lookup = func(id string) (string, string, string, error) { + calls[id]++ + return "auto-local:" + id, "", "", nil + } + for i := 0; i < 30; i++ { + for _, id := range []string{repoID, other} { + if _, err := b.resolve(id); err != nil { + t.Fatal(err) + } + } + } + if calls[repoID] != 1 || calls[other] != 1 { + t.Fatalf("per-object lookup was not eliminated: %v", calls) + } + b.invalidateRoute(repoID) + if _, err := b.resolve(repoID); err != nil || calls[repoID] != 2 || calls[other] != 1 { + t.Fatalf("library-specific invalidation lost isolation: %v %v", calls, err) + } + b.cacheMu.Lock() + b.cacheIndex[repoID].Value.(*v2CachedRoute).expires = time.Now().Add(-time.Second) + b.cacheMu.Unlock() + if _, err := b.resolve(repoID); err != nil || calls[repoID] != 3 || calls[other] != 1 { + t.Fatalf("expired route not rechecked per library: %v %v", calls, err) + } + // Cache hits must still validate the physical root; no silent remount. + if err := os.Remove(filepath.Join(root, other)); err != nil { + t.Fatal(err) + } + if _, err := b.resolve(other); err == nil { + t.Fatal("cached managed route ignored a removed directory") + } +} + +func TestV2StorageFailuresNeverCached(t *testing.T) { + b, _ := v2Fixture(t, "blocks") + calls := 0 + b.lookup = func(string) (string, string, string, error) { + calls++ + if calls <= 2 { + return "", "", "", errors.New("route database unavailable") + } + return "auto-local:" + repoID, "", "", nil + } + for i := 0; i < 2; i++ { + if _, err := b.resolve(repoID); err == nil { + t.Fatal("failed lookup accepted") + } + } + if _, err := b.resolve(repoID); err != nil { + t.Fatal(err) + } + if _, err := b.resolve(repoID); err != nil || calls != 3 { + t.Fatalf("route failures should not stick; successful lookup should: %d %v", calls, err) + } +} + +func TestV2StorageSlowLibraryDoesNotLockOtherRoutes(t *testing.T) { + b, root := v2Fixture(t, "fs") + other := "a2222222-2222-4222-8222-222222222222" + if err := os.Mkdir(filepath.Join(root, other), 0700); err != nil { + t.Fatal(err) + } + blocked := make(chan struct{}) + release := make(chan struct{}) + b.lookup = func(id string) (string, string, string, error) { + if id == repoID { + close(blocked) + <-release + } + return "auto-local:" + id, "", "", nil + } + first := make(chan error, 1) + go func() { + _, err := b.resolve(repoID) + first <- err + }() + <-blocked + second := make(chan error, 1) + go func() { + _, err := b.resolve(other) + second <- err + }() + select { + case err := <-second: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + close(release) + t.Fatal("slow library held global cache lock during lookup") + } + close(release) + if err := <-first; err != nil { + t.Fatal(err) + } +} diff --git a/tests/local_storage/storage.c b/tests/local_storage/storage.c index ac0faf8e..75068a4e 100644 --- a/tests/local_storage/storage.c +++ b/tests/local_storage/storage.c @@ -21,7 +21,7 @@ struct SeafDBRow { const char *cell[5]; }; struct SeafDBTrans { int unused; }; static struct SeafDBTrans fake_trans; static const char *row_cells[5]; -static int fail_insert, fail_commit, sql_inserts, commits, rollbacks; +static int fail_insert, fail_commit, sql_inserts, commits, rollbacks, route_selects; const char * seaf_db_row_get_column_text (SeafDBRow *row, guint32 col) @@ -35,6 +35,7 @@ seaf_db_statement_foreach_row (SeafDB *db, const char *sql, SeafDBRowFunc fn, void *data, int n, ...) { g_assert_nonnull (strstr (sql, "RepoStorageId")); + route_selects++; /* A folder-share sub-library writes its first commit while only the * VirtualRepo row exists; the native-existence join must include it. */ g_assert_nonnull (strstr (sql, "VirtualRepo")); @@ -100,6 +101,8 @@ route_row (const char *storage, const char *legacy_key, const char *state, row_cells[2] = legacy_key; row_cells[3] = state; row_cells[4] = native_exists ? uuid : NULL; + /* Changes to the fake immutable route model are explicit invalidations. */ + route_cache_forget (uuid); } static void @@ -129,6 +132,34 @@ test_uuid_v2_and_legacy_routes (const char *base) g_free (actual); g_free (expected); + /* Thousands of FS/block operations in one library use one positive + * route lookup. A second library has its own slot and never evicts it. */ + int first_lookups = route_selects; + for (int i = 0; i < 40; ++i) { + actual = cf_v2_store_dir (native, repo); + g_assert_nonnull (actual); + g_free (actual); + } + g_assert_cmpint (route_selects, ==, first_lookups); + const char *other_repo = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + actual = cf_v2_store_dir (native, other_repo); + g_assert_nonnull (actual); + g_free (actual); + g_assert_cmpint (route_selects, ==, first_lookups + 1); + actual = cf_v2_store_dir (native, repo); + g_assert_nonnull (actual); + g_free (actual); + g_assert_cmpint (route_selects, ==, first_lookups + 1); + g_mutex_lock (&route_cache_mutex); + CachedRoute *cached = g_hash_table_lookup (route_cache_index, repo); + g_assert_nonnull (cached); + cached->expires_at = 0; + g_mutex_unlock (&route_cache_mutex); + actual = cf_v2_store_dir (native, repo); + g_assert_nonnull (actual); + g_free (actual); + g_assert_cmpint (route_selects, ==, first_lookups + 2); + route_row ("auto-local:historical-key", NULL, NULL, TRUE, FALSE); actual = cf_v2_store_dir (native, repo); expected = g_build_filename (historical, "storage", "commits", repo, NULL); @@ -148,7 +179,10 @@ test_uuid_v2_and_legacy_routes (const char *base) g_assert_cmpstr (actual, ==, expected); g_free (actual); route_row (NULL, NULL, NULL, FALSE, FALSE); + int failed_lookups = route_selects; + g_assert_null (cf_v2_store_dir (native, repo)); g_assert_null (cf_v2_store_dir (native, repo)); + g_assert_cmpint (route_selects, ==, failed_lookups + 2); route_row (NULL, "historical-key", "active", FALSE, TRUE); g_assert_null (cf_v2_store_dir (native, repo)); route_row (NULL, repo, "active", FALSE, TRUE);