Files
patbond-api/patbond-user/src/main/resources/db/migration/V6__creation_baseline.sql
T
lixi 7896a6d20b
CI / backend-test (push) Successful in 11m35s
feat(db): V6 creation schema 地基 + 补回 posts.generation_job_id 外键(T4-01)
creation schema 三表(generation_models / generation_styles /
generation_jobs)逐列取自已评审目标模型 patbond_postgresql.sql
(schema 60/68 + 对象 556-717),含 14 索引与 3 个 updated_at 触发器。

- 零外键需要裁剪:这是第一个不用砍跨 schema 外键的 baseline 迁移,
  identity/media(V1)与 pet_health(V3)目标 schema 均已存在
- 按 V5 文件头指令「M4 补回」补回 community.posts.generation_job_id
  → creation.generation_jobs(id) ON DELETE SET NULL;约束名显式写为
  posts_generation_job_id_fkey,即目标模型内联声明时 PostgreSQL 会
  自动生成的名字,使 V1..V6 建库与照目标模型建库约束名一致
- posts.region_id 保持裸列(区域系统属 M5,ADR-018 裁剪,M5 补回)
- 两条偏索引对齐 worker 语句:ix_generation_jobs_queue 的列序与出队
  ORDER BY 完全一致(SKIP LOCKED 领取走索引扫描不排序);
  ix_generation_jobs_running 驱动租约回收与待轮询扫描

两处刻意偏离目标模型,文件头已逐条记录原因:
1. input_asset_id 改可空(目标模型 NOT NULL)——参考图选填拍板;原
   NOT NULL 会使文生图无法表达。未发明 requires_input 标记(不超出
   已评审模型),「某模型是否必须有输入」由应用层查目录判断
2. 新增 first_attempt_at(目标模型无此列)——iteration-4/06 §4.2 指标
   口径要求。目标模型无法表达:其 ck_generation_jobs_state 强制
   queued 分支 started_at IS NULL,故重排队必然擦除首次尝试时间,
   导致排队时长与纯执行耗时永久无法分离。该列仅首次进入 running 时
   写入、不覆盖,并刻意排除在 queued 分支的置空要求之外;
   ck_generation_jobs_first_attempt 钉住其与 attempt_count 的不变式

测试:
- 新增 CreationMigrationIntegrationTest(12 项)。重点覆盖「日后同步回
  目标模型会静默改回去」的行为:复合外键真实拦截跨 media_kind 引用、
  queued→running→failed→queued 全程后 first_attempt_at 保留而
  started_at 被清空、不变式双向拒绝、provider_request_id 部分唯一索引
  (NULL 可共存、同值冲突)、idempotency_key 的按用户作用域
- CommunityMigrationIntegrationTest 原断言 %generation% 与 %region%
  外键合计为 0,V6 后必然变红(iteration-4/02 已预警)。拆为两条独立
  断言(generation=1、region=0)而非改成合计 1,使任一侧回退可单独定位

全量 381 → 393,零失败零跳过(auth 40 / common 3 / community 107 /
pet 100 / user 131→143)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:35:36 +08:00

264 lines
13 KiB
SQL

