Skip to content

Commit f830ee6

Browse files
committed
fix: roll back consumed blobs and quota on commit failure
Old rollbackMoves() only unlinked moved blob files; it did not release reservations or remove the upload sessions whose payloads had been consumed. A commit that failed after consumeAndMoveCompletedBlob (e.g. module.json rename throws) leaked reservedBytes and orphan sessions. commitModule now tracks each consumed blob in a ConsumedBlob struct (meta, live blob path, session dir) and exposes an explicit commitPointReached flag set right after the module.json.tmp -> module.json rename. Before that point, rollbackConsumedBlobs() does the full reverse: delete payload, removeConsumedSession, releaseReservation. After the commit point, even an access.json write failure is logged-and-swallowed - the module is committed and must not turn into a quota rollback. UploadSessionRepo.ConsumeResult.Ready now carries sessionDir so callers don't re-resolve it. New removeConsumedSession() deletes a session whose payload was already moved out, without touching quota (the caller owns the reserved -> used transition). Adds a BlobQuotaBoundaryFlowTest case that corrupts module.json.tmp (creates a directory with that name so rename fails), asserts 500 + reservedBytes back to 0 + session removed + a fresh session can be created.
1 parent cc63c2f commit f830ee6

3 files changed

Lines changed: 173 additions & 66 deletions

File tree

src/main/kotlin/eu/darken/octi/server/module/ModuleLifecycleService.kt

Lines changed: 88 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

src/main/kotlin/eu/darken/octi/server/module/UploadSessionRepo.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ class UploadSessionRepo @Inject constructor(
451451
// region Lookups (scoped)
452452

453453
sealed interface ConsumeResult {
454-
data class Ready(val meta: UploadSessionMeta) : ConsumeResult
454+
data class Ready(val meta: UploadSessionMeta, val sessionDir: Path) : ConsumeResult
455455
data object SessionNotFound : ConsumeResult
456456
data object PayloadMissing : ConsumeResult
457457
}
@@ -501,7 +501,7 @@ class UploadSessionRepo @Inject constructor(
501501
// cleans it up after the module.json rename. terminateSessionLocked sees the empty
502502
// session dir (staged file is gone) and skips releaseReservation — commitReservation
503503
// owns the reserved→used transition.
504-
ConsumeResult.Ready(meta = current.meta)
504+
ConsumeResult.Ready(meta = current.meta, sessionDir = current.sessionDir)
505505
}
506506
}
507507

@@ -552,6 +552,21 @@ class UploadSessionRepo @Inject constructor(
552552
}
553553
}
554554

555+
/**
556+
* Drops a session whose staged payload has already been consumed by commit logic.
557+
* Does not release quota; the caller owns the reserved -> used or reserved -> free
558+
* transition after moving the staged payload out of the session directory.
559+
*/
560+
fun removeConsumedSession(sessionId: String, sessionDir: Path) {
561+
sessions.remove(sessionId)
562+
try {
563+
sessionDir.deleteRecursively()
564+
} catch (e: Exception) {
565+
log(TAG, WARN) { "removeConsumedSession: failed to clean up $sessionId: ${e.message}" }
566+
}
567+
cleanupEmptyParentsAfterSession(sessionDir)
568+
}
569+
555570
/**
556571
* Checks whether a module has any active non-expired sessions (for GC protection).
557572
*/

src/test/kotlin/eu/darken/octi/server/module/BlobQuotaBoundaryFlowTest.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import io.ktor.http.*
88
import kotlinx.serialization.Serializable
99
import org.junit.jupiter.api.Test
1010
import java.util.*
11+
import kotlin.io.path.createDirectories
1112

