Aset tekstur 3D modern berbasis Physically Based Rendering (PBR) tidak pernah berdiri sendiri sebagai file tunggal. Satu material biasanya membutuhkan kumpulan map yang saling terikat: albedo (base color), normal, roughness, metallic, hingga displacement/height. Kehilangan satu layer akibat kegagalan koneksi dapat merusak shader pipeline pada engine 3D atau menyebabkan crash saat runtime rendering.
Masalah klasik muncul saat klien mengunggah file-file berukuran puluhan hingga ratusan megabyte ini secara simultan langsung ke backend. Kegagalan parsial menyisakan status data yang setengah matang di database dan meninggalkan data biner tak bertuan (orphan blob) di object storage. Pendekatan atomic commit dengan pola two-phase upload menyelesaikan masalah ini secara deterministik.
Akar Masalah: Partial Failure dan Race Condition
Arsitektur upload konvensional sering memicu tiga kegagalan struktural:
- Desync Texture Map: File albedo dan normal berhasil terunggah, namun koneksi putus saat upload roughness. Database menandai material selesai diunggah karena tidak ada validasi atomic state across multiple files.
- Orphan Blob: File terkirim ke storage service (AWS S3, Cloudflare R2, Google Cloud Storage), tetapi request HTTP database commit gagal karena timeout atau client crash. Objek biner menetap selamanya di bucket, membebani biaya penyimpanan tanpa referensi relational database.
- Retry Race Condition: Saat koneksi terganggu, client melakukan retry upload. Tanpa koordinasi idempotensi, worker background dapat menimpa map baru dengan payload lama dari paket retry yang tertunda di jaringan.
Pola Two-Phase Commit untuk Aset Multi-Layer
Pola ini memisahkan alokasi izin upload dari finalisasi status database. Alur kerja dibagi menjadi dua fase terisolasi:
- Fase Inisiasi (Manifest Creation): Klien mengirim metadata seluruh map yang wajib ada beserta checksum SHA-256 dan
idempotency_keyunik. Backend membuat record batch bertatusPENDINGdan menerbitkan Presigned URL terbatas (TTL 15-30 menit) ke direct storage. - Fase Finalisasi (Atomic Commit): Setelah klien selesai mengunggah seluruh biner ke storage secara paralel, klien memanggil endpoint commit. Backend memverifikasi keberadaan dan integritas seluruh blob sebelum mengubah status batch menjadi
ACTIVEdalam satu database transaction.
Lazier alternative: Klien mengemas map ke satu file .tar.zst lalu server mengekstraknya via background worker. Opsi ini dihindari jika file berukuran di atas 2GB karena membebani I/O disk server dan memperlambat feedback loop ke klien.
Desain Skema Database
Skema database harus mendukung validasi set tekstur yang ketat sebelum aset dapat dikonsumsi oleh pipeline downstream.
CREATE TYPE texture_status AS ENUM ('PENDING', 'ACTIVE', 'FAILED');
CREATE TYPE map_type AS ENUM ('albedo', 'normal', 'roughness', 'displacement');
CREATE TABLE texture_sets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
status texture_status DEFAULT 'PENDING',
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE texture_maps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
texture_set_id UUID NOT NULL REFERENCES texture_sets(id) ON DELETE CASCADE,
type map_type NOT NULL,
s3_key VARCHAR(255) NOT NULL,
expected_sha256 CHAR(64) NOT NULL,
is_verified BOOLEAN DEFAULT FALSE,
UNIQUE (texture_set_id, type)
);Implementasi Endpoint Finalisasi
Berikut implementasi validasi server menggunakan Node.js dan AWS SDK v3. Endpoint ini menolak komitmen jika ada map wajib yang hilang atau ukuran payload tidak sesuai.
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import { Pool } from "pg";
const s3 = new S3Client({});
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const REQUIRED_MAPS = ['albedo', 'normal', 'roughness'];
export async function finalizeTextureSet(req, res) {
const { textureSetId } = req.params;
const client = await db.connect();
try {
await client.query('BEGIN');
// Kunci row untuk mencegah race condition dari concurrent commit call
const setRes = await client.query(
`SELECT status, expires_at FROM texture_sets WHERE id = $1 FOR UPDATE`,
[textureSetId]
);
if (setRes.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({ error: "Set tekstur tidak ditemukan." });
}
const set = setRes.rows[0];
if (set.status === 'ACTIVE') {
await client.query('ROLLBACK');
return res.status(200).json({ message: "Aset sudah aktif." });
}
if (new Date() > new Date(set.expires_at)) {
await client.query(
`UPDATE texture_sets SET status = 'FAILED' WHERE id = $1`,
[textureSetId]
);
await client.query('COMMIT');
return res.status(410).json({ error: "Sesi upload kedaluwarsa." });
}
const mapsRes = await client.query(
`SELECT id, type, s3_key, expected_sha256 FROM texture_maps WHERE texture_set_id = $1`,
[textureSetId]
);
const uploadedTypes = mapsRes.rows.map(m => m.type);
const hasAllRequired = REQUIRED_MAPS.every(type => uploadedTypes.includes(type));
if (!hasAllRequired) {
await client.query('ROLLBACK');
return res.status(400).json({ error: "Map tekstur wajib belum lengkap terdaftar." });
}
// Verifikasi fisik keberadaan file di Object Storage
for (const map of mapsRes.rows) {
try {
const head = await s3.send(new HeadObjectCommand({
Bucket: process.env.STORAGE_BUCKET,
Key: map.s3_key
}));
// Validasi checksum base64/hex dari S3 metadata jika diaktifkan saat presign
if (head.ContentLength === 0) {
throw new Error(`File kosong: ${map.s3_key}`);
}
await client.query(
`UPDATE texture_maps SET is_verified = TRUE WHERE id = $1`,
[map.id]
);
} catch (err) {
await client.query('ROLLBACK');
return res.status(422).json({
error: `Objek blob belum terunggah atau tidak valid: ${map.type}`
});
}
}
// Komit status aset secara atomik
await client.query(
`UPDATE texture_sets SET status = 'ACTIVE' WHERE id = $1`,
[textureSetId]
);
await client.query('COMMIT');
return res.status(200).json({ status: "ACTIVE", textureSetId });
} catch (error) {
await client.query('ROLLBACK');
return res.status(500).json({ error: error.message });
} finally {
client.release();
}
}Mitigasi Orphan Blob: Storage Lifecycle & Rollback
Validasi server di atas melindungi database dari desync, namun file blob yang terunggah sebagian tetap berada di bucket jika klien menghentikan proses di tengah jalan. Terapkan strategi berikut untuk membersihkan orphan blob:
1. Prefix Staging dan S3 Lifecycle Expiration
Pisahkan namespace object storage antara upload sementara dan aset terverifikasi:
- Draft upload:
staging/textures/{textureSetId}/{map_type}.png - Aset final:
assets/textures/{textureSetId}/{map_type}.png
Terapkan Lifecycle Rule pada prefix staging/ yang menghapus objek otomatis dalam 1 atau 2 hari. Ketika endpoint commit tervalidasi, backend memindahkan objek secara internal (atau menugaskan worker untuk copy & delete) ke prefix assets/.
2. Scheduled Pruning Worker
Gunakan worker terjadwal untuk mendeteksi batch yang menggantung:
SELECT id FROM texture_sets
WHERE status = 'PENDING' AND expires_at < NOW() - INTERVAL '1 hour';Worker ini menandai status ke FAILED dan mengeksekusi DeleteObjectsCommand ke S3 untuk membersihkan sisa blob sebelum lifecycle rule storage berjalan.
Kesimpulan
Mencegah data desync pada sistem tekstur 3D memerlukan jaminan atomisitas yang mengikat status database dengan eksistensi fisik file storage. Dengan memadukan manifest berbatas waktu, presigned URL langsung ke object storage, verifikasi HeadObject, dan lifecycle cleanup pada prefix staging, integritas pipeline 3D tetap terjaga tanpa risiko kebocoran storage.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!