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
29 changes: 27 additions & 2 deletions docs/task-plugin-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,35 @@
- 路由只匹配 manifest 声明的上游模型;省略 `route.Models` 表示继承 manifest 的范围,指定时进一步收窄。缺失或未知模型拒绝绑定。
- `BindMapped` 固定插件版本、包摘要、原始及映射后模型,以及宿主拥有的 usage contract;`Resolve` 使用历史包恢复任务,不自动切换最新版本。
- `AttachTaskPluginBinding` 校验用户、Token、渠道、请求、模型及冻结用量契约,写入 `Task.PrivateData`。调用方必须在发起付费请求前通过现有事务保存任务;该函数自身不执行数据库写入或上游提交。
- 重启时必须重新加载所需历史包,并在开放流量前恢复宿主的撤销记录。缺失历史包、无效契约、身份变化或已撤销版本均拒绝恢复,不自动重提任务。当前没有自动保存插件目录或撤销清单的管理服务。
- 持久目录在开放流量前恢复历史包和撤销记录。缺失历史包、摘要不匹配、无效契约、身份变化或已撤销版本均拒绝恢复,不自动重提任务。

The loader accepts at most 1 MiB and verifies an externally trusted SHA-256 of the whole document. Batch installation publishes packages and routes together; a conflict leaves the previous generation intact. A key/version cannot silently change contents. Routes match only manifest-declared upstream models; an omitted `route.Models` inherits that set, while an explicit list narrows it. Missing or unknown models cannot bind. Bindings freeze package, usage contract, client model alias and upstream model. Recovery resolves historical packages rather than the latest version.

The service validates task ownership and the frozen usage contract before attaching private metadata. The caller must persist it in the existing transaction before any billable request. The host must reload retained historical packages and revocations before serving traffic after restart. Missing or revoked identities fail closed and never cause automatic submission. Automatic catalog/revocation storage is not implemented here.
The service validates task ownership and the frozen usage contract before attaching private metadata. The caller must persist it in the existing transaction before any billable request. The durable catalog restores historical packages and revocations before startup serves traffic. Missing, altered or revoked identities fail closed and never cause automatic submission.

## 持久目录与恢复 / Durable catalog and recovery

`taskplugin.OpenCatalog(ctx, model.NewTaskPluginCatalogStore(db))` 加载内部目录,`catalog.Host()` 提供既有绑定与恢复能力。应用启动由 `InitializeTaskPluginCatalog` 完成恢复;损坏包或无效状态会阻止启动。迁移仍由主节点执行,非主节点在恢复前只读等待两张目录表,最长 10 分钟且可取消;等待超时或恢复失败均阻止开放流量。目录仍没有公开管理路由或自动加载 JS 的行为。

- `Install` 批量校验并保存完整包,原始文档字节和摘要保持不变;同 key/version 不允许替换内容。安装版本成为该 key 的启用版本,历史版本保留。
- `SetEnabled(false)` 停止新任务绑定,旧任务仍能按历史版本恢复。`Activate` 显式切换已有版本,不自动选择“最新”版本。
- `Revoke` 永久撤销指定版本,新绑定和现有 session 的下一次使用均被拒绝;接口不提供撤销回滚或历史版本删除。
- `task_plugin_catalog_heads` 的版本号条件更新与 `task_plugin_versions` 的变更在同一事务内提交。事务成功后一次发布宿主状态;存储失败、结果未知或刷新失败会暂停宿主,成功 `Refresh` 后恢复。
- 包文档在 MySQL 使用 LONGTEXT,在 SQLite/PostgreSQL 使用 TEXT;新写入采用带版本前缀的 ASCII base64 封装,读取后还原原始字节再校验摘要,避免列字符集改变载荷。旧草稿中的原始 JSON 行仍可读取,不依赖 TEXT 到二进制列的隐式迁移;损坏封装拒绝加载。编码不提供加密或来源认证。版本身份使用固定长度摘要索引,备份与恢复应包含两表的一致状态。
- 其他节点的变更通过显式 `Refresh` 读取。本阶段没有后台同步任务,不保证跨节点实时撤销;后续执行器接入时必须定义刷新时机。目录容量沿用 64 MiB 历史包原始字节上限。
- 读取快照遇到并发版本冲突时最多尝试 3 次,首次冲突先暂停旧宿主,只有成功读取并验证完整快照才恢复;持续冲突、取消或其他存储错误仍暂停。不自动重试写入、提交结果未知或任务操作。