/*
V6 baseline: creation schema (M4 AI 创作), extracted from
patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model,
lines 60/68 schema + 556-717 objects) per iteration-4 T4-01.
Scope notes:
- All 3 creation objects are created here: generation_models,
generation_styles, generation_jobs. Both media kinds ('image', 'video')
are structurally supported from the start, as modeled — the target model
keys the catalog by (id, media_kind) composite unique so a job can only
reference a model/style of its own kind. Video feature rollout is a
contract/client concern, not a schema concern (see ADR-018 supersede).
- Zero cross-schema FKs need stripping, unlike V3 and V5: every target
schema already exists — identity (V1), media (V1), pet_health (V3).
This is the first baseline migration that lands its FKs complete.
- V5's header registered two deferred constraints. This migration settles
the first one, as instructed there ("M4 补回"):
* community.posts.generation_job_id -> creation.generation_jobs(id)
ON DELETE SET NULL is re-added at the end of this file. The constraint
is named explicitly as posts_generation_job_id_fkey so that a database
built by V1..V6 is name-identical to one built from the target model
(where the FK is declared inline and auto-named by PostgreSQL).
* community.posts.region_id -> platform.regions(id) stays STRIPPED;
the region system belongs to M5 (ADR-018 scope cut) — M5 补回.
- Primary keys keep DEFAULT gen_random_uuid() as modeled (V1/V3/V5 同规);
application code supplies explicit UUIDv7 ids, the default is a fallback.
- updated_at triggers reuse platform.set_updated_at() (function from V1).
Two deliberate deviations from the target model, both decided in iteration-4
and recorded here so the divergence is never silent:
1. generation_jobs.input_asset_id is NULLABLE (target model: NOT NULL).
Decision A2: the reference image is optional — mainstream image and video
providers support both text-to-* and reference-guided generation, so the
presence of an input asset is what selects between them at request time.
The target model's NOT NULL would have made text-to-image unrepresentable.
Whether a specific model *requires* an input is deliberately NOT encoded
here (no requires_input flag invented beyond the reviewed model); it is
enforced by the application against the catalog.
2. generation_jobs.first_attempt_at is ADDED (absent from the target model).
Required by iteration-4/06 §4.2: queue wait and pure execution time can
only be separated if the first attempt's start survives retries. The
target model cannot express this — its ck_generation_jobs_state forces
started_at IS NULL on the 'queued' branch, so re-queueing a failed job
necessarily erases the original start. first_attempt_at is therefore
written once (on the first transition into 'running') and never
overwritten, and is deliberately EXCLUDED from the 'queued' branch's
null-out requirements. ck_generation_jobs_first_attempt pins the
invariant against attempt_count.
*/
CREATE SCHEMA creation;
COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation';
CREATE TABLE creation.generation_models (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL,
display_name varchar(128) NOT NULL,
provider_code varchar(64) NOT NULL,
provider_model_name varchar(128) NOT NULL,
media_kind varchar(16) NOT NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code, media_kind),
-- (id, media_kind) is redundant given the PK, but is the target of the
-- composite FK from generation_jobs: it is what makes "a job's model must
-- be of the job's own media_kind" enforceable by the database.
UNIQUE (id, media_kind),
CONSTRAINT ck_generation_models_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_models_names CHECK (
code = btrim(code) AND char_length(code) BETWEEN 2 AND 64
AND char_length(btrim(display_name)) BETWEEN 1 AND 128
)
);
CREATE INDEX ix_generation_models_kind_order
ON creation.generation_models (media_kind, sort_order, id)
WHERE enabled;
CREATE TABLE creation.generation_styles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL,
title varchar(64) NOT NULL,
subtitle varchar(128),
media_kind varchar(16) NOT NULL,
preview_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code, media_kind),
UNIQUE (id, media_kind),
CONSTRAINT ck_generation_styles_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_styles_names CHECK (
code = btrim(code) AND char_length(code) BETWEEN 2 AND 64
AND char_length(btrim(title)) BETWEEN 1 AND 64
)
);
CREATE INDEX ix_generation_styles_preview ON creation.generation_styles (preview_asset_id);
CREATE INDEX ix_generation_styles_kind_order
ON creation.generation_styles (media_kind, sort_order, id)
WHERE enabled;
CREATE TABLE creation.generation_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL,
media_kind varchar(16) NOT NULL,
model_id uuid NOT NULL,
style_id uuid,
-- DEVIATION 1 (see header): nullable, not NOT NULL. NULL = text-to-*,
-- present = reference-guided generation.
input_asset_id uuid REFERENCES media.assets(id) ON DELETE RESTRICT,
output_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
prompt varchar(10000),
negative_prompt varchar(3000),
width_px integer,
height_px integer,
duration_ms bigint,
upscale boolean NOT NULL DEFAULT false,
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
-- Snapshots freeze what was actually asked of the provider: the catalog
-- may be re-pointed or disabled later, historical jobs must stay readable.
provider_code_snapshot varchar(64) NOT NULL,
provider_model_snapshot varchar(128) NOT NULL,
model_version_snapshot varchar(64) NOT NULL,
style_code_snapshot varchar(64),
status varchar(16) NOT NULL DEFAULT 'queued',
progress smallint NOT NULL DEFAULT 0,
priority smallint NOT NULL DEFAULT 0,
attempt_count smallint NOT NULL DEFAULT 0,
max_attempts smallint NOT NULL DEFAULT 3,
provider_request_id varchar(128),
error_code varchar(64),
error_message varchar(1000),
idempotency_key varchar(128) NOT NULL,
request_hash bytea NOT NULL,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
lease_owner varchar(128),
lease_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- DEVIATION 2 (see header): written once on the first transition into
-- 'running', never overwritten, survives re-queueing.
first_attempt_at timestamptz,
started_at timestamptz,
completed_at timestamptz,
version integer NOT NULL DEFAULT 0,
UNIQUE (user_id, idempotency_key),
FOREIGN KEY (model_id, media_kind)
REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT,
FOREIGN KEY (style_id, media_kind)
REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT,
CONSTRAINT ck_generation_jobs_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_jobs_dimensions CHECK (
(width_px IS NULL OR width_px BETWEEN 64 AND 8192)
AND (height_px IS NULL OR height_px BETWEEN 64 AND 8192)
AND (duration_ms IS NULL OR duration_ms > 0)
),
CONSTRAINT ck_generation_jobs_parameters CHECK (jsonb_typeof(parameters) = 'object'),
CONSTRAINT ck_generation_jobs_idempotency CHECK (
idempotency_key = btrim(idempotency_key)
AND char_length(idempotency_key) BETWEEN 1 AND 128
AND octet_length(request_hash) = 32
),
CONSTRAINT ck_generation_jobs_status CHECK (
status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')
),
CONSTRAINT ck_generation_jobs_progress CHECK (progress BETWEEN 0 AND 100),
CONSTRAINT ck_generation_jobs_attempts CHECK (
attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts
),
CONSTRAINT ck_generation_jobs_state CHECK (
(
status = 'queued' AND progress = 0 AND started_at IS NULL AND completed_at IS NULL
AND output_asset_id IS NULL AND error_code IS NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL
AND output_asset_id IS NULL AND error_code IS NULL
AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL
)
OR (
status = 'succeeded' AND progress = 100 AND started_at IS NOT NULL
AND completed_at IS NOT NULL AND output_asset_id IS NOT NULL AND error_code IS NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'failed' AND completed_at IS NOT NULL AND error_code IS NOT NULL
AND output_asset_id IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'cancelled' AND completed_at IS NOT NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
),
CONSTRAINT ck_generation_jobs_lease CHECK (
(lease_owner IS NULL) = (lease_expires_at IS NULL)
AND (lease_expires_at IS NULL OR started_at IS NULL OR lease_expires_at > started_at)
),
-- DEVIATION 2's invariant: first_attempt_at exists exactly when the job
-- has been attempted at least once, and never postdates the current
-- attempt's start. Deliberately absent from ck_generation_jobs_state's
-- 'queued' branch: a re-queued job keeps it.
CONSTRAINT ck_generation_jobs_first_attempt CHECK (
(attempt_count = 0) = (first_attempt_at IS NULL)
AND (
first_attempt_at IS NULL OR started_at IS NULL
OR first_attempt_at <= started_at
)
),
CONSTRAINT ck_generation_jobs_version CHECK (version >= 0)
);
-- Provider-side de-duplication: the pair (provider, provider_request_id) is
-- what makes "did I already submit this upstream?" answerable after a timeout
-- or a worker restart — i.e. the basis of not paying twice.
CREATE UNIQUE INDEX uq_generation_jobs_provider_request
ON creation.generation_jobs (provider_code_snapshot, provider_request_id)
WHERE provider_request_id IS NOT NULL;
CREATE INDEX ix_generation_jobs_user_created
ON creation.generation_jobs (user_id, created_at DESC, id DESC);
CREATE INDEX ix_generation_jobs_pet ON creation.generation_jobs (pet_id);
CREATE INDEX ix_generation_jobs_model ON creation.generation_jobs (model_id);
CREATE INDEX ix_generation_jobs_style ON creation.generation_jobs (style_id);
CREATE INDEX ix_generation_jobs_model_kind ON creation.generation_jobs (model_id, media_kind);
CREATE INDEX ix_generation_jobs_style_kind ON creation.generation_jobs (style_id, media_kind);
CREATE INDEX ix_generation_jobs_input ON creation.generation_jobs (input_asset_id);
CREATE INDEX ix_generation_jobs_output ON creation.generation_jobs (output_asset_id);
-- Column order matches the dequeue statement's ORDER BY exactly, so the
-- FOR UPDATE SKIP LOCKED claim is an index scan, not a sort.
CREATE INDEX ix_generation_jobs_queue
ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id)
WHERE status = 'queued';
-- Drives the lease reaper and the poll-due scan over in-flight jobs.
CREATE INDEX ix_generation_jobs_running
ON creation.generation_jobs (lease_expires_at, id)
WHERE status = 'running';
-- Automatic updated_at maintenance (function created in V1). Business version
-- increments remain explicit so optimistic locking stays visible in
-- repository update statements.
CREATE TRIGGER trg_generation_models_updated_at BEFORE UPDATE ON creation.generation_models
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_generation_styles_updated_at BEFORE UPDATE ON creation.generation_styles
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_generation_jobs_updated_at BEFORE UPDATE ON creation.generation_jobs
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
-- V5's deferred constraint, settled per its header instruction ("M4 补回").
-- Named explicitly to match the auto-generated name of the target model's
-- inline declaration, so V1..V6 and the target model stay name-identical.
-- community.posts.region_id stays bare — that one is M5's to settle.
ALTER TABLE community.posts
ADD CONSTRAINT posts_generation_job_id_fkey
FOREIGN KEY (generation_job_id)
REFERENCES creation.generation_jobs(id) ON DELETE SET NULL;