feat(db): V6 creation schema 地基 + 补回 posts.generation_job_id 外键(T4-01)
CI / backend-test (push) Successful in 11m35s
CI / backend-test (push) Successful in 11m35s
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>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
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;
|
||||
Reference in New Issue
Block a user