64 MiB 容量限制按解码后的原文计算,存储封装有编码体积开销。旧草稿节点不能读取新封装;滚动升级期间应暂停插件安装及新版本写入,所有节点升级完成后再恢复。新编码不能修复已被旧存储破坏的原文,摘要仍会拒绝这些记录;降级应恢复兼容版本与一致备份,不重写摘要来绕过校验。

`OpenCatalog` restores the internal catalog through a GORM store. Startup restores it before serving traffic and stops on corrupt bytes or invalid state. Only the master runs migrations; non-master nodes wait read-only for both catalog tables for at most ten minutes, respecting cancellation. A timeout or failed restoration prevents serving traffic. Batch installation preserves exact package bytes and immutable key/version identities. Disabling blocks new admission but preserves historical task recovery; activation explicitly selects a retained version. Revocation is permanent and invalidates existing sessions on their next use. Historical versions are never automatically deleted or replaced.

A singleton revision CAS serializes metadata transactions across nodes, including concurrent bootstrap. Publication follows commit; uncertain writes or failed refreshes suspend the managed host until a successful refresh. MySQL stores documents as LONGTEXT; SQLite and PostgreSQL use TEXT. Migrations add `task_plugin_catalog_heads` and `task_plugin_versions`; back up and restore both consistently. Other nodes' changes require explicit `Refresh`; background synchronization and immediate multi-node revocation are outside this stage. Retained document bytes remain capped at 64 MiB. The catalog registers no public routes and never evaluates JavaScript.

New documents use a version-prefixed ASCII base64 envelope inside the existing text column. Reads restore the original bytes before digest validation, avoiding character-set-dependent payload changes. Legacy raw JSON rows remain readable without an implicit text-to-binary migration; malformed envelopes fail closed. This encoding provides neither encryption nor source authentication. The 64 MiB retention limit applies to decoded document bytes; stored envelopes add encoding overhead.

Older draft nodes cannot read the new envelope. Pause plugin installation and new-version writes during a rolling upgrade until every node has the new reader. Already damaged legacy documents still fail digest validation; the encoding cannot recover their original bytes. Downgrades require compatible code and a consistent backup, never a rewritten digest that bypasses validation.

Snapshot reads retry concurrent revision conflicts for at most three attempts. The first conflict suspends the old host; only a fully read and validated snapshot restores it. Persistent conflicts, cancellation or other storage errors leave it suspended. Writes, ambiguous commits and task operations are never automatically retried.

## HTTP 能力 / HTTP capability

