Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,74 @@
## 10.0.0 (unreleased)

The library now owns a durable request queue. A consumer describes each
request kind one time with `define()`, enqueues instances with `mutate()`,
and receives every outcome through the definition's handlers, on this launch
or a later one. Outcomes are journaled natively before JS hears about them
and acknowledged only after the handler's promise resolves. See the README's
"Usage" and "Reliable delivery" sections.

This release is built in slices. The JS layer, the codegen spec, and native
stubs land first; the Android and iOS queues follow. Until they land, every
queue method rejects with `E_NOT_IMPLEMENTED`.

Breaking:
- **`startUpload` and `getAllUploads` are removed.** `define()` + `mutate()`
replace the first; the synchronous `getRequests(filter?)` replaces the
second.
- **The v9 event names are removed.** `addListener` takes `'state'`,
`'progress'`, or `'attempt'`. Terminal outcomes go to the definition's
`onSuccess` / `onError`; a `cancelled` outcome calls no handler.
- **`getUnacknowledgedEvents` and `ackEvents` are internal.** The library
drains the journal after `configure()` and acknowledges after each handler
settles.
- **`cancelUpload` and `removeUpload` fold into `cancel(id)`.** A live entry
settles `cancelled` and is forgotten after its ack; a settled entry is
forgotten now, row and bytes.
- **Per-upload `wifiOnly` becomes `setWifiOnly(enabled)`** on the queue,
persisted natively.
- **`progress` carries `{ id, bytesSent, totalBytes }`** instead of a
percentage.
- **`configure()` must be called at boot, after every `define()`.** It starts
the replay of journaled outcomes. It also takes `lifetimeMs`, `retry`, and a
`headers` provider that runs at `mutate()`.
- **`ErrorKind` gains `'truncated'`.** With a `response` parser set and a body
over the 1 MB cap, `onError` fires with it instead of `onSuccess`.

Added:
- **`createUploadClient()`**: builds a client with its own definitions and
settings. The default export is one client.
- **`define({ key, request, response?, onSuccess?, onError? })`**: `vars`
infer from the `request` parameter, the handler data type from the
`response` return. A duplicate key replaces the definition and warns in
development.
- **`mutate(vars, { id? })`**: runs `request(vars)` once, merges the configured
headers under the descriptor's, validates the descriptor (exactly one of
`data` / `form` / `file`; `parts` only with `file`; parts must tile the
file; no field outside the descriptor shape), defaults `expiresAt` to now +
`lifetimeMs`, and resolves when the entry is durable. `vars` are capped at
4 KB. A definition whose `request` takes no vars calls `mutate()` with no
arguments.
- **Request bodies**: JSON (`data`), multipart (`form`), whole file (`file`),
and chunked (`file` + `parts`). All under one entry shape and one id.
- **Delivery rules**: dedupe by event id; the outcomes of one id deliver in
order, one handler at a time; an outcome for an id waits for that id's
in-flight `mutate()`; an outcome whose key has no definition stays
unacknowledged and reaches `state` listeners with `reason: 'unhandled-key'`;
a handler that has not settled after 30 s logs a warning.
- **`pause()` / `resume()`** for the whole queue, **`updateHeaders(patch)`** to
re-auth parked entries, and the **`attempt`** event with one row per HTTP
attempt before interpretation.

Removed:
- `startUpload`, `startChunkedUpload` (native), `cancelUpload`,
`removeUpload`, `getAllUploads`, the public `getUnacknowledgedEvents` /
`ackEvents`, and the `progress` / `error` / `completed` / `cancelled` event
names, with their `ProgressData`, `CompletedData`, `ErrorData`,
`CancelledData`, `EventData`, `TerminalEventData`, `JournaledEvent`,
`UploadSnapshot`, `UploadOptions`, `ChunkedUploadOptions`,
`StartUploadOptions`, `AndroidOnlyUploadOptions`, `RawUploadOptions`, and
`UploadId` types.

## 9.0.0

Chunked uploads move into the library: one file, many part requests, one upload
Expand Down
473 changes: 246 additions & 227 deletions README.md

Large diffs are not rendered by default.

215 changes: 42 additions & 173 deletions android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.google.gson.Gson
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.UUID


/**
Expand All @@ -32,8 +32,11 @@ class UploaderModule(context: ReactApplicationContext) :
const val TAG = "RNFileUploader.UploaderModule"
const val WORKER_TAG = "RNFileUploader"
// WorkInfo exposes tags but not the unique-work name, so the upload id is
// also stored as a prefixed tag to recover it in getAllUploads.
// also stored as a prefixed tag to recover it from a WorkInfo row.
const val ID_TAG_PREFIX = "RNFileUploaderId:"
// v10 slice 1 ships the JS layer alone. Every queue method rejects with
// this code until slice 2 builds the Android queue and executor.
const val E_NOT_IMPLEMENTED = "E_NOT_IMPLEMENTED"

// The live module, so EventReporter can reach the codegen emitters — they are
// protected on the generated spec, so only this class may call them. Null
Expand Down Expand Up @@ -65,13 +68,18 @@ class UploaderModule(context: ReactApplicationContext) :

// MARK: - Event emission (called by EventReporter)

fun emitProgressEvent(params: WritableMap) = safeEmit { emitOnProgress(params) }
// The v9 workers still report through these. The v10 spec has no per-outcome
// emitters and a different progress shape ({ id, bytesSent, totalBytes }), so
// until slice 2 rewires the workers to onState/onProgress/onSettled, the live
// v9 payloads are dropped here. Terminal outcomes are journaled first, so
// nothing durable is lost.
fun emitProgressEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit

fun emitCompletedEvent(params: WritableMap) = safeEmit { emitOnCompleted(params) }
fun emitCompletedEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit

fun emitErrorEvent(params: WritableMap) = safeEmit { emitOnError(params) }
fun emitErrorEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit

fun emitCancelledEvent(params: WritableMap) = safeEmit { emitOnCancelled(params) }
fun emitCancelledEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit

fun emitNotificationEvent(params: WritableMap) = safeEmit { emitOnNotification(params) }

Expand Down Expand Up @@ -139,92 +147,49 @@ class UploaderModule(context: ReactApplicationContext) :


/**
* Enumerates the uploads that WorkManager still knows about, as
* [{ id, state }]. Chunked uploads also carry the aggregate
* { bytesSent, totalBytes }, and they are listed from their durable
* manifests, even after WorkManager prunes finished work (in roughly a day).
* Terminal outcomes must be read from getUnacknowledgedEvents, which is
* durable until acknowledged.
* Synchronous. The live rows of the v10 queue. Slice 2 serializes them from
* the in-memory index; until then the queue is empty.
*/
override fun getAllUploads(promise: Promise) {
try {
val manifests = ChunkedManifestStore.get(reactApplicationContext).all()
.associateBy { it.id }
// Several WorkInfo rows can exist for one upload id. Finished chains
// linger until they are pruned (in roughly a day), and APPEND_OR_REPLACE
// resumes add rows. Thus the rows are grouped, and each id gets exactly
// ONE entry, like iOS (one row per upload, with the same state
// vocabulary).
val statesById = workManager.getWorkInfosByTag(WORKER_TAG).get()
.groupBy(
{ info ->
info.tags.firstOrNull { it.startsWith(ID_TAG_PREFIX) }
?.removePrefix(ID_TAG_PREFIX)
},
{ it.state },
)
val arr = Arguments.createArray()
for ((id, states) in statesById) {
if (id == null || id in manifests) continue
arr.pushMap(Arguments.createMap().apply {
putString("id", id)
putString("state", simpleUploadState(states))
})
}
// Chunked uploads are listed from their durable manifests, which outlive
// the WorkManager rows. Only a live row contributes (running or pending).
// A lingering finished row never speaks for a manifest that is really
// "finished, awaiting its ack" or "stalled, awaiting a startUpload
// resume".
for (manifest in manifests.values) {
arr.pushMap(Arguments.createMap().apply {
putString("id", manifest.id)
putString(
"state",
chunkedUploadState(statesById[manifest.id].orEmpty(), manifest.allAccepted),
)
putChunkedBytes(manifest)
})
}
promise.resolve(arr)
} catch (exc: Throwable) {
Log.e(TAG, exc.message, exc)
promise.reject(exc)
}
}
override fun getRequests(): WritableArray = Arguments.createArray()


/**
* Saves the notification configuration (see [NotificationConfig]). Thus a
* worker that WorkManager relaunches with no JS can read it. Each call
* replaces the full configuration. An omitted field goes back to the library
* default.
* default. The v10 `lifetimeMs` and `retry` fields ride along in the same
* map; slice 2 persists them next to the queue.
*/
override fun configure(options: ReadableMap) {
NotificationConfig.save(reactApplicationContext, NotificationConfig.fromReadableMap(options))
}


/*
* Starts a file upload.
* Returns a promise with the string ID of the upload.
*/
override fun startUpload(options: ReadableMap, promise: Promise) {
try {
val id = enqueueUpload(options)
promise.resolve(id)
} catch (exc: Throwable) {
if (exc !is Upload.MissingOptionException) {
exc.printStackTrace()
Log.e(TAG, exc.message, exc)
}
promise.reject(exc)
}
}
// MARK: - v10 queue (stubs until slice 2)

private fun notImplemented(promise: Promise, method: String) =
promise.reject(E_NOT_IMPLEMENTED, "RNFileUploader.$method: the Android queue is not built yet")

/** Persists { id, key, vars, descriptor } and schedules it. Slice 2. */
override fun enqueue(entry: ReadableMap, promise: Promise) = notImplemented(promise, "enqueue")

override fun pause(promise: Promise) = notImplemented(promise, "pause")

override fun resume(promise: Promise) = notImplemented(promise, "resume")

override fun cancel(id: String, promise: Promise) = notImplemented(promise, "cancel")

override fun setWifiOnly(enabled: Boolean, promise: Promise) = notImplemented(promise, "setWifiOnly")

override fun updateHeaders(patch: ReadableMap, promise: Promise) = notImplemented(promise, "updateHeaders")


// MARK: - v9 enqueue paths, kept for slice 2 to wire behind enqueue()

/**
* @return the id of the enqueued upload
*/
@Suppress("unused")
private fun enqueueUpload(options: ReadableMap): String {
val upload = Upload.fromReadableMap(options)
val data = Gson().toJson(upload)
Expand Down Expand Up @@ -262,18 +227,7 @@ class UploaderModule(context: ReactApplicationContext) :
* recovery, a resume after a stop, and a resume with fresh auth are all this
* same call.
*/
override fun startChunkedUpload(options: ReadableMap, promise: Promise) {
try {
promise.resolve(enqueueChunkedUpload(options))
} catch (exc: Throwable) {
if (exc !is IllegalArgumentException) {
exc.printStackTrace()
Log.e(TAG, exc.message, exc)
}
promise.reject(exc)
}
}

@Suppress("unused")
private fun enqueueChunkedUpload(options: ReadableMap): String {
val store = ChunkedManifestStore.get(reactApplicationContext)
val id = options.getString("id")
Expand Down Expand Up @@ -346,6 +300,7 @@ class UploaderModule(context: ReactApplicationContext) :
return id
}

@Suppress("unused")
private fun takeOwnership(source: File, blob: File) {
if (!source.exists()) {
// A crash between the rename and the manifest save leaves the bytes at
Expand All @@ -359,84 +314,6 @@ class UploaderModule(context: ReactApplicationContext) :
// renameTo cannot cross filesystems. Files.move falls back to copy+delete.
Files.move(source.toPath(), blob.toPath(), StandardCopyOption.REPLACE_EXISTING)
}


/**
* Releases an upload's stored state. It cancels the scheduled or running
* work, then deletes the chunked manifest and the moved bytes. It is safe on
* any id. A simple upload has nothing stored, so the call reduces to the
* work cancel. There is deliberately no 'cancelled' event. This is an
* explicit release by the consumer, not an outcome that the consumer awaits.
*/
override fun removeUpload(id: String, promise: Promise) {
try {
workManager.cancelUniqueWork(id)
// No user-cancel mark was set, so a running worker's stop handler
// reports nothing. The consume call clears a stale mark from a prior
// life.
UserCancellations.consume(id)
ChunkedManifestStore.get(reactApplicationContext).remove(id)
promise.resolve(null)
} catch (exc: Throwable) {
exc.printStackTrace()
Log.e(TAG, exc.message, exc)
promise.reject(exc)
}
}


/*
* Cancels file upload
* Accepts upload ID as a first argument, this upload will be cancelled
* Event "cancelled" will be fired when upload is cancelled.
*/
override fun cancelUpload(id: String, promise: Promise) {
try {
val activeStates = workManager.getWorkInfosForUniqueWork(id).get()
.map { it.state }
.filter { !it.isFinished }

if (activeStates.isEmpty()) {
// Nothing to cancel. Drop any mark so a later upload reusing this id
// can't be misreported as a user cancel.
UserCancellations.consume(id)
promise.resolve(false)

return
}

// Record the intent BEFORE the cancel. Then a running worker's stop
// handler can tell this apart from a system stop, and it reports
// cancelReason 'user'.
UserCancellations.mark(id)
workManager.cancelUniqueWork(id)

if (cancelReportsFromModule(activeStates)) {
// No worker ever started: the rows are only ENQUEUED, or BLOCKED
// behind an appended chain. Thus no stop handler will ever run, and
// nothing else would ever report this cancellation. A consumer would
// then await this upload's outcome forever. Report it here instead,
// and consume the mark so it cannot leak.
UserCancellations.consume(id)
EventReporter.journalAndEmit(
reactApplicationContext,
EventJournal.Entry(
eventId = UUID.randomUUID().toString(),
uploadId = id,
type = "cancelled",
timestamp = System.currentTimeMillis(),
cancelReason = "user",
),
)
}

promise.resolve(true)
} catch (exc: Throwable) {
exc.printStackTrace()
Log.e(TAG, exc.message, exc)
promise.reject(exc)
}
}
}

/**
Expand Down Expand Up @@ -479,14 +356,6 @@ internal fun cancelReportsFromModule(unfinishedStates: List<WorkInfo.State>): Bo
internal fun hasQueuedSuccessor(states: List<WorkInfo.State>): Boolean =
states.any { !it.isFinished && it != WorkInfo.State.RUNNING }

// The aggregate byte fields for a chunked upload's snapshot. bytesSent counts
// accepted parts only. That is the durable number, and it has meaning even in
// a process where the worker does not run.
private fun WritableMap.putChunkedBytes(manifest: ChunkedManifest) {
putDouble("bytesSent", manifest.acceptedBytes.toDouble())
putDouble("totalBytes", manifest.totalBytes.toDouble())
}

/**
* One state for a chunked upload id, from all its WorkInfo rows plus the
* durable manifest. A live row wins. With no live row, the manifest speaks.
Expand Down
Loading
Loading