Skip to content

Legacy Asset Directory

ddddd edited this page Aug 6, 2026 · 1 revision

Legacy Asset Directory / 遗留资产目录

SPA 容器化部署的经典问题:容器更新后,旧版本客户端仍然缓存的 hash chunk(chunk.abc123.js)在新容器中已不存在,导致 dynamic import 404 报错。

This page describes the multi-source legacy directory design that allows SPACK to serve assets from previous deployments, eliminating chunk-404 errors after container redeployment.

问题 / Problem

sequenceDiagram
    participant Client as 浏览器 / Browser
    participant CDN
    participant Container as 容器 / Container

    Note over Client,Container: 部署 v1 / Deploy v1
    Client->>Container: GET /index.html → 200
    Client->>Container: GET /chunk.abc123.js → 200 (immutable cache)

    Note over Client,Container: 部署 v2 / Deploy v2
    Note over Container: v1 文件被替换 / v1 files replaced<br/>chunk.abc123.js 已删除 / deleted

    Client->>Container: lazy-load chunk.abc123.js → 404 💥
    Note over Client: ChunkLoadError / 白屏 / white screen
Loading

设计目标 / Design Goals

  1. 零感知恢复:旧客户端请求旧 chunk 时,正常返回内容,而不是报错或刷新
  2. 运维简单:部署流程只需在更新 dist 前把旧文件拷贝到 legacy 目录
  3. 自动清理:按版本数或 TTL 自动淘汰过期 legacy 资产
  4. 资源开销可控:legacy 目录大小有上限,不影响主数据平面性能

Design goals:

  1. Zero-impact recovery: Old clients requesting stale chunks receive the actual content, not an error or reload
  2. Simple operations: Deployment only needs to copy old files to a legacy directory before updating
  3. Automatic cleanup: Evict expired legacy assets by version count or TTL
  4. Bounded resource cost: Legacy directory size is capped, with no impact on primary data-plane performance

架构 / Architecture

flowchart TB
    subgraph Deploy["部署流程 / Deployment"]
        Script["部署脚本 / Deploy script"]
        Script -->|cp -r| LegacyFS["/app/legacy/&lt;timestamp&gt;"]
        Script -->|更新 / update| PrimaryFS["/app/dist"]
    end

    subgraph Startup["SPACK 启动 / Startup"]
        PrimaryFS -->|scan| PrimaryCatalog["Primary Catalog"]
        LegacyFS -->|scan, skip existing in primary| LegacyCatalog["Legacy Catalog"]
    end

    subgraph Runtime["请求处理 / Request Handling"]
        Request["HTTP Request"]
        Request --> Resolve{"resolver"}
        Resolve -->|found| PrimaryCatalog
        PrimaryCatalog -->|"Cache: immutable + long max-age"| Response["200 + body"]
        Resolve -->|miss| LegacyCatalog
        LegacyCatalog -->|found| LegacyResponse["200 + body<br/>Cache: max-age=3600, no immutable"]
        LegacyCatalog -->|miss| NotFound["stale-asset recovery<br/>or 404"]
    end

    subgraph Janitor["清理 / Janitor"]
        Scheduler["scheduler (per 24h)"]
        Scheduler -->|remove expired| LegacyFS
        Scheduler -->|trim to max_versions| LegacyFS
        Scheduler -->|update| LegacyCatalog
    end
Loading

目录结构 / Directory Layout

/app/
├── dist/                    # 当前版本 (primary)
│   ├── index.html
│   ├── assets/
│   │   ├── chunk.xyz789.js  # v2 的新 chunk
│   │   └── main.xyz789.js
│   └── ...
│
└── legacy/                  # 持久化卷 / persistent volume
    ├── 20250701-120000/     # 部署批次 1 / deployment batch 1
    │   ├── index.html
    │   ├── assets/
    │   │   ├── chunk.abc123.js  # ← 旧客户端请求的旧 chunk
    │   │   └── main.abc123.js
    │   └── ...
    │
    └── 20250702-090000/     # 部署批次 2 / deployment batch 2
        └── ...