Expand All @@ -37,6 +61,7 @@ Limits are four calls, 15 seconds per call, a 30-second scope deadline, 1 MiB re
go test ./pkg/taskplugin ./pkg/taskusage -count=1
go test ./service -run TestTaskPluginHost -count=1
go test ./model -run TestTaskPluginHost -count=1
go test ./pkg/taskplugin ./model ./service -run 'TestCatalog|TestTaskPluginCatalog' -count=1
```

这些合成测试不代表真实供应商、JS 隔离、多节点撤销传播或 MySQL/PostgreSQL 实库验收已通过。
Expand Down
8 changes: 8 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,14 @@ func InitResources() error {
}

model.CheckSetup()
if !common.IsMasterNode {
if err := service.WaitForTaskPluginCatalogSchema(context.Background(), model.DB); err != nil {
return fmt.Errorf("wait for task plugin catalog schema: %w", err)
}
}
if err := service.InitializeTaskPluginCatalog(context.Background(), model.DB); err != nil {
return fmt.Errorf("initialize task plugin catalog: %w", err)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Initialize options, should after model.InitDB()
model.InitOptionMap()
Expand Down
2 changes: 2 additions & 0 deletions model/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,8 @@ func migrateDB() error {
&QuotaDataSnapshot{},
&QuotaDataSnapshotRetry{},
&Task{},
&TaskPluginCatalogHead{},
&TaskPluginVersion{},
&Model{},
&Vendor{},
&PrefillGroup{},
Expand Down
278 changes: 278 additions & 0 deletions model/task_plugin_catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/*
Copyright (C) 2023-2026 MAX-API-Next

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/

package model

import (
"bytes"
"context"
"crypto/sha256"
"database/sql/driver"
"encoding/base64"
"encoding/hex"
"errors"
"math"

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/pkg/taskplugin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)

// One revision serializes catalog mutations across nodes, including bootstrap.
// MutationID distinguishes a duplicate INSERT under MySQL CLIENT_FOUND_ROWS.
type TaskPluginCatalogHead struct {
ID uint `gorm:"primaryKey;autoIncrement:false"`
Revision int64 `gorm:"not null"`
MutationID string `gorm:"size:64;not null"`
}

type taskPluginDocument []byte

const taskPluginDocumentPrefix = "maxapi:taskplugin:base64:v1:"

// Value preserves pinned bytes across database character sets without altering
// existing text columns. This is an encoding, not encryption or authentication.
func (document taskPluginDocument) Value() (driver.Value, error) {
return taskPluginDocumentPrefix + base64.StdEncoding.EncodeToString(document), nil
}

// Scan also accepts legacy raw JSON documents. Their digest is still checked
// by the catalog loader; a malformed versioned envelope must not fall back.
func (document *taskPluginDocument) Scan(value any) error {
var raw []byte
switch value := value.(type) {
case string:
raw = []byte(value)
case []byte:
raw = bytes.Clone(value)
default:
return taskplugin.ErrCatalogInvalid
}
if encoded, ok := bytes.CutPrefix(raw, []byte(taskPluginDocumentPrefix)); ok {
if len(encoded) > base64.StdEncoding.EncodedLen(taskplugin.MaxPackageBytes) {
return taskplugin.ErrCatalogInvalid
}
decoded, err := base64.StdEncoding.Strict().DecodeString(string(encoded))
if err != nil {
return taskplugin.ErrCatalogInvalid
}
raw = decoded
}
*document = taskPluginDocument(raw)
return nil
}

func (taskPluginDocument) GormDataType() string { return "string" }
func (taskPluginDocument) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
if db.Dialector.Name() == "mysql" {
return "longtext"
}
return "text"
}

// Identity hashes key/version to keep the primary index within MySQL 5.7's
// older utf8mb4 limits. The pinned document remains the identity authority.
// Documents are immutable, private, and never changed by metadata updates.
type TaskPluginVersion struct {
Identity string `gorm:"size:64;primaryKey" json:"-"`
Digest string `gorm:"size:64;not null" json:"-"`
Document taskPluginDocument `gorm:"not null" json:"-"`
Active bool `gorm:"not null"`
Enabled bool `gorm:"not null"`
Revoked bool `gorm:"not null"`
}

type TaskPluginCatalogStore struct{ db *gorm.DB }

func NewTaskPluginCatalogStore(db *gorm.DB) *TaskPluginCatalogStore {
if db == nil {
return &TaskPluginCatalogStore{}
}
// A driver error can embed SQL arguments containing a package's source.
return &TaskPluginCatalogStore{db: db.Session(&gorm.Session{Logger: db.Logger.LogMode(logger.Silent)})}
}

var _ taskplugin.CatalogStore = (*TaskPluginCatalogStore)(nil)

// SchemaReady only observes the two catalog tables. Replica startup must not
// perform DDL; the master remains responsible for migrations.
func (store *TaskPluginCatalogStore) SchemaReady(ctx context.Context) bool {
if store == nil || store.db == nil || ctx == nil || ctx.Err() != nil {
return false
}
db := store.db.WithContext(ctx)
return db.Migrator().HasTable(&TaskPluginCatalogHead{}) && db.Migrator().HasTable(&TaskPluginVersion{})
}

func loadTaskPluginVersions(tx *gorm.DB) ([]taskplugin.StoredPackage, error) {
var records []TaskPluginVersion
if err := tx.Order("identity").Find(&records).Error; err != nil {
return nil, err
}
result := make([]taskplugin.StoredPackage, 0, len(records))
for _, record := range records {
entry := taskplugin.StoredPackage{Document: []byte(record.Document), Digest: record.Digest, Active: record.Active, Enabled: record.Enabled, Revoked: record.Revoked}
identity, err := taskPluginStorageIdentity(entry)
if err != nil || identity != record.Identity {
return nil, taskplugin.ErrCatalogInvalid
}
result = append(result, entry)
}
return result, nil
}

func taskPluginStorageIdentity(entry taskplugin.StoredPackage) (string, error) {
p, err := taskplugin.LoadPackage(bytes.NewReader(entry.Document), entry.Digest)
if err != nil {
return "", taskplugin.ErrCatalogInvalid
}
manifest := p.Manifest()
digest := sha256.Sum256([]byte(manifest.Key + "@" + manifest.Version))
return hex.EncodeToString(digest[:]), nil
}

func (store *TaskPluginCatalogStore) Load(ctx context.Context) (taskplugin.CatalogSnapshot, error) {
if store == nil || store.db == nil || ctx == nil {
return taskplugin.CatalogSnapshot{}, taskplugin.ErrCatalogUnavailable
}
var snapshot taskplugin.CatalogSnapshot
err := store.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var head TaskPluginCatalogHead
err := tx.First(&head, 1).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
if !missing && (head.Revision <= 0 || head.MutationID == "") {
return taskplugin.ErrCatalogInvalid
}
rows, err := loadTaskPluginVersions(tx)
if err != nil {
return err
}
var after TaskPluginCatalogHead
err = tx.First(&after, 1).Error
// Under READ COMMITTED, reject a mixed snapshot. MySQL's consistent
// snapshot and SQLite's transaction also satisfy this invariant.
if missing {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return taskplugin.ErrCatalogConflict
}
if len(rows) != 0 {
return taskplugin.ErrCatalogInvalid
}
} else if err != nil || head != after {
return taskplugin.ErrCatalogConflict
}
snapshot = taskplugin.CatalogSnapshot{Revision: head.Revision, Packages: rows}
return nil
})
if err != nil {
if errors.Is(err, taskplugin.ErrCatalogInvalid) || errors.Is(err, taskplugin.ErrCatalogConflict) {
return taskplugin.CatalogSnapshot{}, err
}
return taskplugin.CatalogSnapshot{}, taskplugin.ErrCatalogUnavailable
}
return snapshot, nil
}

func (store *TaskPluginCatalogStore) CompareAndSwap(ctx context.Context, revision int64, packages []taskplugin.StoredPackage) (int64, error) {
if store == nil || store.db == nil || ctx == nil {
return 0, taskplugin.ErrCatalogUnavailable
}
if revision < 0 || revision == math.MaxInt64 {
return 0, taskplugin.ErrCatalogInvalid
}
next := taskplugin.CatalogSnapshot{Revision: revision + 1, Packages: packages}
if err := taskplugin.ValidateCatalogTransition(taskplugin.CatalogSnapshot{}, next); err != nil {
return 0, err
}
mutationID := common.GetUUID()
err := store.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if revision == 0 {
head := TaskPluginCatalogHead{ID: 1, Revision: 1, MutationID: mutationID}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&head).Error; err != nil {
return err
}
var claimed TaskPluginCatalogHead
if err := tx.First(&claimed, 1).Error; err != nil {
return err
}
if claimed.MutationID != mutationID || claimed.Revision != 1 {
return taskplugin.ErrCatalogConflict
}
} else {
result := tx.Model(&TaskPluginCatalogHead{}).Where("id = ? AND revision = ?", 1, revision).
Updates(map[string]any{"revision": revision + 1, "mutation_id": mutationID})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return taskplugin.ErrCatalogConflict
}
}
previousRows, err := loadTaskPluginVersions(tx)
if err != nil {
return err
}
if revision == 0 && len(previousRows) != 0 {
return taskplugin.ErrCatalogInvalid
}
previous := taskplugin.CatalogSnapshot{Revision: revision, Packages: previousRows}
if err := taskplugin.ValidateCatalogTransition(previous, next); err != nil {
return err
}
known := make(map[string]bool, len(previousRows))
for _, old := range previousRows {
identity, err := taskPluginStorageIdentity(old)
if err != nil {
return err
}
known[identity] = true
}
for _, entry := range next.Packages {
identity, err := taskPluginStorageIdentity(entry)
if err != nil {
return err
}
if known[identity] {
if err := tx.Model(&TaskPluginVersion{}).Where("identity = ?", identity).
Updates(map[string]any{"active": entry.Active, "enabled": entry.Enabled, "revoked": entry.Revoked}).Error; err != nil {
return err
}
} else {
record := TaskPluginVersion{Identity: identity, Digest: entry.Digest, Document: taskPluginDocument(entry.Document), Active: entry.Active, Enabled: entry.Enabled, Revoked: entry.Revoked}
if err := tx.Create(&record).Error; err != nil {
return err
}
}
}
return nil
})
if err != nil {
if errors.Is(err, taskplugin.ErrCatalogConflict) || errors.Is(err, taskplugin.ErrCatalogInvalid) {
return 0, err
}
return 0, taskplugin.ErrCatalogUnavailable
}
return revision + 1, nil
}
Loading
Loading