How Asset Collection Works in OpenMAIC with PostgreSQL Persistence
OpenMAIC's asset collection mechanism periodically deletes unreferenced binary blobs from PostgreSQL by computing a grace-period cutoff, batching candidates, and executing atomic transactions that lock rows, verify zero references, delete bytes, and remove metadata rows.
OpenMAIC stores binary assets—such as uploaded images, audio, and video—in a PostgreSQL-backed persistence layer. To prevent infinite storage growth, the system implements a garbage collection process that reclaims orphaned blobs after a configurable safety window, all while maintaining strict transactional consistency.
The Asset Storage Architecture
The asset_blobs Table
Binary assets reside in the asset_blobs table. Each row tracks a blob’s content_hash (its unique identifier) and an unreferenced_at timestamp indicating when the last reference to that blob was removed. Only rows with an unreferenced_at value older than the configured grace period are eligible for deletion.
The AssetCollector Class
The core logic lives in AssetCollector (packages/@openmaic/storage/src/asset/collector.ts). This class orchestrates bounded collection passes using two dependencies:
- Queryable: A thin PostgreSQL query surface (typically a
pg.Poolor similar) for executing SQL. - AssetByteStore: An interface (
packages/@openmaic/storage/src/asset/byte-store.ts) that handles the actual deletion of raw bytes, optionally providing adeleteWithmethod to avoid self-deadlocks.
Step-by-Step Collection Process
The collectPass() method implements a six-stage workflow:
1. Initialization and Cutoff Calculation
When instantiated, the collector accepts options for graceMs, batchSize, and a clock override for testing. At runtime, it computes a cutoff timestamp using now() – graceMs. Only blobs whose unreferenced_at predates this cutoff are considered candidates.
See the cutoff calculation in packages/@openmaic/storage/src/asset/collector.ts#L68.
2. Candidate Fetching
The collector queries the asset_blobs table with a bounded SELECT statement limited to batchSize rows. Results are ordered by unreferenced_at (oldest first) and then by content_hash to ensure deterministic, starvation-free processing.
This query appears at packages/@openmaic/storage/src/asset/collector.ts#L83.
3. Transactional Processing
For each candidate, the collector opens a withTransaction block to guarantee atomicity. Inside the transaction:
- Row locking: The candidate row is locked with
FOR UPDATEto prevent concurrent modifications. - Reference verification: A
NOT EXISTScheck confirms no entries in theasset_entriestable still reference this blob. - Byte deletion: The
AssetByteStoredeletes the raw bytes. If the store implementsdeleteWith, the deletion uses the sameQueryableto avoid self-deadlock; otherwise, the bytes must live outside the same database. - Metadata cleanup: The
asset_blobsrow is deleted.
This transaction body is implemented at packages/@openmaic/storage/src/asset/collector.ts#L96-L124.
4. Pass Result Reporting
After iterating through candidates, collectPass() returns an AssetCollectionPass object containing:
collected: The count of blobs actually deleted during this pass.capped: A boolean indicating whether the result hit thebatchSizelimit. Whentrue, additional passes are required to clear remaining eligible blobs.
The return statement is located at packages/@openmaic/storage/src/asset/collector.ts#L129-L130.
5. Scheduling and Idempotency
Hosts invoke collectPass() on a recurring schedule (e.g., via a cron job every few minutes). The design is idempotent; if multiple hosts run simultaneously, PostgreSQL's row-level locks ensure only one process deletes any specific blob.
Configuration and Safety Guarantees
OpenMAIC enforces several safety mechanisms to protect data integrity and system stability:
- Grace period: The default
DEFAULT_ASSET_COLLECTION_GRACE_MSis one hour. This ensures signed URLs (which must expire earlier) remain valid while the underlying bytes are protected from premature deletion. The relationship is validated byassertSignedUrlTtlWithinGrace. - Batch size:
DEFAULT_ASSET_COLLECTION_BATCH_SIZEdefaults to 1000, limiting transaction scope and preventing long-running queries from impacting database performance. - Self-deadlock protection: If an
AssetByteStorelacks thedeleteWithmethod but stores bytes within the same PostgreSQL database, the collector throws acollectorConfigurationFailure()error. This prevents a deadlock scenario where the delete operation would wait on a row lock already held by the transaction. - Transactional safety: All read operations, reference checks, byte deletions, and row removals occur within a single PostgreSQL transaction, ensuring atomic rollback if any step fails.
Implementation Example
The following TypeScript example demonstrates wiring the collector with a PostgreSQL pool and a byte store:
import { AssetCollector } from '@openmaic/storage/src/asset/collector';
import { PgAssetByteStore } from '@openmaic/storage/src/asset/byte-store';
import pg from 'pg';
// 1️⃣ Create a Queryable and transaction helper
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const withTransaction = async <T>(fn: (q: any) => Promise<T>) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
};
// 2️⃣ Instantiate the byte store and collector
const byteStore = new PgAssetByteStore(/* configuration */);
const collector = new AssetCollector(pool, byteStore, {
withTransaction,
graceMs: 2 * 60 * 60 * 1000, // 2 hours
batchSize: 500,
});
// 3️⃣ Run a bounded collection pass (typically from a background worker)
async function runAssetCollection() {
const pass = await collector.collectPass();
console.log(`Deleted ${pass.collected} unreferenced assets`);
if (pass.capped) {
// More work remains—schedule another pass immediately
setTimeout(runAssetCollection, 0);
}
}
runAssetCollection();
Summary
- AssetCollector (
packages/@openmaic/storage/src/asset/collector.ts) manages the lifecycle of unreferenced binary blobs in PostgreSQL. - Blobs become eligible for deletion only after their
unreferenced_attimestamp exceeds the configurablegraceMswindow. - Each collection pass processes a bounded batch (default 1000) within atomic transactions that lock rows, verify zero references, delete bytes, and remove metadata.
- The idempotent design allows safe concurrent execution across multiple hosts without double-deletion risks.
- Built-in safeguards prevent self-deadlocks and ensure signed URLs expire safely before their underlying assets are reclaimed.
Frequently Asked Questions
What is the default grace period for asset collection?
The default grace period is one hour (DEFAULT_ASSET_COLLECTION_GRACE_MS). This duration ensures that presigned URLs—which must expire earlier than the grace period—remain functional while protecting recently unreferenced assets from premature deletion.
How does the collector prevent database deadlocks?
The collector prevents deadlocks through two mechanisms. First, it uses FOR UPDATE row locks within transactions to serialize access to candidate blobs. Second, it validates that the AssetByteStore implements a deleteWith method when bytes reside in the same PostgreSQL instance; if this contract is violated, the collector throws a configuration error rather than risk a circular lock dependency.
Can multiple collector instances run simultaneously?
Yes. The architecture is explicitly idempotent and supports concurrent execution. Because each candidate row is locked with FOR UPDATE before deletion, only one process can successfully delete a specific blob even if multiple hosts invoke collectPass() at the same time. Failed attempts simply skip to the next candidate.
How do I adjust the batch size for collection passes?
Pass a batchSize option when instantiating AssetCollector. The default is 1000 rows per pass. Lower values reduce transaction duration and memory pressure but require more frequent passes to clear large backlogs of unreferenced assets.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →