feat(publish): P0-1 발행 레이어 스키마+projection+워커 (study→viewer)
docsrv-viewer-publish 발행 인프라 — 뷰어가 read API로 당길 published projection + transactional outbox + 단일 라이터 발행 워커. study_publish_enabled=false 기본 (저자/4-A enqueue 결선 P0-1b 전까지 inert). read-only 경로·additive·소프트락 무관. - migrations 365~370: published(kind·pub_id opaque+stable·rev·payload_hash·deleted·schema_version) + UNIQUE(kind,pub_id)/(kind,source_id) + rev idx + publish_outbox + 미처리 부분 idx - models/published.py: Published·PublishOutbox (관계 없음=mapper 안전) - services/study/publish_projection.py: project_question/explanation + payload_hash(정렬 sha256) - services/study/publish_enqueue.py: enqueue_publish/question + backfill(bounded page) - workers/study_publish_worker.py: outbox drain → pg_advisory_xact_lock 단일라이터 rev 부여 + (payload_hash,deleted) 디둡 + 배치내 중복 flush - config: study_publish_enabled(기본 false) · main: publish_outbox_consumer 1m max_instances=1 plan: plans/2026-06-23-study-to-viewer-slice1-plan.html (P0-1, 3R 적대리뷰 통과) 검증: py_compile·payload_hash 단위·마이그 1문/파일·매퍼 standalone. 전체 매퍼/마이그 apply=배포 게이트. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
-- 365_published.sql
|
||||
-- 발행 레이어(docsrv-viewer-publish) projection 테이블. 뷰어가 read API로 당겨 자기 SQLite로 복제.
|
||||
-- kind-discriminated 단일 테이블(study_question | study_explanation | ... 후속 news/document).
|
||||
-- pub_id = opaque+stable(워커가 (kind,source_id)당 1회 부여, republish=rev bump에도 불변) = 뷰어 dedup키=progress키.
|
||||
-- source_id = 내부 소스 행 id (pub_id→내부 역매핑, ingest write-back 해소용).
|
||||
-- rev = 발행 워커 커밋순 gapless 커서(pg_advisory_lock 단일 라이터). 뷰어 feed = WHERE rev>since.
|
||||
-- payload_hash = sha256(정렬 JSON). (payload_hash, deleted) 디둡 — no-op 재투영 억제, tombstone 보존.
|
||||
-- deleted = tombstone(삭제/만료도 feed 1급 이벤트). schema_version = 엔벨로프 버전(미지원 가시거부).
|
||||
CREATE TABLE IF NOT EXISTS published (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
kind VARCHAR(40) NOT NULL,
|
||||
source_id BIGINT NOT NULL,
|
||||
pub_id TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
schema_version SMALLINT NOT NULL DEFAULT 1,
|
||||
rev BIGINT NOT NULL,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 366_published_kind_pubid_uq.sql
|
||||
-- pub_id 는 kind 내 유일(뷰어 dedup/progress 키 무결성, pub_id→내부 역해소 유일성 보장).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS published_kind_pubid_uq ON published (kind, pub_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 367_published_kind_source_uq.sql
|
||||
-- (kind, source_id) 당 발행 행 1개 — 발행 워커 upsert 타깃 + pub_id 재사용(같은 source=같은 pub_id) 키.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS published_kind_source_uq ON published (kind, source_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 368_published_rev_idx.sql
|
||||
-- 뷰어 pull-sync feed: SELECT ... WHERE rev > :since ORDER BY rev LIMIT :page (P0-2).
|
||||
CREATE INDEX IF NOT EXISTS published_rev_idx ON published (rev);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 369_publish_outbox.sql
|
||||
-- transactional outbox — 저작/4-A 트랜잭션이 같은 tx에서 여기 INSERT(P0-1 규율),
|
||||
-- 단일 발행 워커가 id(커밋순) 순으로 drain 하며 published 에 rev 부여(소스 updated_at 폴링 금지=갭 재발).
|
||||
-- processed_at = 워커 drain 시 스탬프(NULL=미처리). payload/hash 는 enqueue 시점 스냅샷.
|
||||
CREATE TABLE IF NOT EXISTS publish_outbox (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
kind VARCHAR(40) NOT NULL,
|
||||
source_id BIGINT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
schema_version SMALLINT NOT NULL DEFAULT 1,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 370_publish_outbox_unprocessed_idx.sql
|
||||
-- 워커 drain 쿼리: WHERE processed_at IS NULL ORDER BY id (커밋순). 부분 인덱스로 미처리분만.
|
||||
CREATE INDEX IF NOT EXISTS publish_outbox_unprocessed_idx ON publish_outbox (id) WHERE processed_at IS NULL;
|
||||
Reference in New Issue
Block a user