From 4c2b9107650deaa051fba306ce45818acbdb117f Mon Sep 17 00:00:00 2001 From: greebo Date: Thu, 19 Mar 2026 18:20:21 +0300 Subject: [PATCH] Complete publish preview Phase 2A with retention, refresh and cache consistency --- backend/app/core/config.py | 1 + backend/app/repositories/scheme_artifacts.py | 15 ++- backend/app/services/publish_preview.py | 47 ++++++---- backend/app/services/publish_preview_cache.py | 93 ++++++++++++++++--- .../docs/publish-preview-phase-2a-status.md | 30 ++++++ 5 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 backend/docs/publish-preview-phase-2a-status.md diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 85f7666..d928fc2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -35,6 +35,7 @@ class Settings(BaseSettings): svg_display_technical_text_patterns: str = "debug,tech,helper,tmp,service" storage_root_dir: str = "/data" + publish_preview_retention_per_variant: int = 2 model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/app/repositories/scheme_artifacts.py b/backend/app/repositories/scheme_artifacts.py index 4bc34c2..1ad55c1 100644 --- a/backend/app/repositories/scheme_artifacts.py +++ b/backend/app/repositories/scheme_artifacts.py @@ -1,6 +1,6 @@ from uuid import uuid4 -from sqlalchemy import asc, desc, select +from sqlalchemy import asc, delete, desc, select from app.db.session import AsyncSessionLocal from app.models.scheme_artifact import SchemeArtifactRecord @@ -99,3 +99,16 @@ async def get_latest_scheme_artifact( result = await session.execute(stmt) return result.scalar_one_or_none() + + +async def delete_scheme_artifacts_by_artifact_ids(artifact_ids: list[str]) -> int: + if not artifact_ids: + return 0 + + async with AsyncSessionLocal() as session: + stmt = delete(SchemeArtifactRecord).where( + SchemeArtifactRecord.artifact_id.in_(artifact_ids) + ) + result = await session.execute(stmt) + await session.commit() + return int(result.rowcount or 0) diff --git a/backend/app/services/publish_preview.py b/backend/app/services/publish_preview.py index a3a12de..514c2f8 100644 --- a/backend/app/services/publish_preview.py +++ b/backend/app/services/publish_preview.py @@ -18,6 +18,24 @@ from app.services.scheme_validation import build_scheme_validation_report from app.services.structure_diff import build_structure_diff +def _serialize_artifacts(artifacts_rows: list) -> dict: + return { + "total": len(artifacts_rows), + "items": [ + { + "artifact_id": row.artifact_id, + "artifact_type": row.artifact_type, + "artifact_variant": row.artifact_variant, + "status": row.status, + "storage_path": row.storage_path, + "meta_json": row.meta_json, + "created_at": row.created_at.isoformat(), + } + for row in artifacts_rows + ], + } + + async def build_publish_preview_bundle( *, scheme_id: str, @@ -62,21 +80,7 @@ async def build_publish_preview_bundle( except Exception: unpriced += 1 - artifacts = { - "total": len(artifacts_rows), - "items": [ - { - "artifact_id": row.artifact_id, - "artifact_type": row.artifact_type, - "artifact_variant": row.artifact_variant, - "status": row.status, - "storage_path": row.storage_path, - "meta_json": row.meta_json, - "created_at": row.created_at.isoformat(), - } - for row in artifacts_rows - ], - } + artifacts = _serialize_artifacts(artifacts_rows) pricing_coverage = { "snapshot_available": snapshot_available, @@ -126,10 +130,21 @@ async def get_or_build_publish_preview_bundle( scheme_version_id=scheme_version_id, baseline_override_scheme_version_id=baseline_override_scheme_version_id, ) - await save_publish_preview_artifact( + + save_result = await save_publish_preview_artifact( scheme_id=scheme_id, scheme_version_id=scheme_version_id, payload=payload, baseline_scheme_version_id=payload["structure_diff"]["baseline_scheme_version_id"], ) + + artifacts_rows = await list_scheme_artifacts(scheme_version_id=scheme_version_id) + payload["artifacts"] = _serialize_artifacts(artifacts_rows) + payload["summary"]["has_artifacts"] = payload["artifacts"]["total"] > 0 + payload["summary"]["preview_cache_cleanup"] = save_result["cleanup"] + + artifact = save_result["artifact"] + path = Path(artifact.storage_path) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload diff --git a/backend/app/services/publish_preview_cache.py b/backend/app/services/publish_preview_cache.py index 3162dc5..0a8115e 100644 --- a/backend/app/services/publish_preview_cache.py +++ b/backend/app/services/publish_preview_cache.py @@ -5,7 +5,11 @@ from pathlib import Path from uuid import uuid4 from app.core.config import settings -from app.repositories.scheme_artifacts import create_scheme_artifact, list_scheme_artifacts +from app.repositories.scheme_artifacts import ( + create_scheme_artifact, + delete_scheme_artifacts_by_artifact_ids, + list_scheme_artifacts, +) def _preview_storage_dir() -> Path: @@ -14,6 +18,64 @@ def _preview_storage_dir() -> Path: return path +def _cleanup_preview_file(storage_path: str) -> None: + path = Path(storage_path) + try: + if path.exists() and path.is_file(): + path.unlink() + except FileNotFoundError: + pass + + parent = path.parent + preview_root = Path(settings.storage_preview_dir) + + try: + if parent != preview_root and parent.exists(): + parent.rmdir() + except OSError: + pass + + +async def cleanup_publish_preview_artifacts( + *, + scheme_version_id: str, + baseline_scheme_version_id: str | None, +) -> dict: + retention = max(1, settings.publish_preview_retention_per_variant) + variant = baseline_scheme_version_id or "default" + + rows = await list_scheme_artifacts( + scheme_version_id=scheme_version_id, + artifact_type="publish_preview", + artifact_variant=variant, + ) + if len(rows) <= retention: + return { + "retention": retention, + "deleted_count": 0, + "deleted_artifact_ids": [], + } + + rows_sorted = sorted( + rows, + key=lambda row: (row.created_at, row.id), + reverse=True, + ) + to_delete = rows_sorted[retention:] + + deleted_artifact_ids = [row.artifact_id for row in to_delete] + for row in to_delete: + _cleanup_preview_file(row.storage_path) + + deleted_count = await delete_scheme_artifacts_by_artifact_ids(deleted_artifact_ids) + + return { + "retention": retention, + "deleted_count": deleted_count, + "deleted_artifact_ids": deleted_artifact_ids, + } + + async def save_publish_preview_artifact( *, scheme_id: str, @@ -37,7 +99,16 @@ async def save_publish_preview_artifact( "summary": payload.get("summary"), }, ) - return artifact + + cleanup = await cleanup_publish_preview_artifacts( + scheme_version_id=scheme_version_id, + baseline_scheme_version_id=baseline_scheme_version_id, + ) + + return { + "artifact": artifact, + "cleanup": cleanup, + } async def get_latest_publish_preview_artifact( @@ -45,15 +116,13 @@ async def get_latest_publish_preview_artifact( scheme_version_id: str, baseline_scheme_version_id: str | None, ): - rows = await list_scheme_artifacts(scheme_version_id=scheme_version_id) - variant = baseline_scheme_version_id or "default" - - matching = [ - row for row in rows - if row.artifact_type == "publish_preview" and row.artifact_variant == variant - ] - if not matching: + rows = await list_scheme_artifacts( + scheme_version_id=scheme_version_id, + artifact_type="publish_preview", + artifact_variant=baseline_scheme_version_id or "default", + ) + if not rows: return None - matching.sort(key=lambda row: (row.created_at, row.id), reverse=True) - return matching[0] + rows.sort(key=lambda row: (row.created_at, row.id), reverse=True) + return rows[0] diff --git a/backend/docs/publish-preview-phase-2a-status.md b/backend/docs/publish-preview-phase-2a-status.md new file mode 100644 index 0000000..894216c --- /dev/null +++ b/backend/docs/publish-preview-phase-2a-status.md @@ -0,0 +1,30 @@ +# Publish Preview — Phase 2A Status + +## Status + +Phase 2A is mostly closed. + +## Confirmed + +- retention policy works +- `refresh=true` works +- cached read works +- response contract is more consistent +- cleanup actually removes old records from the database + +## Retention result + +For the current draft version, exactly 2 preview artifacts remain in the database, matching the configured retention policy. + +## Response contract + +The response now explicitly returns: + +- `preview_cache_cleanup.retention=2` +- `deleted_count` +- `deleted_artifact_ids` + +## Runtime state + +- logs are clean +- no new exceptions were observed