配置 / Configuration

# spack.yaml (新增 / new section)
assets:
  root: /app/dist                    # 当前版本资产 / current version
  legacy:
    enable: true                     # 启用 legacy 目录 / enable legacy scan
    dir: /app/legacy                 # legacy 根目录 / legacy root directory
    max_age: "168h"                  # 单个批次的 TTL (7 天) / per-batch TTL
    max_versions: 3                   # 最多保留批次 / max deployment batches
    scan_on_start: true              # 启动时扫描 / scan at startup

配置结构体设计(Go side):

type LegacyAssets struct {
    Enable       bool   `koanf:"enable"         validate:"required"`
    Dir          string `koanf:"dir"            validate:"required,dirpath"`
    MaxAge       string `koanf:"max_age"        validate:"required,spack_flexible_duration"`
    MaxVersions  int    `koanf:"max_versions"   validate:"gte=1,lte=32"`
    ScanOnStart  bool   `koanf:"scan_on_start"`
}

查找链 / Resolution Chain

resolver.Resolve(request)
  │
  ├─ 1. Primary Catalog lookup
  │     found → serve (immutable cache policy for fingerprinted paths)
  │     miss  → fall through
  │
  ├─ 2. Legacy Catalog lookup
  │     found → serve (short cache TTL, no immutable)
  │     miss  → fall through
  │
  └─ 3. Stale-asset recovery (fingerprinted .js)
        └─ 或 / or 404

关键规则:

  • Primary 优先:同名路径优先从 primary 返回,legacy 只做 fallback
  • Hash 天然不冲突:chunk.abc123.js 和 chunk.xyz789.js 是不同的文件名,不存在覆盖
  • Legacy 不生成变体:legacy 资产不进入 pipeline,不生成压缩/图片变体
  • Legacy 缓存策略:短 TTL (max-age=3600),不带 immutable 标记
  • 只读:legacy 目录在运行时只读,不写入新文件

Key rules:

  • Primary wins: Same-path assets always resolve from primary; legacy is fallback only
  • No hash collision: Fingerprinted file names are naturally disjoint — no overwrites
  • No variant generation: Legacy assets skip the pipeline entirely; no compression/image variants generated
  • Legacy cache policy: Short TTL (max-age=3600), no immutable directive
  • Read-only: Legacy directory is read-only at runtime; no new files written

部署流程 / Deployment Flow

Kubernetes / Docker Compose

# 持久化 legacy 目录 / persist legacy directory
volumes:
  spack-legacy:
    driver: local

services:
  spack:
    image: ghcr.io/lyonbrown4d/spack:latest
    volumes:
      - ./dist:/app/dist           # 当前版本 / current version
      - spack-legacy:/app/legacy   # 持久化跨容器 / persistent across containers
    environment:
      SPACK_ASSETS_ROOT: /app/dist
      # SPACK_ASSETS_LEGACY_DIR: /app/legacy

部署脚本 / Deploy Script

#!/bin/sh
# Pre-deploy: backup current dist to legacy

LEGACY_ROOT="/app/legacy"
DIST="/app/dist"
TIMESTAMP=$(date -u +%Y%m%d-%H%M%S)

# 1. copy current dist into legacy
if [ -d "$DIST" ] && [ "$(ls -A "$DIST")" ]; then
  mkdir -p "$LEGACY_ROOT"
  cp -r "$DIST" "$LEGACY_ROOT/$TIMESTAMP"
  echo "legacy: saved $TIMESTAMP"
fi

# 2. deploy new dist (e.g. unzip artifact, or container restart with new volume)
# ... deployment tooling here ...

# 3. start SPACK — it scans /app/dist (primary) + /app/legacy/* (legacy batches)

清理策略 / Eviction Policy

flowchart LR
    Timer["Timer (per 24h)"] --> Age{"max_age expired?"}
    Age -->|yes| Remove["remove batch directory"]
    Age -->|no| Count{"batches > max_versions?"}
    Count -->|yes| Trim["remove oldest batches"]
    Count -->|no| Skip["keep all"]
    Remove --> Rebuild["trigger catalog update"]
    Trim --> Rebuild
Loading
  • 基于目录名:按目录名字典序(即时间戳)确定新旧,最旧的先删除
  • 粒度是批次:整个 batch 目录一起删除,不逐文件清理
  • 先删后重建:删除后触发 catalog update,legacy catalog 重建

Eviction details:

  • Directory-name-based: Batch age is determined by directory name lexicographic order (i.e. timestamp); oldest removed first
  • Batch granularity: Entire batch directory is removed at once, not file-by-file
  • Rebuild after delete: Catalog update triggered after removal; legacy catalog rebuilt

改动模块 / Impacted Modules

模块 / Module 改动 / Change 复杂度 / Effort
config.Assets 新增 LegacyAssets 结构体 小 / Small
source 新增 MultiSourceFactory,封装 primary + legacy 扫描 中 / Medium
sourcecatalog 两遍扫描:primary → legacy(跳过已注册路径) 中 / Medium
catalog 可选:asset 标记 Source 字段;或两个 catalog 实例 中-大 / Medium-Large
resolver 查找链:primary → legacy → stale-recovery → 404 小 / Small
cachepolicy legacy 资产专用缓存策略(短 TTL,no immutable) 小 / Small
pipeline legacy 资产跳过 pipeline 小 / Small
task 新增 legacy janitor 定时任务 小 / Small
metrics 新增 legacy hit/miss/size 指标 小 / Small

与其他方案的关系 / Relationship with Other Solutions

                    ┌──────────────────────────┐
                    │   Stale-Asset Recovery    │
                    │   (reload script, 已实现)  │
                    │   Ultimate fallback       │
                    └──────────┬───────────────┘
                               │ 终极兜底
                    ┌──────────▼───────────────┐
                    │   Legacy Asset Directory  │
                    │   (本方案 / this design)   │
                    │   正常情况下的主食          │
                    │   Primary path            │
                    └──────────────────────────┘
  • Legacy Directory 是正常路径:旧 chunk 直接命中,无感知
  • Stale-Asset Recovery(已实现) 是终极兜底:legacy 清理后或未配置 legacy 时,返回 reload 脚本避免僵死

两层互补,不互相替代。

Relationship:

  • Legacy Directory is the primary path: stale chunks resolve transparently from legacy
  • Stale-Asset Recovery (implemented) is the ultimate fallback: after legacy eviction or when legacy is not configured, the reload script prevents the client from being stuck in a broken state

The two layers complement each other; neither replaces the other.

设计取舍 / Tradeoffs

决策 / Decision 原因 / Reason
目录时间戳作为版本标识 不依赖外部元数据,部署脚本只需 cp -r
legacy 不进入 pipeline 旧文件不需要额外处理变体,源文件本身已满足需求
legacy 缓存策略更保守 旧版本内容生命周期短,不应被 CDN/浏览器长期缓存
批次粒度清理 时间戳目录天然按批次组织,逐个文件清理复杂且无必要
持久化卷而非容器内路径 容器重建后 legacy 内容需要存活,必须挂载外部卷
与 stale-asset recovery 共存 legacy 是正常路径,recovery 是兜底,分层防御

Key tradeoffs:

Decision Reason
Directory timestamp as version identifier No external metadata needed; deploy script only needs cp -r
Legacy skips pipeline Old files don't need variant processing; source files suffice
Conservative legacy cache policy Legacy content is short-lived; should not be cached long-term by CDN/browser
Batch-granularity eviction Timestamp directories naturally organize by batch; per-file eviction is unnecessary complexity
Persistent volume, not container path Legacy content must survive container rebuilds; external volume required
Coexists with stale-asset recovery Legacy is primary path, recovery is fallback; layered defense

Clone this wiki locally