Offline Outbox & Retry Engine Reference
Every change is journaled in the local store in the same transaction as the local edit, and sent later; a network drop, crash or restart does not lose it.
1. Outbox Architecture & Execution Loop
sequenceDiagram
autonumber
participant App as Client Call (send, flag, move, remove)
participant Store as ObjectBox Store
participant Worker as Outbox Worker
participant Server as Aduki REST API
App->>Store: Atomic Tx: Write Message (dirty) + Insert Outbox entry
Store-->>App: Return optimistic Message
App->>Worker: drain()
activate Worker
Worker->>Store: Next pending entry (oldest first)
Worker->>Server: HttpDispatcher: REST call (send carries Idempotency-Key)
alt Transient failure (network, 5xx, 408, 409, 429)
Worker->>Store: attempts++, nextRetry = now + jitter
Worker->>Worker: Stop: later entries wait (order is preserved)
else Rejected for good (other 4xx)
Worker->>Store: Drop entry, clear dirty (next sync restores server state)
Worker->>App: onRejected(entry, error)
else Success
Worker->>Store: Delete entry, clear dirty (send: adopt the server id)
end
deactivate Worker
2. Core Method Signatures
Manager
package pro.aduki.sync.outbox
class Manager(storage: Storage) {
constructor(store: BoxStore)
fun send(msg: Message, raw: ByteArray): Outbox // raw = JSON send request
fun flag(hex: String, flag: Int): Outbox // toggles locally, journals the result
fun move(hex: String, dest: String): Outbox
fun remove(hex: String): Outbox
fun enqueue(action: String, payload: ByteArray, hex: String = ""): Outbox
fun pending(): List<Outbox>
fun complete(id: Long, hex: String? = null)
fun rename(old: String, new: String) // placeholder id -> server id
fun fail(id: Long, delay: Long)
}
Worker and Dispatcher
fun interface Dispatcher {
suspend fun dispatch(action: Outbox)
}
class Rejected(message: String, val code: Int? = null, cause: Throwable? = null) : Exception(message, cause)
class Worker(
manager: Manager,
dispatcher: Dispatcher,
jitter: Jitter = Jitter(),
onRejected: (Outbox, Rejected) -> Unit = { _, _ -> }
) {
suspend fun drain(): Int
}
HttpDispatcher
The REST implementation (pro.aduki.sync.http):
val manager = Manager(boxStore)
val dispatcher = HttpDispatcher(
api = client.mailApi,
lookup = { hex -> messageBox.query(Message_.hex.equal(hex)).build().use { it.findFirst() } },
rename = manager::rename,
fallback = null // or a Dispatcher for non-mail actions such as "appointment_cancel"
)
val worker = Worker(manager, dispatcher, onRejected = { entry, e -> /* tell the user */ })
3. Data Model: Outbox Entity
@Entity
data class Outbox(
@Id var id: Long = 0,
@Index var hex: String = "",
@Index var action: String = "", // "send", "flag", "move", "delete", ...
var payload: ByteArray = byteArrayOf(),
@Index var created: Long = System.currentTimeMillis(),
var attempts: Int = 0,
var nextRetry: Long = 0
)
Actions, Payloads and Endpoints
action | Payload (UTF-8 JSON) | Endpoint |
|---|---|---|
send | {"to": [...], "cc": [...], "subject": "...", "text": "...", "from"?: "..."} | POST /v1/user/mail/send |
flag | {"hex": "...", "flag": 4, "set": true} — the resulting state, not a toggle | PATCH /v1/user/mail/{hex}/flags (add or remove) |
move | {"hex": "...", "mailbox": "<mailbox hex>"} | PATCH /v1/user/mail/{hex}/mailbox |
delete | {"hex": "..."} | DELETE /v1/user/mail/{hex} |
Entries journaled by 0.1.x ("hex:flag", "hex:dest", a bare hex, or the raw body for send) are still dispatched.
A flag, move or delete of a message the server no longer has (404) completes: there is nothing left to do.
4. Idempotency & Replay Protection
A send whose response is lost (timeout, dropped connection) is retried with the same key, and the server replays the first result instead of sending again:
POST /v1/user/mail/send HTTP/1.1
Authorization: Bearer <access token>
Idempotency-Key: outbox-42-1790413340658
Content-Type: application/json
{"to":["bob@example.com"],"cc":[],"subject":"Lunch","text":"Friday?"}
The key is outbox-<entry id>-<created>, stable across retries and app restarts. While the first attempt is still running, a retry gets 409 and is retried later. After the send, the local placeholder message (msg_<millis>) takes the server’s id from the response.
5. Decorrelated Jitter Retry Backoff
Each retry waits min(max, random(base, 3 x previous)) (Amazon’s Decorrelated Jitter), so retries from many devices do not synchronise.
package pro.aduki.core.retry
class Jitter(val base: Long = 100, val max: Long = 30_000) {
fun next(attempt: Int = 0): Long // next delay in ms
fun reset()
fun current(): Long
}