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:
gilesb
2026-01-09 00:59:12 +00:00
parent a0ed1ae5ae
commit 647c564c47
3 changed files with 488 additions and 137 deletions

81
db.py
View File

@@ -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]: