Skip to content
Open
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
7 changes: 5 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ MAX API brings AI model and AI Agent execution into a configurable, observable,

SQLite is used by default, so local evaluation does not require an external database.

> [!WARNING]
> SQLite is intended only for local evaluation, development, and small-scale testing. It is not recommended for production: concurrent requests, multi-instance deployments, large log or usage datasets, database migrations, backup and restore operations, and long transactions can cause lock contention, blocked writes, slow or failed migrations, and other availability or data-maintenance problems. Use MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 with a reliable backup and recovery plan in production.

```bash
# 1. Pull the image
docker pull cscitechtop/max-api:latest
Expand Down Expand Up @@ -487,8 +490,8 @@ A task rate-card can match prices by request parameters:
| Component | Requirement |
|------|------|
| Container engine | Docker / Docker Compose |
| Local database | SQLite; mount `/data` when deploying with Docker |
| Remote database | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 |
| Local database | SQLite, only for local evaluation, development, or small-scale testing; mount `/data` when using Docker |
| Production database | MySQL ≥ 5.7.8 or PostgreSQL ≥ 9.6 with a reliable backup and recovery plan |
| Cache | In-memory cache for single-node deployments; Redis recommended for multi-node deployments |
| Frontend build | Bun workspace; keep `web/package.json` and `web/bun.lock` |
| Source build | Use the Go version declared in `go.mod` (currently Go 1.25.1+) with the repository `go.sum`; after dependency or security updates, run `go mod download`, `go mod verify`, and rebuild |
Expand Down
7 changes: 5 additions & 2 deletions README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ MAX API place l'exécution des modèles IA et des AI Agents dans un cadre config

SQLite est utilisé par défaut ; aucun service de base de données externe n'est requis pour un essai local.

> [!WARNING]
> SQLite est réservé à l'évaluation locale, au développement et aux tests à petite échelle. Son utilisation est déconseillée en production : les requêtes concurrentes, les déploiements multi-instances, les volumes importants de journaux ou de données d'usage, les migrations, les opérations de sauvegarde/restauration et les transactions longues peuvent provoquer des contentions de verrouillage, des écritures bloquées, des migrations lentes ou en échec, ainsi que des problèmes de disponibilité ou de maintenance des données. En production, utilisez MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 avec un plan fiable de sauvegarde et de restauration.

```bash
# 1. Récupérer l'image
docker pull cscitechtop/max-api:latest
Expand Down Expand Up @@ -424,8 +427,8 @@ Les modèles vidéo comme Seedance 2.0 peuvent utiliser la résolution, l'entré
| Composant | Exigence |
|------|------|
| Moteur conteneur | Docker / Docker Compose |
| Base locale | SQLite, monter `/data` avec Docker |
| Base distante | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 |
| Base locale | SQLite, uniquement pour l'évaluation locale, le développement ou les tests à petite échelle ; monter `/data` avec Docker |
| Base de production | MySQL ≥ 5.7.8 ou PostgreSQL ≥ 9.6 avec un plan fiable de sauvegarde et de restauration |
| Cache | Mémoire en mono-nœud, Redis recommandé en multi-nœud |
| Build frontend | Bun workspace, conserver `web/package.json` et `web/bun.lock` |
| Build source | Utiliser la version de Go déclarée dans `go.mod` (actuellement Go 1.25.1+) avec `go.sum` ; après une mise à jour de dépendances ou de sécurité, exécuter `go mod download`, `go mod verify`, puis reconstruire |
Expand Down
7 changes: 5 additions & 2 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ MAX API は、AI モデルと AI Agent の実行プロセスを、設定可能

デフォルトでは SQLite を使用するため、ローカル評価に追加データベースは不要です。

> [!WARNING]
> SQLite はローカル評価、開発、小規模テスト専用です。本番環境での使用は推奨しません。同時リクエスト、複数インスタンス構成、大量のログ・使用量データ、データベース移行、バックアップ/リストア、長時間トランザクションでは、ロック競合、書き込み停止、移行の長時間化や失敗、可用性・データ保守上の問題が発生する可能性があります。本番環境では、信頼できるバックアップ/リカバリ計画とともに MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 を使用してください。