1213
private fun base64Encode(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
1314

@@ -26,6 +27,39 @@ class BlobQuotaBoundaryFlowTest : TestRunner() {
2627
val sessionId: String = "",
2728
)
2829

30+
private suspend fun TestEnvironment.createFinalizedSession(
31+
creds: Credentials,
32+
moduleId: String,
33+
payload: ByteArray,
34+
): SessionInfo {
35+
val hash = payload.sha256Hex()
36+
val session = http.post("/v1/module/$moduleId/blob-sessions") {
37+
url { parameters.append("device-id", creds.deviceId.toString()) }
38+
addCredentials(creds)
39+
contentType(ContentType.Application.Json)
40+
setBody("""{"sizeBytes": ${payload.size}, "hashAlgorithm": "sha256", "hashHex": "$hash"}""")
41+
}.body<SessionInfo>()
42+
43+
if (payload.isNotEmpty()) {
44+
http.patch("/v1/module/$moduleId/blob-sessions/${session.sessionId}") {
45+
url { parameters.append("device-id", creds.deviceId.toString()) }
46+
addCredentials(creds)
47+
header("Upload-Offset", "0")
48+
contentType(ContentType.Application.OctetStream)
49+
setBody(payload)
50+
}.status shouldBe HttpStatusCode.NoContent
51+
}
52+
53+
http.post("/v1/module/$moduleId/blob-sessions/${session.sessionId}/finalize") {
54+
url { parameters.append("device-id", creds.deviceId.toString()) }
55+
addCredentials(creds)
56+
contentType(ContentType.Application.Json)
57+
setBody("{}")
58+
}.status shouldBe HttpStatusCode.OK
59+
60+
return session
61+
}
62+
2963
@Test
3064
fun `session at exact quota succeeds, one more byte returns 507`() {
3165
runTest2(appConfig = baseConfig.copy(accountQuotaBytes = 1024, maxBlobBytes = 10_000)) {
@@ -76,6 +110,40 @@ class BlobQuotaBoundaryFlowTest : TestRunner() {
76110
}
77111
}
78112

113+
@Test
114+
fun `failed PUT after consuming blob releases reservation`() {
115+
runTest2(appConfig = baseConfig.copy(accountQuotaBytes = 1024, maxBlobBytes = 10_000)) {
116+
val creds = createDevice()
117+
val accountId = UUID.fromString(creds.account)
118+
val failingModuleId = "eu.darken.octi.quota.failedput"
119+
val payload = ByteArray(1024) { 7 }
120+
val session = createFinalizedSession(creds, failingModuleId, payload)
121+
122+
component.storageTracker().getUsage(accountId).reservedBytes shouldBe 1024
123+
124+
val moduleDir = BlobFixtures.moduleDir(config.dataPath, accountId, creds.deviceId, failingModuleId)
125+
moduleDir.resolve("module.json.tmp").createDirectories()
126+
127+
http.put("/v1/module/$failingModuleId") {
128+
url { parameters.append("device-id", creds.deviceId.toString()) }
129+
addCredentials(creds)
130+
header("If-None-Match", "*")
131+
contentType(ContentType.Application.Json)
132+
setBody("""{"documentBase64": "${base64Encode(ByteArray(0))}", "blobRefs": [{"blobId": "${session.blobId}"}]}""")
133+
}.status shouldBe HttpStatusCode.InternalServerError
134+
135+
component.storageTracker().getUsage(accountId).reservedBytes shouldBe 0
136+
component.sessionRepo().getSession(session.sessionId, accountId, creds.deviceId, failingModuleId) shouldBe null
137+
138+
http.post("/v1/module/eu.darken.octi.quota.retry/blob-sessions") {
139+
url { parameters.append("device-id", creds.deviceId.toString()) }
140+
addCredentials(creds)
141+
contentType(ContentType.Application.Json)
142+
setBody("""{"sizeBytes": 1024}""")
143+
}.status shouldBe HttpStatusCode.Created
144+
}
145+
}
146+
79147
// Documents current behaviour: zero-byte sessions skip the tryReserve call entirely (the
80148
// `sizeBytes > 0` branch in BlobRoute.createSession). They can be created even when quota
81149
// is fully reserved. If production later gates zero-byte sessions for consistency, update

0 commit comments

Comments
 (0)