Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 115 additions & 5 deletions common/cf-v2-storage.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions doc/target-single-library-10m.md
Original file line number Diff line number Diff line change
@@ -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 万条目目标**不等于**所有文件必须支持客户端全量同步。
3 changes: 3 additions & 0 deletions fileserver/objstore/cf_v2_route_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
97 changes: 92 additions & 5 deletions fileserver/objstore/cf_v2_storage.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package objstore

import (
"container/list"
"context"
"database/sql"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"

"github.com/google/uuid"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading