MutationQueue

class MutationQueue(apiClient: ApiClient, scope: CoroutineScope, store: MutationStore = InMemoryMutationStore(), defaultRetryPolicy: RetryPolicy = DefaultRetryPolicy(retryNonIdempotent = true), maxStatuses: Int = 64, codecs: List<MutationCodec<*>> = emptyList())

Enqueues "forgivable" mutations (likes, follows, and similar fire-and-forget writes) for background execution with retry, instead of awaiting them inline from a ViewModel.

enqueueMutation/enqueue return immediately — the actual ApiClient.request call and any retries run on scope, which the caller owns (e.g. an app-scoped CoroutineScope that outlives any single screen). This is the fix for two problems with calling apiClient.request(...) directly for mutations:

  1. No queueing: if the process dies mid-flight, the mutation is silently lost.

  2. No retry: DefaultRetryPolicy deliberately excludes POST/PATCH by default (retrying a non-idempotent call risks a duplicate write), so a plain NetworkClient never retries them — correct for most calls, wrong for ones the caller has decided are safe/idempotent-in-effect to retry (e.g. "set like state to true" is idempotent even though it's a POST).

Behavior:

  • Coalescing: enqueueing under a MutationKey that already has a pending/retrying mutation replaces it — including cancelling any in-progress retry backoff — so only the latest desired state for that key is ever sent. See MutationKey.

  • Retry: reuses RetryPolicy (the same interface io.github.maniramezan.kenwork.network.NetworkClient uses), but as a queue-level (or per-enqueue) setting distinct from io.github.maniramezan.kenwork.network.NetworkClientConfiguration.retryPolicy — so opting a mutation into non-idempotent retry never loosens the client's own default. It is also the only retry layer: each attempt runs under withRetryPolicy(RetryPolicy.None), so a io.github.maniramezan.kenwork.network.NetworkClient doesn't retry underneath the queue and multiply the attempts. With the defaults, a mutation has at most 3 queue attempts; OAuth's 401 refresh-and-replay remains a separate path within each attempt. The client's configured connectivity gate is awaited before each queue attempt, using the client's time bound, so disabling nested retries does not bypass connectivity waiting.

  • Persistence: pass a MutationCodec to enqueue to have the mutation survive in store (see MutationStore for durability); omit it for a purely in-process, fire-and-forget mutation.

  • Status: observe statusFlow for a key to reflect pending/retrying/succeeded/failed in the UI.

  • Lifecycle & Cleanup: completed workers are removed once idle, and cancel cancels in-flight work, removes persisted records, and resets status to null. Internal status tracking is bounded so the queue does not grow indefinitely.

Parameters

apiClient

executes the underlying HTTP calls.

scope

owns every mutation's execution + retry backoff; mutations outlive the caller's own scope (e.g. a ViewModel's) as long as this scope is alive.

store

where enqueued mutations (that were given a MutationCodec) are persisted.

defaultRetryPolicy

applied to mutations enqueued without an explicit retryPolicy. Defaults to retrying non-idempotent methods too — the whole point of this queue is to make POST/PATCH mutations retryable — unlike DefaultRetryPolicy's own conservative default.

maxStatuses

maximum number of status flows to retain per queue instance. When exceeded, the least-recently-accessed entries are evicted. Must be positive. Defaults to 64.

codecs

MutationCodecs to register upfront, so restore can decode their records even before any matching enqueue call runs in this process. Enqueueing with a new codec also registers it.

Constructors

Link copied to clipboard
constructor(apiClient: ApiClient, scope: CoroutineScope, store: MutationStore = InMemoryMutationStore(), defaultRetryPolicy: RetryPolicy = DefaultRetryPolicy(retryNonIdempotent = true), maxStatuses: Int = 64, codecs: List<MutationCodec<*>> = emptyList())

Functions

Link copied to clipboard
suspend fun cancel(key: MutationKey)

Cancels any pending or in-flight mutation for key.

Link copied to clipboard
suspend fun MutationQueue.enqueue(key: MutationKey, endpoint: NetworkEndpoint, retryPolicy: RetryPolicy? = null): MutationHandle

Enqueues a bodyless mutation (e.g. POST /videos/42/like with no request body).

inline suspend fun <B : Any> MutationQueue.enqueue(key: MutationKey, endpoint: NetworkEndpoint, body: B, codec: MutationCodec<B>? = null, retryPolicy: RetryPolicy? = null): MutationHandle

Enqueues a mutation sending body of type B, serialized on execution.

Link copied to clipboard
suspend fun <B : Any> enqueueMutation(key: MutationKey, endpoint: NetworkEndpoint, body: B? = null, bodyType: TypeInfo? = null, codec: MutationCodec<B>? = null, retryPolicy: RetryPolicy? = null): MutationHandle

Enqueues a mutation and returns immediately; endpoint (with body, described by bodyType) is executed on scope in the background, retried per retryPolicy (or defaultRetryPolicy when null), and coalesced with any other pending mutation sharing key.

Link copied to clipboard
suspend fun restore()

Loads every persisted MutationRecord from store and resubmits it for execution, using the MutationCodec registered under MutationRecord.codecId (via the constructor's codecs or a prior enqueue call) to reconstruct the endpoint + body.

Link copied to clipboard
fun statusFlow(key: MutationKey): StateFlow<MutationStatus?>

The current/most recent MutationStatus for key, or null if nothing has ever been enqueued under it or if it was cancelled via cancel. Keeps emitting past a terminal MutationStatus.Succeeded/MutationStatus.Failed until evicted or superseded by a new mutation.