@@ -52,6 +52,12 @@ class ModuleLifecycleService @Inject constructor(
5252 data class EtagMismatch (val currentEtag : String ) : DeleteBlobResult
5353 }
5454
55+ private data class ConsumedBlob (
56+ val meta : UploadSessionMeta ,
57+ val liveBlobFile : Path ,
58+ val sessionDir : Path ,
59+ )
60+
5561 /* *
5662 * Legacy POST write — under device lock, loads old meta, rejects if blob-backed,
5763 * pre-checks the document quota delta, writes payload, settles quota, aborts
@@ -235,6 +241,24 @@ class ModuleLifecycleService @Inject constructor(
235241 return @withLock CommitResult .QuotaExceeded to emptyList<Path >()
236242 }
237243 var rollbackDocDelta = docDelta > 0
244+ var commitPointReached = false
245+ val consumedBlobs = mutableListOf<ConsumedBlob >()
246+
247+ fun rollbackConsumedBlobs () {
248+ if (consumedBlobs.isEmpty()) return
249+ consumedBlobs.asReversed().forEach { consumed ->
250+ try {
251+ consumed.liveBlobFile.parent?.deleteRecursively()
252+ } catch (e: Exception ) {
253+ log(TAG , WARN ) { " commitModule: failed to rollback consumed blob ${consumed.liveBlobFile} : ${e.message} " }
254+ }
255+ sessionRepo.removeConsumedSession(consumed.meta.sessionId, consumed.sessionDir)
256+ if (consumed.meta.expectedSizeBytes > 0 ) {
257+ storageTracker.releaseReservation(caller.accountId, consumed.meta.expectedSizeBytes)
258+ }
259+ }
260+ consumedBlobs.clear()
261+ }
238262
239263 try {
240264 val modulePath = moduleRepo.resolveModulePath(target, moduleId)
@@ -265,60 +289,54 @@ class ModuleLifecycleService @Inject constructor(
265289
266290 // Pass 2 — move staged blobs into live storage. peek+consume is TOCTOU
267291 // (GC can terminate a peeked session before consume runs); on any failure
268- // here, roll back already-moved blobs by deleting their storageKey dirs.
292+ // before module.json becomes the commit point, rollbackConsumedBlobs()
293+ // deletes already-moved payloads and releases their reservations.
269294 val newBlobRefs = mutableListOf<BlobRef >()
270- val movedDestPaths = mutableListOf<Path >()
271- fun rollbackMoves () {
272- movedDestPaths.forEach { dest ->
273- runCatching { dest.parent?.deleteRecursively() }
295+ for (blobId in blobRefIds) {
296+ val existingRef = existingMeta?.blobRefs?.find { it.blobId == blobId }
297+ if (existingRef != null ) {
298+ newBlobRefs.add(existingRef)
299+ continue
274300 }
275- }
276- try {
277- for (blobId in blobRefIds) {
278- val existingRef = existingMeta?.blobRefs?.find { it.blobId == blobId }
279- if (existingRef != null ) {
280- newBlobRefs.add(existingRef)
281- continue
282- }
283- var capturedDest: Path ? = null
284- val consumed = sessionRepo.consumeAndMoveCompletedBlob(
285- blobId = blobId,
286- accountId = caller.accountId,
287- deviceId = target.id,
288- moduleId = moduleId,
289- ) { meta ->
290- val prefix = meta.storageKey.take(4 )
291- val destPath = modulePath.resolve(" blobs" ).resolve(prefix).resolve(meta.storageKey).resolve(" payload.blob" )
292- capturedDest = destPath
293- destPath
294- }
295- val sessionMeta = when (consumed) {
296- is UploadSessionRepo .ConsumeResult .Ready -> {
297- capturedDest?.let { movedDestPaths.add(it) }
298- consumed.meta
299- }
300- UploadSessionRepo .ConsumeResult .SessionNotFound -> {
301- rollbackMoves()
302- return @withLock CommitResult .BadRequest (" Referenced blobId not found: $blobId " ) to emptyList<Path >()
303- }
304- UploadSessionRepo .ConsumeResult .PayloadMissing -> {
305- rollbackMoves()
306- return @withLock CommitResult .BadRequest (" Staged blob payload missing for $blobId " ) to emptyList<Path >()
307- }
308- }
309- newBlobRefs.add(
310- BlobRef (
311- blobId = sessionMeta.blobId,
312- storageKey = sessionMeta.storageKey,
313- sizeBytes = sessionMeta.expectedSizeBytes,
314- hashAlgorithm = sessionMeta.hashAlgorithm,
315- hashHex = sessionMeta.hashHex,
301+ var capturedDest: Path ? = null
302+ val consumed = sessionRepo.consumeAndMoveCompletedBlob(
303+ blobId = blobId,
304+ accountId = caller.accountId,
305+ deviceId = target.id,
306+ moduleId = moduleId,
307+ ) { meta ->
308+ val prefix = meta.storageKey.take(4 )
309+ val destPath = modulePath.resolve(" blobs" ).resolve(prefix).resolve(meta.storageKey).resolve(" payload.blob" )
310+ capturedDest = destPath
311+ destPath
312+ }
313+ val sessionMeta = when (consumed) {
314+ is UploadSessionRepo .ConsumeResult .Ready -> {
315+ val liveBlobFile = capturedDest
316+ ? : throw IllegalStateException (" Consumed blob destination was not captured" )
317+ consumedBlobs.add(
318+ ConsumedBlob (
319+ meta = consumed.meta,
320+ liveBlobFile = liveBlobFile,
321+ sessionDir = consumed.sessionDir,
322+ )
316323 )
317- )
324+ consumed.meta
325+ }
326+ UploadSessionRepo .ConsumeResult .SessionNotFound ->
327+ return @withLock CommitResult .BadRequest (" Referenced blobId not found: $blobId " ) to emptyList<Path >()
328+ UploadSessionRepo .ConsumeResult .PayloadMissing ->
329+ return @withLock CommitResult .BadRequest (" Staged blob payload missing for $blobId " ) to emptyList<Path >()
318330 }
319- } catch (e: Exception ) {
320- rollbackMoves()
321- throw e
331+ newBlobRefs.add(
332+ BlobRef (
333+ blobId = sessionMeta.blobId,
334+ storageKey = sessionMeta.storageKey,
335+ sizeBytes = sessionMeta.expectedSizeBytes,
336+ hashAlgorithm = sessionMeta.hashAlgorithm,
337+ hashHex = sessionMeta.hashHex,
338+ )
339+ )
322340 }
323341
324342 // Write payload.blob first, then module.json as commit point
@@ -343,25 +361,29 @@ class ModuleLifecycleService @Inject constructor(
343361 val tempMeta = modulePath.resolve(" module.json.tmp" )
344362 tempMeta.writeText(json.encodeToString(meta))
345363 tempMeta.moveTo(metaFile, overwrite = true )
364+ commitPointReached = true
365+ rollbackDocDelta = false
346366
347- // Update access metadata
348- val accessFile = modulePath.resolve(" access.json" )
349- val tempAccess = modulePath.resolve(" access.json.tmp" )
350- tempAccess.writeText(json.encodeToString(AccessMeta (lastAccessedAt = now)))
351- tempAccess.moveTo(accessFile, overwrite = true )
367+ // Update access metadata. module.json is the commit point; an access
368+ // write failure must not turn a committed module into a quota rollback.
369+ try {
370+ val accessFile = modulePath.resolve(" access.json" )
371+ val tempAccess = modulePath.resolve(" access.json.tmp" )
372+ tempAccess.writeText(json.encodeToString(AccessMeta (lastAccessedAt = now)))
373+ tempAccess.moveTo(accessFile, overwrite = true )
374+ } catch (e: Exception ) {
375+ log(TAG , WARN ) { " commitModule: failed to persist access metadata for $moduleId : ${e.message} " }
376+ }
352377
353- // Clean up committed sessions
354- for (ref in newBlobRefs) {
355- if (existingMeta?.blobRefs?.any { it.blobId == ref.blobId } != true ) {
356- sessionRepo.removeCommittedSessionByBlobId(ref.blobId)
357- }
378+ // Clean up sessions whose payloads were consumed by this commit. Quota is
379+ // settled below; this deletion deliberately does not release reservations.
380+ for (consumed in consumedBlobs) {
381+ sessionRepo.removeConsumedSession(consumed.meta.sessionId, consumed.sessionDir)
358382 }
359383
360384 // Quota update for blobs and shrinking documents. Positive doc delta was
361385 // already applied by tryAdjustUsed above.
362- val newReferencedBytes = newBlobRefs.filter { ref ->
363- existingMeta?.blobRefs?.any { it.blobId == ref.blobId } != true
364- }.sumOf { it.sizeBytes }
386+ val newReferencedBytes = consumedBlobs.sumOf { it.meta.expectedSizeBytes }
365387 val orphanedBlobRefs = existingMeta?.blobRefs
366388 ?.filter { old -> newBlobRefs.none { it.blobId == old.blobId } }
367389 ? : emptyList()
@@ -373,7 +395,6 @@ class ModuleLifecycleService @Inject constructor(
373395 if (docDelta < 0 ) {
374396 storageTracker.adjustUsed(caller.accountId, docDelta)
375397 }
376- rollbackDocDelta = false
377398
378399 // Collect orphaned blob paths for async deletion outside the lock —
379400 // deleting here would block every concurrent read/write for large orphans.
@@ -385,6 +406,9 @@ class ModuleLifecycleService @Inject constructor(
385406 log(TAG ) { " commitModule(${caller.id.shortId()} ): $moduleId committed, etag=$newEtag " }
386407 CommitResult .Success (newEtag) to orphansToDelete
387408 } finally {
409+ if (! commitPointReached) {
410+ rollbackConsumedBlobs()
411+ }
388412 if (rollbackDocDelta) {
389413 storageTracker.adjustUsed(caller.accountId, - docDelta)
390414 }
0 commit comments