```bash
# 1. イメージを取得
docker pull cscitechtop/max-api:latest
Expand Down Expand Up @@ -424,8 +427,8 @@ Seedance 2.0 などの動画モデルでは、解像度や動画入力などの
| コンポーネント | 要件 |
|------|------|
| コンテナエンジン | Docker / Docker Compose |
| ローカルデータベース | SQLite。Docker デプロイ時は `/data` をマウント |
| リモートデータベース | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6 |
| ローカルデータベース | SQLite。ローカル評価、開発、小規模テスト専用。Docker 利用時は `/data` をマウント |
| 本番データベース | MySQL ≥ 5.7.8 または PostgreSQL ≥ 9.6。信頼できるバックアップ/リカバリ計画を設定 |
| キャッシュ | 単一ノードはメモリキャッシュ、複数ノードは Redis 推奨 |
| フロントエンドビルド | Bun workspace。`web/package.json` と `web/bun.lock` を保持 |
| ソースビルド | `go.mod` に記載された Go バージョン(現在は Go 1.25.1+)とリポジトリ内の `go.sum` を使用。依存関係またはセキュリティ更新後は `go mod download`、`go mod verify` を実行してから再ビルド |
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ MAX API 的设计把 AI 模型和 AI Agent 的运行过程纳入可配置、可

默认使用 SQLite,本地体验无需额外数据库。

> [!WARNING]
> SQLite 仅适合本地体验、开发和小规模测试。正式/生产环境不建议使用 SQLite:在并发请求、多实例部署、大量日志与用量数据、数据库迁移、备份恢复或长事务场景下,可能出现锁等待、写入阻塞、迁移耗时或失败,以及可用性和数据维护问题。正式环境请使用 MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6,并配置可靠的备份与恢复方案。

```bash
# 1. 拉取镜像
docker pull cscitechtop/max-api:latest
Expand Down Expand Up @@ -489,8 +492,8 @@ Seedance 2.0 等视频模型可按分辨率、视频输入等请求参数参与
| 组件 | 要求 |
|------|------|
| 容器引擎 | Docker / Docker Compose |
| 本地数据库 | SQLite,Docker 部署时需挂载 `/data` |
| 远程数据库 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
| 本地数据库 | SQLite,仅用于本地体验、开发或小规模测试;Docker 部署时需挂载 `/data` |
| 正式环境数据库 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6,并配置可靠的备份与恢复方案 |
| 缓存 | 单机可使用内存缓存,多机部署建议使用 Redis |
| 前端构建 | 使用 Bun workspace,需保留 `web/package.json` 与 `web/bun.lock` |
| 源码构建 | 使用仓库 `go.mod` 声明的 Go 版本(当前 Go 1.25.1+)和 `go.sum`;依赖或安全更新后运行 `go mod download`、`go mod verify` 并重新构建 |
Expand Down
7 changes: 5 additions & 2 deletions README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ MAX API 的设计把 AI 模型和 AI Agent 的运行过程纳入可配置、可

默认使用 SQLite,本地体验无需额外数据库。

> [!WARNING]
> SQLite 仅适合本地体验、开发和小规模测试。正式/生产环境不建议使用 SQLite:在并发请求、多实例部署、大量日志与用量数据、数据库迁移、备份恢复或长事务场景下,可能出现锁等待、写入阻塞、迁移耗时或失败,以及可用性和数据维护问题。正式环境请使用 MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6,并配置可靠的备份与恢复方案。

```bash
# 1. 拉取镜像
docker pull cscitechtop/max-api:latest
Expand Down Expand Up @@ -487,8 +490,8 @@ Seedance 2.0 等视频模型可按分辨率、视频输入等请求参数参与
| 组件 | 要求 |
|------|------|
| 容器引擎 | Docker / Docker Compose |
| 本地数据库 | SQLite,Docker 部署时需挂载 `/data` |
| 远程数据库 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
| 本地数据库 | SQLite,仅用于本地体验、开发或小规模测试;Docker 部署时需挂载 `/data` |
| 正式环境数据库 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6,并配置可靠的备份与恢复方案 |
| 缓存 | 单机可使用内存缓存,多机部署建议使用 Redis |
| 前端构建 | 使用 Bun workspace,需保留 `web/package.json` 与 `web/bun.lock` |
| 源码构建 | 使用仓库 `go.mod` 声明的 Go 版本(当前 Go 1.25.1+)和 `go.sum`;依赖或安全更新后运行 `go mod download`、`go mod verify` 并重新构建 |
Expand Down
7 changes: 5 additions & 2 deletions README.zh_TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ MAX API 的設計把 AI 模型和 AI Agent 的運行過程納入可配置、可

默認使用 SQLite,本地體驗無需額外資料庫。

> [!WARNING]
> SQLite 僅適合本地體驗、開發和小規模測試。正式/生產環境不建議使用 SQLite:在併發請求、多實例部署、大量日志與用量資料、資料庫遷移、備份恢復或長事務場景下,可能出現鎖等待、寫入阻塞、遷移耗時或失敗,以及可用性和資料維護問題。正式環境請使用 MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6,並配置可靠的備份與恢復方案。

```bash
# 1. 拉取鏡像
docker pull cscitechtop/max-api:latest
Expand Down Expand Up @@ -487,8 +490,8 @@ Seedance 2.0 等視頻模型可按分辨率、視頻輸入等請求參數參與
| 組件 | 要求 |
|------|------|
| 容器引擎 | Docker / Docker Compose |
| 本地資料庫 | SQLite,Docker 部署時需掛載 `/data` |
| 遠程資料庫 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6 |
| 本地資料庫 | SQLite,僅用於本地體驗、開發或小規模測試;Docker 部署時需掛載 `/data` |
| 正式環境資料庫 | MySQL ≥ 5.7.8 或 PostgreSQL ≥ 9.6,並配置可靠的備份與恢復方案 |
| 緩存 | 單機可使用內存緩存,多機部署建議使用 Redis |
| 前端構建 | 使用 Bun workspace,需保留 `web/package.json` 與 `web/bun.lock` |
| 源碼構建 | 使用倉庫 `go.mod` 聲明的 Go 版本(目前 Go 1.25.1+)和 `go.sum`;依賴或安全更新後運行 `go mod download`、`go mod verify` 並重新構建 |
Expand Down
2 changes: 1 addition & 1 deletion common/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func InitEnv() {

// Initialize rate limit variables
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180)
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 720)
GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))

GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
Expand Down
158 changes: 147 additions & 11 deletions common/redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,44 @@ end
return 0
`)

const redisCacheVersionSequenceKey = "cache-version:sequence"

var redisGetOrInitCacheVersionScript = redis.NewScript(`
local current = redis.call("GET", KEYS[1])
if current then
return current
end
local next = redis.call("INCR", KEYS[2])
redis.call("SET", KEYS[1], next)
return next
`)

var redisInvalidateVersionedHashScript = redis.NewScript(`
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
local sequence = tonumber(redis.call("GET", KEYS[2]) or "0")
if current > sequence then
redis.call("SET", KEYS[2], current)
sequence = current
end
local next = redis.call("INCR", KEYS[2])
redis.call("SET", KEYS[1], next)
redis.call("DEL", KEYS[3])
return next
`)

var redisDeleteVersionedHashScript = redis.NewScript(`
local current = tonumber(redis.call("GET", KEYS[1]) or "0")
local sequence = tonumber(redis.call("GET", KEYS[2]) or "0")
if current > sequence then
redis.call("SET", KEYS[2], current)
end
redis.call("DEL", KEYS[3])
redis.call("DEL", KEYS[1])
return 1
`)

var errRedisCacheVersionChanged = errors.New("redis cache version changed")

func RedisSet(key string, value string, expiration time.Duration) error {
if DebugEnabled {
SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration))
Expand Down Expand Up @@ -130,10 +168,34 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
}
ctx := context.Background()

data := make(map[string]interface{})
data, err := redisHashData(obj)
if err != nil {
return err
}

txn := RDB.TxPipeline()
txn.HSet(ctx, key, data)

// 只有在 expiration 大于 0 时才设置过期时间
if expiration > 0 {
txn.Expire(ctx, key, expiration)
}

_, err = txn.Exec(ctx)
if err != nil {
return fmt.Errorf("failed to execute transaction: %w", err)
}
return nil
}

func redisHashData(obj interface{}) (map[string]interface{}, error) {
value := reflect.ValueOf(obj)
if value.Kind() != reflect.Ptr || value.IsNil() || value.Elem().Kind() != reflect.Struct {
return nil, fmt.Errorf("obj must be a non-nil pointer to a struct, got %T", obj)
}

// 使用反射遍历结构体字段
v := reflect.ValueOf(obj).Elem()
data := make(map[string]interface{})
v := value.Elem()
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
Expand Down Expand Up @@ -162,20 +224,94 @@ func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error {
// 其他类型直接转换为字符串
data[field.Name] = fmt.Sprintf("%v", value.Interface())
}
return data, nil
}

txn := RDB.TxPipeline()
txn.HSet(ctx, key, data)
// RedisGetCacheVersion reads a version used to fence cache-aside refills.
// A missing key is initialized from one shared monotonic sequence so deleting
// an entity's version marker never reuses its prior generation.
func RedisGetCacheVersion(key string) (int64, error) {
result, err := redisGetOrInitCacheVersionScript.Run(
context.Background(), RDB, []string{key, redisCacheVersionSequenceKey},
).Int64()
return result, err
}

// 只有在 expiration 大于 0 时才设置过期时间
if expiration > 0 {
txn.Expire(ctx, key, expiration)
// RedisHSetObjIfVersion stores a DB snapshot only while its cache generation
// is unchanged. WATCH makes the comparison and write safe across processes.
func RedisHSetObjIfVersion(key, versionKey string, expectedVersion int64, obj interface{}, expiration time.Duration) (bool, error) {
data, err := redisHashData(obj)
if err != nil {
return false, err
}
return redisHSetIfVersion(key, versionKey, expectedVersion, data, expiration)
}

// RedisHSetFieldIfVersion is the field-level variant used by narrow cache
// refills that must not overwrite unrelated hash fields.
func RedisHSetFieldIfVersion(key, versionKey string, expectedVersion int64, field string, value interface{}, expiration time.Duration) (bool, error) {
return redisHSetIfVersion(key, versionKey, expectedVersion, map[string]interface{}{field: value}, expiration)
}

func redisHSetIfVersion(key, versionKey string, expectedVersion int64, data map[string]interface{}, expiration time.Duration) (bool, error) {
stored := false
ctx := context.Background()
err := RDB.Watch(ctx, func(tx *redis.Tx) error {
version, err := tx.Get(ctx, versionKey).Int64()
if errors.Is(err, redis.Nil) {
// A deleted version key must not reset the fence to the initial
// generation. All live cache readers obtain a non-negative version;
// treating a missing key as -1 rejects stale in-flight refills.
version, err = -1, nil
}
if err != nil {
return err
}
if version != expectedVersion {
return errRedisCacheVersionChanged
}

_, err := txn.Exec(ctx)
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.HSet(ctx, key, data)
if expiration > 0 {
pipe.Expire(ctx, key, expiration)
}
return nil
})
if err == nil {
stored = true
}
return err
}, versionKey)
if errors.Is(err, errRedisCacheVersionChanged) || errors.Is(err, redis.TxFailedErr) {
return false, nil
}
if err != nil {
return fmt.Errorf("failed to execute transaction: %w", err)
return false, err
}
return nil
return stored, nil
}

// RedisInvalidateVersionedHash advances the shared generation and removes the
// cached hash atomically, preventing older DB snapshots from refilling it.
func RedisInvalidateVersionedHash(key, versionKey string) error {
_, err := redisInvalidateVersionedHashScript.Run(
context.Background(), RDB,
[]string{versionKey, redisCacheVersionSequenceKey, key},
).Result()
return err
}

// RedisDeleteVersionedHash removes an entity cache and its generation marker.
// This is reserved for permanent entity deletion. Cache refills treat a missing
// generation as a fence miss, so deleting the marker cannot re-admit a snapshot
// captured before the entity was removed.
func RedisDeleteVersionedHash(key, versionKey string) error {
_, err := redisDeleteVersionedHashScript.Run(
context.Background(), RDB,
[]string{versionKey, redisCacheVersionSequenceKey, key},
).Result()
return err
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func RedisHGetObj(key string, obj interface{}) error {
Expand Down
49 changes: 49 additions & 0 deletions common/redis_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package common

import (
"testing"
"time"

"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/require"
)

func TestVersionedHashDeletionRejectsStaleRefillsAndReleasesEntityKeys(t *testing.T) {
server := miniredis.RunT(t)
oldRDB := RDB
RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() {
require.NoError(t, RDB.Close())
RDB = oldRDB
})

cacheKey := "cache:test-entity"
versionKey := "cache-version:test-entity"
version, err := RedisGetCacheVersion(versionKey)
require.NoError(t, err)
require.Positive(t, version)

stored, err := RedisHSetFieldIfVersion(cacheKey, versionKey, version, "Quota", 10, time.Minute)
require.NoError(t, err)
require.True(t, stored)

require.NoError(t, RedisInvalidateVersionedHash(cacheKey, versionKey))
newVersion, err := RedisGetCacheVersion(versionKey)
require.NoError(t, err)
require.Greater(t, newVersion, version)
stored, err = RedisHSetFieldIfVersion(cacheKey, versionKey, version, "Quota", 20, time.Minute)
require.NoError(t, err)
require.False(t, stored)

require.NoError(t, RedisDeleteVersionedHash(cacheKey, versionKey))
require.False(t, server.Exists(cacheKey))
require.False(t, server.Exists(versionKey))
stored, err = RedisHSetFieldIfVersion(cacheKey, versionKey, newVersion, "Quota", 30, time.Minute)
require.NoError(t, err)
require.False(t, stored)

recreatedVersion, err := RedisGetCacheVersion(versionKey)
require.NoError(t, err)
require.Greater(t, recreatedVersion, newVersion)
}
Loading
Loading