Implement atomic publishing with IPFS and DB transactions
All publishing operations now use three-phase atomic approach: 1. Phase 1: Preparation - validate inputs, gather IPFS CIDs 2. Phase 2: IPFS operations - pin all content before any DB changes 3. Phase 3: DB transaction - all-or-nothing database commits Changes: ipfs_client.py: - Add IPFSError exception class - Add add_bytes() to store content on IPFS - Add add_json() to store JSON documents on IPFS - Add pin_or_raise() for synchronous pinning with error handling db.py: - Add transaction() context manager for atomic DB operations - Add create_asset_tx() for transactional asset creation - Add create_activity_tx() for transactional activity creation - Add get_asset_by_hash_tx() for lookup within transactions - Add asset_exists_by_name_tx() for existence check within transactions server.py: - Rewrite record_run: - Check L2 first for inputs, fall back to L1 - Store recipe JSON on IPFS with CID in provenance - Auto-register input assets if not already on L2 - All operations atomic - Rewrite publish_cache: - IPFS CID now required - Synchronous pinning before DB commit - Transaction for asset + activity - Rewrite _register_asset_impl: - IPFS CID now required - Synchronous pinning before DB commit - Transaction for asset + activity Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
81
db.py
81
db.py
@@ -134,6 +134,23 @@ async def get_connection():
|
||||
yield conn
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction():
|
||||
"""
|
||||
Get a connection with an active transaction.
|
||||
|
||||
Usage:
|
||||
async with db.transaction() as conn:
|
||||
await create_asset_tx(conn, asset1)
|
||||
await create_asset_tx(conn, asset2)
|
||||
await create_activity_tx(conn, activity)
|
||||
# Commits on exit, rolls back on exception
|
||||
"""
|
||||
async with get_pool().acquire() as conn:
|
||||
async with conn.transaction():
|
||||
yield conn
|
||||
|
||||
|
||||
# ============ Users ============
|
||||
|
||||
async def get_user(username: str) -> Optional[dict]:
|
||||
@@ -329,6 +346,52 @@ def _parse_asset_row(row) -> dict:
|
||||
return asset
|
||||
|
||||
|
||||
# ============ Assets (Transaction variants) ============
|
||||
|
||||
async def get_asset_by_hash_tx(conn, content_hash: str) -> Optional[dict]:
|
||||
"""Get asset by content hash within a transaction."""
|
||||
row = await conn.fetchrow(
|
||||
"""SELECT name, content_hash, ipfs_cid, asset_type, tags, metadata, url,
|
||||
provenance, description, origin, owner, created_at, updated_at
|
||||
FROM assets WHERE content_hash = $1""",
|
||||
content_hash
|
||||
)
|
||||
if row:
|
||||
return _parse_asset_row(row)
|
||||
return None
|
||||
|
||||
|
||||
async def asset_exists_by_name_tx(conn, name: str) -> bool:
|
||||
"""Check if asset name exists within a transaction."""
|
||||
return await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM assets WHERE name = $1)",
|
||||
name
|
||||
)
|
||||
|
||||
|
||||
async def create_asset_tx(conn, asset: dict) -> dict:
|
||||
"""Create a new asset within a transaction."""
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO assets (name, content_hash, ipfs_cid, asset_type, tags, metadata,
|
||||
url, provenance, description, origin, owner, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *""",
|
||||
asset["name"],
|
||||
asset["content_hash"],
|
||||
asset.get("ipfs_cid"),
|
||||
asset["asset_type"],
|
||||
json.dumps(asset.get("tags", [])),
|
||||
json.dumps(asset.get("metadata", {})),
|
||||
asset.get("url"),
|
||||
json.dumps(asset.get("provenance")) if asset.get("provenance") else None,
|
||||
asset.get("description"),
|
||||
json.dumps(asset.get("origin")) if asset.get("origin") else None,
|
||||
asset["owner"],
|
||||
_parse_timestamp(asset.get("created_at"))
|
||||
)
|
||||
return _parse_asset_row(row)
|
||||
|
||||
|
||||
# ============ Activities ============
|
||||
|
||||
async def get_activity(activity_id: str) -> Optional[dict]:
|
||||
@@ -432,6 +495,24 @@ def _parse_activity_row(row) -> dict:
|
||||
return activity
|
||||
|
||||
|
||||
# ============ Activities (Transaction variants) ============
|
||||
|
||||
async def create_activity_tx(conn, activity: dict) -> dict:
|
||||
"""Create a new activity within a transaction."""
|
||||
row = await conn.fetchrow(
|
||||
"""INSERT INTO activities (activity_id, activity_type, actor_id, object_data, published, signature)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *""",
|
||||
UUID(activity["activity_id"]),
|
||||
activity["activity_type"],
|
||||
activity["actor_id"],
|
||||
json.dumps(activity["object_data"]),
|
||||
_parse_timestamp(activity["published"]),
|
||||
json.dumps(activity.get("signature")) if activity.get("signature") else None
|
||||
)
|
||||
return _parse_activity_row(row)
|
||||
|
||||
|
||||
# ============ Followers ============
|
||||
|
||||
async def get_followers(username: str) -> list[dict]:
|
||||
|
||||
Reference in New Issue
Block a user