feat(storage): weighted policy selection by free capacity (#226)

GroupSetting.WeightedPolicies makes default policy selection pick the
allowed policy with the most remaining MaxTotalSize headroom that fits
the file (upstream #2178 item 2). Directory/user preferences still win;
uncapped and suspended policies are excluded from the weighing.

Generated with Devin
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
parent a939c6e383
commit 1f4c32f2d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -224,6 +224,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Per-user blob relocation (upstream #2729/misc) — `RelocateEntityService` gains a third scope `src_user_id` (mutually exclusive with `entity_ids`/`src_policy_id`); `NewRelocateUserTask` selects entities by `created_by` with the same cursor-resumable transfer path; admin user editor gains a "Relocate files" button opening the relocate dialog prefilled with the user scope; en+zh locales - [x] Per-user blob relocation (upstream #2729/misc) — `RelocateEntityService` gains a third scope `src_user_id` (mutually exclusive with `entity_ids`/`src_policy_id`); `NewRelocateUserTask` selects entities by `created_by` with the same cursor-resumable transfer path; admin user editor gains a "Relocate files" button opening the relocate dialog prefilled with the user scope; en+zh locales
- [x] Tencent Captcha (upstream #2178) — `captcha_type=tcaptcha` now performs real verification: `pkg/tcaptcha` calls Tencent Cloud `DescribeCaptchaResult` with full TC3-HMAC-SHA256 request signing (CaptchaAppId/AppSecretKey + SecretId/SecretKey, CaptchaType 9, client IP propagated); login/register/forgot-password flows emit `{ticket, randstr}` from the TCaptcha.js popup widget via a new `TCaptcha` verify-button component; admin Captcha section gains the provider option + four credential fields; en+zh locales - [x] Tencent Captcha (upstream #2178) — `captcha_type=tcaptcha` now performs real verification: `pkg/tcaptcha` calls Tencent Cloud `DescribeCaptchaResult` with full TC3-HMAC-SHA256 request signing (CaptchaAppId/AppSecretKey + SecretId/SecretKey, CaptchaType 9, client IP propagated); login/register/forgot-password flows emit `{ticket, randstr}` from the TCaptcha.js popup widget via a new `TCaptcha` verify-button component; admin Captcha section gains the provider option + four credential fields; en+zh locales
- [x] Localized admin strings (#25/#2691) — `setting.Provider.Localized` resolves any `<key>_i18n` JSON map by language tag (exact → bare primary subtag → wildcard `*` → base value); `SiteBasicLocalized` covers site name/title/description; consumed by site config, announcement endpoint, share-preview OG tags, index.html placeholders, WOPI breadcrumb, and email templates (recipient language); SKU gains `name_i18n`/`des_i18n` columns resolved per buyer language in the shop; admin gets a reusable `LocalizedFields` accordion (per-language inputs) wired into site name/description/announcement and SKU name/description; en+zh locales - [x] Localized admin strings (#25/#2691) — `setting.Provider.Localized` resolves any `<key>_i18n` JSON map by language tag (exact → bare primary subtag → wildcard `*` → base value); `SiteBasicLocalized` covers site name/title/description; consumed by site config, announcement endpoint, share-preview OG tags, index.html placeholders, WOPI breadcrumb, and email templates (recipient language); SKU gains `name_i18n`/`des_i18n` columns resolved per buyer language in the shop; admin gets a reusable `LocalizedFields` accordion (per-language inputs) wired into site name/description/announcement and SKU name/description; en+zh locales
- [x] Weighted policy selection (upstream #2178 item 2) — `GroupSetting.WeightedPolicies` spreads uploads across the group's allowed policies by free capacity: `pickByFreeCapacity` picks the member with the most remaining `MaxTotalSize` headroom that fits the file (uncapped/suspended members not weighed); explicit directory/user preferences still win; size-aware `getPreferredPolicyForSize` wired into both upload paths; admin group editor gains a switch, en+zh locales
## 6. Phase D — desktop, all platforms ## 6. Phase D — desktop, all platforms

@ -1427,6 +1427,8 @@
"editGroup": "Edit {{group}}", "editGroup": "Edit {{group}}",
"switchablePolicies": "Switchable storage policies", "switchablePolicies": "Switchable storage policies",
"switchablePoliciesDes": "Policies members of this group can freely switch to. The default policy above is always available.", "switchablePoliciesDes": "Policies members of this group can freely switch to. The default policy above is always available.",
"weightedPolicies": "Weighted policy selection",
"weightedPoliciesDes": "When enabled, uploads without an explicit policy preference are routed to the allowed policy with the most free capacity that fits the file. Only policies with a total capacity limit are weighed.",
"sharePublicList": "Public share directory", "sharePublicList": "Public share directory",
"sharePublicListDes": "When enabled, users can opt their share links into the public directory (Discover page). Password-protected shares cannot be listed." "sharePublicListDes": "When enabled, users can opt their share links into the public directory (Discover page). Password-protected shares cannot be listed."
}, },

@ -1427,6 +1427,8 @@
"editGroup": "编辑 {{group}}", "editGroup": "编辑 {{group}}",
"switchablePolicies": "可切换存储策略", "switchablePolicies": "可切换存储策略",
"switchablePoliciesDes": "该组成员可以自由切换使用的存储策略。上方默认策略始终可用。", "switchablePoliciesDes": "该组成员可以自由切换使用的存储策略。上方默认策略始终可用。",
"weightedPolicies": "按容量加权选择策略",
"weightedPoliciesDes": "启用后,未指定策略偏好的上传会被分配到剩余容量最多且可容纳该文件的可切换存储策略。仅参与设置了总容量限制的策略。",
"sharePublicList": "公开分享目录", "sharePublicList": "公开分享目录",
"sharePublicListDes": "开启后,用户可以将分享链接收录到公开目录(发现页)。带密码的分享无法被收录。" "sharePublicListDes": "开启后,用户可以将分享链接收录到公开目录(发现页)。带密码的分享无法被收录。"
}, },

@ -68,6 +68,7 @@ export interface GroupSetting {
default_pinned?: number[]; default_pinned?: number[];
allowed_nodes?: number[]; allowed_nodes?: number[];
allow_select_node?: boolean; allow_select_node?: boolean;
weighted_policies?: boolean;
} }
export interface AdminListGroupResponse { export interface AdminListGroupResponse {

@ -126,6 +126,25 @@ const BasicInfoSection = () => {
/> />
<NoMarginHelperText>{t("group.switchablePoliciesDes")}</NoMarginHelperText> <NoMarginHelperText>{t("group.switchablePoliciesDes")}</NoMarginHelperText>
</SettingForm> </SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={!!values.settings?.weighted_policies}
onChange={(e) =>
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, weighted_policies: e.target.checked ? true : undefined },
}))
}
/>
}
label={t("group.weightedPolicies")}
/>
<NoMarginHelperText>{t("group.weightedPoliciesDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("group.initialStorageQuota")} lgWidth={5}> <SettingForm title={t("group.initialStorageQuota")} lgWidth={5}>
<FormControl fullWidth> <FormControl fullWidth>
<SizeInput <SizeInput

@ -77,6 +77,12 @@ type (
// AllowSelectNode lets members pick a preferred node when creating // AllowSelectNode lets members pick a preferred node when creating
// tasks (remote download, archive create/extract). // tasks (remote download, archive create/extract).
AllowSelectNode bool `json:"allow_select_node,omitempty"` AllowSelectNode bool `json:"allow_select_node,omitempty"`
// WeightedPolicies makes the default policy selection spread uploads
// across the group's allowed policies by free capacity: the member
// with the most remaining MaxTotalSize headroom that fits the file
// wins. Policies without a MaxTotalSize cap are excluded from the
// weighing; if none qualify the group default applies as before.
WeightedPolicies bool `json:"weighted_policies,omitempty"`
} }
// PolicySetting 非公有的存储策略属性 // PolicySetting 非公有的存储策略属性

@ -11,6 +11,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/application/constants" "github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/ent" "github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
"github.com/cloudreve/Cloudreve/v4/inventory" "github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types" "github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/activity" "github.com/cloudreve/Cloudreve/v4/pkg/activity"
@ -759,6 +760,12 @@ func (f *DBFS) generateEncryptMetadata(ctx context.Context, uploadRequest *fs.Up
// getPreferredPolicy tries to get the preferred storage policy for the given file. // getPreferredPolicy tries to get the preferred storage policy for the given file.
func (f *DBFS) getPreferredPolicy(ctx context.Context, file *File) (*ent.StoragePolicy, error) { func (f *DBFS) getPreferredPolicy(ctx context.Context, file *File) (*ent.StoragePolicy, error) {
return f.getPreferredPolicyForSize(ctx, file, 0)
}
// getPreferredPolicyForSize is getPreferredPolicy with the incoming upload
// size, so weighted-capacity selection can exclude policies without headroom.
func (f *DBFS) getPreferredPolicyForSize(ctx context.Context, file *File, size int64) (*ent.StoragePolicy, error) {
owner := file.Owner() owner := file.Owner()
ownerGroup := owner.Edges.Group ownerGroup := owner.Edges.Group
if ownerGroup == nil { if ownerGroup == nil {
@ -774,7 +781,7 @@ func (f *DBFS) getPreferredPolicy(ctx context.Context, file *File) (*ent.Storage
return nil, serializer.NewError(serializer.CodeDBError, "No active storage policy available for the group", nil) return nil, serializer.NewError(serializer.CodeDBError, "No active storage policy available for the group", nil)
} }
candidate := f.pickPolicy(ctx, file, owner, allowed) candidate := f.pickPolicy(ctx, file, owner, allowed, size)
// A load_balance policy resolves to one of its weighted children before // A load_balance policy resolves to one of its weighted children before
// any storage driver sees it. // any storage driver sees it.
@ -791,9 +798,11 @@ func (f *DBFS) getPreferredPolicy(ctx context.Context, file *File) (*ent.Storage
// pickPolicy chooses among the group's allowed policies: the nearest ancestor // pickPolicy chooses among the group's allowed policies: the nearest ancestor
// directory carrying a preferred-policy marker wins; next the owner's own // directory carrying a preferred-policy marker wins; next the owner's own
// preferred_policy setting (applied only in their own tree); finally the // preferred_policy setting (applied only in their own tree); with the group's
// group default, or the first allowed policy when none is configured. // weighted_policies flag on, the allowed policy with the most free capacity
func (f *DBFS) pickPolicy(ctx context.Context, file *File, owner *ent.User, allowed []*ent.StoragePolicy) *ent.StoragePolicy { // that fits the file; finally the group default, or the first allowed policy
// when none is configured.
func (f *DBFS) pickPolicy(ctx context.Context, file *File, owner *ent.User, allowed []*ent.StoragePolicy, size int64) *ent.StoragePolicy {
inAllowed := func(id int) *ent.StoragePolicy { inAllowed := func(id int) *ent.StoragePolicy {
for _, p := range allowed { for _, p := range allowed {
if p.ID == id { if p.ID == id {
@ -831,12 +840,41 @@ func (f *DBFS) pickPolicy(ctx context.Context, file *File, owner *ent.User, allo
} }
} }
if ownerGroup := owner.Edges.Group; ownerGroup != nil && ownerGroup.Settings != nil && ownerGroup.Settings.WeightedPolicies {
if p := f.pickByFreeCapacity(ctx, allowed, size); p != nil {
return p
}
}
if p := inAllowed(owner.Edges.Group.StoragePolicyID); p != nil { if p := inAllowed(owner.Edges.Group.StoragePolicyID); p != nil {
return p return p
} }
return allowed[0] return allowed[0]
} }
// pickByFreeCapacity returns the allowed policy with the most remaining
// MaxTotalSize headroom that still fits size. Uncapped and suspended
// policies are not weighed; nil is returned when nothing qualifies.
func (f *DBFS) pickByFreeCapacity(ctx context.Context, allowed []*ent.StoragePolicy, size int64) *ent.StoragePolicy {
var best *ent.StoragePolicy
bestFree := int64(-1)
for _, p := range allowed {
if p.Settings == nil || p.Settings.MaxTotalSize <= 0 || p.Status == storagepolicy.StatusSuspended {
continue
}
_, used, err := f.fileClient.CountEntityByStoragePolicyID(ctx, p.ID)
if err != nil {
f.l.Warning("Failed to weigh storage policy %d usage: %s", p.ID, err)
continue
}
free := p.Settings.MaxTotalSize - int64(used)
if free >= size && free > bestFree {
best, bestFree = p, free
}
}
return best
}
func (f *DBFS) getFileByPath(ctx context.Context, navigator Navigator, path *fs.URI) (*File, error) { func (f *DBFS) getFileByPath(ctx context.Context, navigator Navigator, path *fs.URI) (*File, error) {
file, err := navigator.To(ctx, path) file, err := navigator.To(ctx, path)
if err != nil && errors.Is(err, ErrFsNotInitialized) { if err != nil && errors.Is(err, ErrFsNotInitialized) {

@ -99,13 +99,13 @@ func TestPickPolicyPrecedence(t *testing.T) {
t.Run("directory marker wins over user preference", func(t *testing.T) { t.Run("directory marker wins over user preference", func(t *testing.T) {
setPreferredMarker(t, client, dir, f.hasher, a.ID) setPreferredMarker(t, client, dir, f.hasher, a.ID)
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed) got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed, 0)
require.Equal(t, a.ID, got.ID) require.Equal(t, a.ID, got.ID)
}) })
t.Run("user preference wins over group default", func(t *testing.T) { t.Run("user preference wins over group default", func(t *testing.T) {
client.Metadata.Delete().ExecX(schema.SkipSoftDelete(ctx)) client.Metadata.Delete().ExecX(schema.SkipSoftDelete(ctx))
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed) got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed, 0)
require.Equal(t, b.ID, got.ID) require.Equal(t, b.ID, got.ID)
}) })
@ -116,14 +116,14 @@ func TestPickPolicyPrecedence(t *testing.T) {
SetStatus(storagepolicy.StatusActive).SaveX(ctx) SetStatus(storagepolicy.StatusActive).SaveX(ctx)
setPreferredMarker(t, client, root, f.hasher, a.ID) setPreferredMarker(t, client, root, f.hasher, a.ID)
setPreferredMarker(t, client, dir, f.hasher, outside.ID) setPreferredMarker(t, client, dir, f.hasher, outside.ID)
got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed) got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed, 0)
require.Equal(t, b.ID, got.ID) require.Equal(t, b.ID, got.ID)
}) })
t.Run("other user's tree ignores user preference", func(t *testing.T) { t.Run("other user's tree ignores user preference", func(t *testing.T) {
other := client.User.Create().SetEmail("x@example.com").SetNick("x").SetGroup(group).SaveX(ctx) other := client.User.Create().SetEmail("x@example.com").SetNick("x").SetGroup(group).SaveX(ctx)
otherFs := pickPolicyDBFS(t, client, other) otherFs := pickPolicyDBFS(t, client, other)
got := otherFs.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed) got := otherFs.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed, 0)
require.Equal(t, def.ID, got.ID) require.Equal(t, def.ID, got.ID)
}) })
@ -132,7 +132,68 @@ func TestPickPolicyPrecedence(t *testing.T) {
SetGroup(group).SetSettings(&types.UserSetting{}).SaveX(ctx) SetGroup(group).SetSettings(&types.UserSetting{}).SaveX(ctx)
ownerNoPref.SetGroup(group) ownerNoPref.SetGroup(group)
fsNoPref := pickPolicyDBFS(t, client, ownerNoPref) fsNoPref := pickPolicyDBFS(t, client, ownerNoPref)
got := fsNoPref.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), ownerNoPref, allowed) got := fsNoPref.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), ownerNoPref, allowed, 0)
require.Equal(t, def.ID, got.ID)
})
}
func TestPickPolicyWeightedCapacity(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
mk := func(name string, cap int64) *ent.StoragePolicy {
return client.StoragePolicy.Create().SetName(name).SetType("local").
SetStatus(storagepolicy.StatusActive).
SetSettings(&types.PolicySetting{MaxTotalSize: cap}).SaveX(ctx)
}
def := mk("def", 0)
big := mk("big", 1000)
small := mk("small", 100)
uncapped := client.StoragePolicy.Create().SetName("uncapped").SetType("local").
SetStatus(storagepolicy.StatusActive).
SetSettings(&types.PolicySetting{}).SaveX(ctx)
suspended := client.StoragePolicy.Create().SetName("sus").SetType("local").
SetStatus(storagepolicy.StatusSuspended).
SetSettings(&types.PolicySetting{MaxTotalSize: 99999}).SaveX(ctx)
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
SetStoragePolicies(def).AddAllowedPolicies(big, small, uncapped, suspended).
SetSettings(&types.GroupSetting{WeightedPolicies: true}).SaveX(ctx)
owner := client.User.Create().SetEmail("o@example.com").SetNick("o").
SetGroup(group).SetSettings(&types.UserSetting{}).SaveX(ctx)
root := client.File.Create().SetName(inventory.RootFolderName).
SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
allowed := []*ent.StoragePolicy{big, small, uncapped, suspended, def}
f := pickPolicyDBFS(t, client, owner)
owner.SetGroup(group)
t.Run("most free capacity wins", func(t *testing.T) {
got := f.pickPolicy(ctx, wrapChain(root), owner, allowed, 10)
require.Equal(t, big.ID, got.ID)
})
t.Run("skips policy without headroom for the file", func(t *testing.T) {
got := f.pickPolicy(ctx, wrapChain(root), owner, allowed, 500)
require.Equal(t, big.ID, got.ID) // small only has 100 free
got = f.pickPolicy(ctx, wrapChain(root), owner, allowed, 2000)
require.Equal(t, def.ID, got.ID) // nothing fits -> group default
})
t.Run("usage fills shift the pick", func(t *testing.T) {
client.Entity.Create().SetType(int(types.EntityTypeVersion)).
SetSource("cloudreve/data/big/" + t.Name()).SetSize(950).
SetReferenceCount(1).SetCreatedBy(owner.ID).
SetStoragePolicyEntities(big.ID).SaveX(ctx)
got := f.pickPolicy(ctx, wrapChain(root), owner, allowed, 10)
require.Equal(t, small.ID, got.ID) // big now has only 50 free
})
t.Run("flag off keeps group default", func(t *testing.T) {
group.Update().SetSettings(&types.GroupSetting{}).ExecX(ctx)
owner.SetGroup(client.Group.GetX(ctx, group.ID))
got := f.pickPolicy(ctx, wrapChain(root), owner, allowed, 10)
require.Equal(t, def.ID, got.ID) require.Equal(t, def.ID, got.ID)
}) })
} }

@ -46,7 +46,7 @@ func (f *DBFS) PreValidateUpload(ctx context.Context, dst *fs.URI, files ...fs.P
} }
// Get parent folder storage policy and performs validation // Get parent folder storage policy and performs validation
policy, err := f.getPreferredPolicy(ctx, dstFile) policy, err := f.getPreferredPolicyForSize(ctx, dstFile, total)
if err != nil { if err != nil {
return err return err
} }
@ -146,7 +146,7 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts ..
// explicitly instead of inheriting the ancestor's preference. // explicitly instead of inheriting the ancestor's preference.
policy, err = f.storagePolicyClient.GetPolicyByID(ctx, req.Props.PreferredStoragePolicy) policy, err = f.storagePolicyClient.GetPolicyByID(ctx, req.Props.PreferredStoragePolicy)
} else { } else {
policy, err = f.getPreferredPolicy(ctx, ancestor) policy, err = f.getPreferredPolicyForSize(ctx, ancestor, req.Props.Size)
} }
if err != nil { if err != nil {
return nil, err return nil, err

Loading…
Cancel
Save