From f442374e5137c0d3bffbcc5805bbf0dd16704d64 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Tue, 8 Sep 2026 18:16:36 -0400 Subject: [PATCH 01/22] v10 slice 1: define()/mutate() JS layer, codegen spec, native stubs The public surface becomes a durable mutation registry modeled on TanStack Query's setMutationDefaults + mutate. A consumer calls define() once per request kind at boot, with a request builder, an optional response parser, and onSuccess/onError handlers. Call sites pass only variables to mutate(). The native queue owns durability, retry, and delivery. JS: src/registry.ts (define, mutate, descriptor validation, header merge, vars cap, ids), src/delivery.ts (journal replay after configure(), eventId dedupe, wait-for-mutate ordering, handler routing, ack after the handler's promise, 30 s warning, unhandled-key reporting), src/index.ts (createUploadClient), src/types.ts. 113 tests plus type tests. Codegen spec: enqueue, pause, resume, cancel, setWifiOnly, updateHeaders, synchronous getRequests, onState/onProgress/onAttempt/onSettled emitters. Removed: startUpload, startChunkedUpload, cancelUpload, removeUpload, getAllUploads, and the v9 per-outcome emitters. Native: both modules stub the new methods with E_NOT_IMPLEMENTED so the package compiles and CI passes alone. The v9 engines stay in place for the Android and iOS slices to wire up. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 70 ++ README.md | 473 ++++++------- .../backgroundupload/UploaderModule.kt | 215 ++---- ios/RNFileUploader.mm | 82 ++- package.json | 1 + src/NativeRNFileUploader.ts | 71 +- src/__tests__/client.test.ts | 520 +++++++++++++++ src/__tests__/delivery.test.ts | 627 ++++++++++++++++++ src/__tests__/index.test.ts | 260 -------- src/__tests__/registry.test.ts | 488 ++++++++++++++ src/__typetests__/define.ts | 230 +++++++ src/delivery.ts | 260 ++++++++ src/index.ts | 354 +++++----- src/registry.ts | 371 +++++++++++ src/types.ts | 400 ++++++----- 15 files changed, 3352 insertions(+), 1070 deletions(-) create mode 100644 src/__tests__/client.test.ts create mode 100644 src/__tests__/delivery.test.ts delete mode 100644 src/__tests__/index.test.ts create mode 100644 src/__tests__/registry.test.ts create mode 100644 src/__typetests__/define.ts create mode 100644 src/delivery.ts create mode 100644 src/registry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index efbc0e92..e128ab1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,73 @@ +## 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; 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`, and `RawUploadOptions` + types. + ## 9.0.1 Fixed: diff --git a/README.md b/README.md index 2947d31e..cae49820 100644 --- a/README.md +++ b/README.md @@ -51,248 +51,266 @@ generated header is Objective-C++ only — so the handler lives on `RNBackground # Usage -```js -import Upload from 'react-native-background-upload'; - -// Optional. Call one time at app startup to set the Android notification text. -// The library keeps the text in native storage. Thus a worker relaunched with -// no JS shows the same text. If you do not call configure(), the library uses -// default text and makes its own channel. The call does nothing on iOS. -Upload.configure({ android: { notificationTitle: 'Uploading…' } }); - -// Listeners are global. Every event carries the upload's id. -Upload.addListener('progress', ({ id, progress }) => {}); -// responseCode/responseBody are set for simple uploads only. A chunked -// 'completed' carries neither, because no single response represents N parts. -Upload.addListener('completed', ({ id, responseCode, responseBody }) => {}); -Upload.addListener('error', ({ id, error, errorKind, responseCode }) => {}); -Upload.addListener('cancelled', ({ id, cancelReason }) => {}); - -const uploadId = await Upload.startUpload({ - type: 'raw', - url: 'https://myservice.com/path/to/post', - path: 'file://path/to/file/on/device', - method: 'POST', - headers: { 'content-type': 'application/octet-stream' }, - // Optional. Non-2xx responses to treat as success (for example, an - // idempotent create that conflicts). Each other non-2xx response is an - // 'error' with errorKind 'http'. - accept: [{ status: 409, bodyIncludes: 'already completed' }], +The library owns a durable queue of HTTP requests: JSON bodies, multipart +forms, whole files, and chunked files. You describe each request kind one +time with `define()`, enqueue instances with `mutate()`, and receive every +outcome through the definition's handlers. Outcomes survive app death, +because the native side journals them before it tells JS. + +```ts +import { createUploadClient } from 'react-native-background-upload'; + +export const uploads = createUploadClient(); + +// One definition per request kind. `request` runs one time, at mutate(). +// `vars` must be JSON and at most 4 KB; native persists them next to the entry. +// Declare the vars as a `type` alias: an `interface` fails the Json constraint. +type AddCommentVars = { siteId: string; noteId: string; comment: string }; +export const addComment = uploads.define({ + key: 'note.comment.add', // persisted with every entry; rename with care + request: ({ siteId, noteId, comment }: AddCommentVars) => ({ + url: `https://api.example.com/sites/${siteId}/notes/${noteId}/comments`, + data: { comment }, // JSON body. Default method is POST. + }), + // Parses the JSON body before onSuccess. Zod users pass schema.parse. + response: (raw) => (raw as { content: Comment[] }).content, + onSuccess: (content, { noteId }, meta) => { + // Runs after the server accepted the request, possibly on a later launch. + store.dispatch(commentsLoaded({ noteId, content })); + }, + onError: (error, vars, meta) => { + // error.errorKind: 'http' | 'network' | 'file' | 'expired' | 'truncated' | 'unknown' + }, }); -``` -## Chunked uploads - -A `type: 'chunked'` upload sends one file as many part requests but stays one -logical upload: one id, one event stream, byte-weighted `progress`, and -`completed` only when every part has been accepted. You author the parts — URL, -headers, byte range — once, at creation; the library owns the transport and -never constructs or edits a protocol field. Author the ranges with `chunkPlan` -so the part count you tell your server and the parts the library sends derive -from the same array: - -```js -const size = (await stat(path)).size; -const ranges = Upload.chunkPlan(size, { min: 8 * 2 ** 20, max: 20 * 2 ** 20 }); -// Tell your server ranges.length parts, then: -await Upload.startUpload({ - type: 'chunked', - id: myDurableId, // required - path, // see file ownership below - parts: ranges.map((range, i) => ({ - url: partUrl(i + 1), - headers: { - Authorization: token, - 'Content-Type': 'application/octet-stream', - 'Content-Range': `bytes ${range.start}-${range.end - 1}/${size}`, - }, - range, // bytes, end exclusive - })), - accept: [{ status: 409, bodyIncludes: 'already completed' }], - expiresAt: Date.now() + 14 * 24 * 60 * 60 * 1000, // required, epoch ms +// At boot, after every define() call. Replay of journaled outcomes starts here. +uploads.configure({ + headers: () => ({ Authorization: `Bearer ${currentToken()}` }), + android: { notificationTitle: 'Uploading', notificationChannel: 'uploads' }, }); + +// Anywhere. Resolves when the entry is durable, never on the network. +const { id } = await addComment.mutate( + { siteId, noteId, comment }, + { id: localCommentId }, // optional; makes a re-dispatch idempotent +); ``` -**File ownership.** The library takes the file: an O(1) rename into its own -directory at `startUpload`. Nothing your app does afterward (cache sweeps, -logout cleanup) can destroy the bytes mid-upload. The file is deleted in -exactly one case — a `completed` event has been acknowledged via `ackEvents`. -Copy the file first if you need it afterward. - -**Resume is re-calling `startUpload`.** The parts are persisted in a native -manifest, so crash recovery, resume after `cancelUpload`, resume after expiry, -and refreshing auth headers are all the same call: `startUpload` again with the -same id and the same part ranges/URLs. Parts already accepted are skipped; the -rest continue with the new call's headers and `expiresAt` (this is how a fresh -token reaches parts that stalled on 401). Once a manifest exists, `path` is -ignored — the library's owned bytes are the source of truth. - -**Recreate is the same call with different parts.** When the old server upload -is dead (for example, swept server-side), author fresh part URLs and call -`startUpload` with the same id and the new parts array. The owned bytes are -kept, the parts are replaced, and every part resets to unsent; the new ranges -must tile the same total size. A recreate is accepted only while the upload is -not running — stalled on a terminal error, expired, or cancelled. While it is -running, a differing parts array is rejected: that is a consumer bug, not a -recreate. - -**Lifetime.** Within `expiresAt`, transient failures (network, 5xx) retry on -exponential backoff with no attempt cap. Past it, the library journals an -`error` with `errorKind: 'expired'` and stops — keeping the manifest and bytes, -so you can resume the same server upload with a later `expiresAt`, or recreate -under a new one. When neither is wanted, release them with `removeUpload`. - -Choosing a value: `expiresAt` is when your app *hears about* a stuck upload, -not when data is lost — bytes survive expiry. Pick something well inside your -backend's own cleanup horizon so expiry fires while the server upload is still -resumable, and generous enough for real offline stretches. The OpenSpace -backend prunes incomplete multipart uploads 31 days after creation -(`UploadPartCleanup`); Diana passes 14 days, leaving a 17-day window where an -expired upload can still resume the same server uploadId. +Handlers must be idempotent. The library acknowledges an outcome only after +the handler's promise resolves, so a crash before that point redelivers the +outcome at the next launch. -# Reliable delivery +## The request descriptor -Terminal events (`completed` / `error` / `cancelled`) are journaled natively -*before* they are emitted, so they survive app death, JS reloads, and background -relaunches. Events stay in the journal until you acknowledge them. Drain it on -every app start: - -```js -const events = await Upload.getUnacknowledgedEvents(); -for (const e of events) { - // e: { eventId, id, type, timestamp, responseCode?, responseBody?, - // responseHeaders?, error?, errorKind?, cancelReason? } - handleOutcome(e); -} -await Upload.ackEvents(events.map((e) => e.eventId)); +`request(vars)` returns a plain object. Exactly one body kind is required. +A field outside this table makes `mutate()` reject and name the field, because +TypeScript does not flag a misspelled key on an inferred arrow return. -// Then reconcile anything still in flight: -const live = await Upload.getAllUploads(); // [{ id, state, ... }] +| Field | Notes | +| --- | --- | +| `url` | Required unless `parts` is set. | +| `method` | `POST` (default), `PUT`, `PATCH`, `DELETE`, `GET`. With `parts` it applies to every part. | +| `headers` | Merged over `configure().headers()`. Every chunked part inherits the result. | +| `data` | JSON body. | +| `form` | `multipart/form-data`: `[{ name, contentType, string }]` or `[{ name, contentType, path, fileName? }]`. File parts are copied. | +| `file` | Whole file body. Copied. Moved when `parts` is set. | +| `parts` | Chunked over `file`: `[{ url, headers?, range: { start, end } }]`, bytes, end exclusive, tiling the file from 0. | +| `accept` | Non-2xx responses to treat as success: `[{ status, bodyIncludes? }]`. | +| `expiresAt` | Epoch ms. Default now + `lifetimeMs` (14 days). Past it: `error` with `errorKind: 'expired'`. | +| `retry` | Per-request override of the `configure()` retry defaults. | +| `android` | `{ noNotification?: boolean }`. See Silent uploads. | + +### Chunked uploads + +A descriptor with `file` and `parts` sends one file as many part requests but +stays one entry: one id, byte-weighted `progress`, and `completed` only when +every part is accepted. Author the ranges with `chunkPlan` so the part count +you tell your server and the parts the library sends derive from one array. + +```ts +type CaptureFileVars = { path: string; size: number; uploadId: string }; + +const captureFile = uploads.define({ + key: 'capture.file', + request: ({ path, size, uploadId }: CaptureFileVars) => { + const ranges = uploads.chunkPlan(size, { min: 8 * 2 ** 20, max: 20 * 2 ** 20 }); + return { + method: 'PUT', + file: path, // moved into the library directory + // Derive each part URL from its index. The part count you tell the + // server and the parts sent here then come from the one chunkPlan call. + parts: ranges.map((range, i) => ({ + url: partUrl(uploadId, i + 1), // part numbers are 1-indexed + headers: { 'Content-Range': `${range.start}-${range.end - 1}/${size}` }, + range, + })), + accept: [{ status: 409, bodyIncludes: 'already completed' }], + // A 404 on a part means the server-side multipart is gone. Make it + // terminal so onError can recreate under a fresh server upload id. + retry: { terminalHttp: { exempt: [] } }, + }; + }, + onError: (error, vars) => { /* recreate: mutate() again with new parts */ }, +}); ``` -Notes: -- **`completed` fires only for 2xx** (or a response matching the request's - `accept` rules). Every other HTTP response is an `error` with - `errorKind: 'http'` and the response attached — a 400 is an error, not a - completion. -- `errorKind` is `'http' | 'network' | 'file' | 'expired' | 'unknown'`. Retry - transport failures; treat client errors as terminal; `expired` means a chunked - upload's `expiresAt` passed (see Chunked uploads for recovery). -- `cancelReason` distinguishes a user cancel (`'user'`) from a system kill - (`'system'`). -- Duplicate journal entries for one upload id are possible if the process dies at - the wrong moment (Android may re-run the worker) — dedupe by `id`, keep latest. -- Android: `getAllUploads()` reflects only live/recent work (WorkManager prunes - finished work after ~a day). The journal is the source of truth for outcomes. +**File ownership.** A chunked `file` is moved into the library's directory at +`mutate()`; a single `file` body and every `form` part path are copied. Bytes +are deleted after a `completed` outcome is acknowledged, or on `cancel()`. +Nothing else deletes them. + +**Same id, again.** `mutate()` with an id that exists follows the v9 rules. +Same parts or body: resume; new headers and `expiresAt` replace the stored +ones, and a settled entry reopens and settles once more. Settled entry with +different parts: recreate over the same bytes (the new parts must tile the +same size). Running entry with different parts: reject. + +### Silent uploads (Android) + +`android: { noNotification: true }` runs the request without a progress +notification. That notification is also the worker's foreground-service +notification, so a silent request runs as an ordinary background worker and +the OS may defer or restart it. Reserve it for small payloads. + +# Reliable delivery + +1. **Write-ahead.** Entry, descriptor, and staged body persist before any + attempt. `mutate()` resolves when the write lands. +2. **Journal before emit, ack after the handler.** Every terminal outcome is + journaled natively, then delivered. The library acknowledges after the + handler's promise resolves. A rejection, or app death before the ack, + redelivers at the next launch. A handler that has not settled after 30 s + gets a console warning and keeps waiting. +3. **One outcome per settle cycle.** `pause()` produces none. A same-id + `mutate()` on a settled entry reopens it, and it settles once more. +4. **Never before `mutate()` resolves.** Delivery for an id waits for the + caller's promise. +5. **Replay starts after `configure()`.** Outcomes journaled by a dead session + deliver then. Call every `define()` first. +6. **Unknown key is loud.** An outcome whose key has no definition stays + unacknowledged and reaches `state` listeners with `reason: 'unhandled-key'`. +7. **Completed entries are forgotten after ack.** Row and bytes go. An `error` + or `expired` entry keeps both until `cancel()` or a same-id `mutate()`. + +Retry classes: + +| Attempt outcome | Action | +| --- | --- | +| 2xx, or an `accept` rule matches | settle `completed` | +| network failure, 5xx, 408, 429 | exponential backoff (base 1 s, max 2 h, jitter 0.2) until `expiresAt` | +| 401, 403 | park as `awaiting-auth`; resume on `updateHeaders()` | +| other 4xx not in `retry.terminalHttp.exempt` | settle `error` with `errorKind: 'http'` | +| 4xx in `exempt` (default `[404]`) | as transient | +| payload missing on disk | settle `error` with `errorKind: 'file'` | +| `expiresAt` passed | settle `error` with `errorKind: 'expired'`; bytes kept | +| response body over 1 MB with a `response` parser | `onError` with `errorKind: 'truncated'`; the entry is `completed` | + +Every attempt sends an `X-Request-Id` header, minted per attempt. `Meta.requestId` +carries the last one. # API -All methods are on the default export. +`createUploadClient()` returns a client. The default export is one client; +an app needs one. + +### `define(definition): { key, mutate }` + +```ts +type Definition = + | { + key: string; + request: (vars: V) => RequestDescriptor; + response: (raw: unknown) => T; // JSON-parsed body, or undefined when there is none + onSuccess?: (data: T, vars: V, meta: Meta) => void | Promise; + onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise; + } + | { + key: string; + request: (vars: V) => RequestDescriptor; + response?: undefined; + onSuccess?: (data: RawResponse, vars: V, meta: Meta) => void | Promise; + onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise; + }; +``` -### `configure(options): void` -One-time setup — call at app startup. `options.android` sets the upload -notification's text and identity: -`notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`. The config is -persisted natively, so a worker relaunched by WorkManager with no JS running -shows the same text. Optional: omitted fields keep the library defaults (each -call replaces the whole config). A no-op on iOS, which has no library -notification. - -### `startUpload(options): Promise` -Starts an upload; resolves to its id. Discriminated on `options.type`: `'raw'` -sends the whole file as one request body, `'chunked'` sends the authored parts -(see Chunked uploads). Rejects (or, for malformed chunked input, throws -synchronously) only on bad options — transport failures and HTTP error responses -arrive later as `error` events, not a rejection. - -**Idempotent for every upload, always.** Calling `startUpload` again with an id -that is already pending or running is never an error: a raw upload resolves with -the same id instead of starting a duplicate; a chunked upload reconciles — parts -already accepted are skipped, the rest continue with the new call's headers. No -pre-dispatch dedupe is needed on your side. - -Options for `type: 'raw'`: - -| Option | Type | Notes | -| --- | --- | --- | -| `url` | string | Required. | -| `path` | string | Required. Local file path (`file://…`). URIs are not escaped for you. | -| `method` | string | Default `POST`. | -| `headers` | object | HTTP headers. | -| `id` | string | Defaults to a generated UUID. | -| `wifiOnly` | boolean | Wait for wifi before/while uploading. | -| `accept` | AcceptRule[] | Non-2xx responses to treat as success — see Accept rules. | -| `android` | object | Optional. `noNotification` (default false) — see Silent uploads. Notification text is set once via `configure()`, not per upload. | - -Options for `type: 'chunked'`: - -| Option | Type | Notes | -| --- | --- | --- | -| `id` | string | Required — your durable id. | -| `path` | string | Required. The library takes ownership of the file — see Chunked uploads. | -| `parts` | array | Required. `{ url, headers, range: { start, end } }` per part; ranges in bytes, end exclusive. Sent verbatim as PUTs. | -| `expiresAt` | number | Required, epoch ms. Past it: terminal `error` with `errorKind: 'expired'`. | -| `accept` | AcceptRule[] | See Accept rules. | -| `wifiOnly` | boolean | Wait for wifi before/while uploading. | -| `android` | object | Same as raw. | - -#### Accept rules - -`accept: Array<{ status: number, bodyIncludes?: string }>` — non-2xx responses -to treat as success, for both upload types. `bodyIncludes` narrows a rule by -response-body substring, for servers where one status carries several meanings -distinguishable only by message. A response matching a rule completes the -request (for chunked, marks the part accepted); any other non-2xx is an `error` -with `errorKind: 'http'`. - -#### Silent uploads (Android) - -`android: { noNotification: true }` uploads a file without posting a progress -notification, so the shade only shows the uploads a user actually asked to watch. - -That notification is also the worker's foreground-service notification, so a -silent upload runs as an ordinary background worker instead. The OS is then free -to defer it, or to stop it mid-flight and let WorkManager re-run it later. Keep -the notification for anything that takes real time to upload; reserve -`noNotification` for small payloads a restart would cost nothing. - -All uploads share one notification (identified by the configured -`notificationId`), and its progress bar reports every in-flight upload — silent -ones included. - -### `cancelUpload(uploadId): Promise` -Cancels an upload. Fires a `cancelled` event with `cancelReason: 'user'`. For a -chunked upload this cancels in-flight requests but keeps the manifest and bytes — -the next `startUpload` with the same id resumes it (there is no separate pause -API). - -### `removeUpload(uploadId): Promise` -Releases an upload's native manifest and bytes. Every terminal outcome other -than an acked `completed` (expired, error, cancelled) keeps both so you can -resume or recreate; call this once neither is wanted. +`V` infers from the `request` parameter annotation, `T` from the `response` +return type. Without `response`, `onSuccess` receives the `RawResponse` +(`{ status?, headers?, body?, bodyTruncated }`), and an `onSuccess` annotated +with any other type is a compile error. `V` must be a `type` alias with +mutable arrays: an `interface` or a `readonly T[]` field fails the `Json` +constraint, and the compiler error names `null` rather than the cause. A +`request` that declares no parameter gives `V = null`, and `mutate()` then +takes no arguments. When `response` is set and +the body was truncated, `onError` gets `errorKind: 'truncated'`. When +`response` throws, `onError` gets `errorKind: 'unknown'` with the thrown +message; the entry still settles as completed. A key that is already defined +is replaced, with a warning in development. A `cancelled` outcome calls no +handler. + +`Meta` is `{ id, key, at, attempts, requestId? }`; `at` is the native outcome +time. + +### `mutate(vars, { id? }): Promise<{ id }>` + +Runs `request(vars)` once, merges `configure().headers()` under the +descriptor's headers, validates the descriptor, defaults `expiresAt`, and +persists the entry. Resolves with the id when the write lands. Rejects on a +malformed descriptor, an unknown descriptor field, a missing file, or `vars` +over 4 KB. Only `vars` are capped. `id` defaults to a UUID. For a definition +whose `request` takes no vars, call `mutate()` with no arguments; native stores +`null`. -### `chunkPlan(sizeBytes, { min?, max? }): Array<{ start, end }>` -Splits a byte count into contiguous, end-exclusive ranges: a deterministic -greedy walk of `max`-sized chunks (default 20MB), with a final remainder smaller -than `min` (default 8MB) absorbed into the previous chunk. A file smaller than -`min` is a single chunk. Pure and deterministic on purpose: call it once and -derive both your server's part count and the `parts` array from the same result, -so the two can never disagree. +### `configure(options): void` -### `addListener(eventType, listener): EventSubscription` -`addListener(event: 'progress' | 'error' | 'completed' | 'cancelled', callback)`. -Listeners are global — there is no per-upload subscription; every event carries -the upload's `id`, so discriminate on it. Call `.remove()` on the result to -unsubscribe. +Call one time at boot, after every `define()`. Starts replay of journaled +outcomes. A second call updates the settings and does not replay again. -### `getUnacknowledgedEvents(): Promise` -Terminal events not yet acknowledged, including ones that fired while JS was dead. +| Option | Notes | +| --- | --- | +| `lifetimeMs` | Default `expiresAt` distance. Default 14 days. | +| `retry` | `{ backoff?: { baseMs, maxMs, jitter }, terminalHttp?: { exempt } }`. Each of the two objects is optional, but one you give must be complete. Defaults 1 s, 2 h, 0.2, `[404]`. | +| `headers` | `() => Record`, called at `mutate()`. The descriptor merges over it. | +| `android` | Notification text and identity: `notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`. Persisted natively. | + +### `pause(): Promise` and `resume(): Promise` +Whole-queue pause. No outcome is produced; live rows show `paused`. + +### `cancel(id): Promise` +A live entry settles `cancelled` with reason `user` and is forgotten after +its ack. A settled entry is forgotten now, row and bytes. + +### `setWifiOnly(enabled): Promise` +Persisted natively. Applies to queued and future entries. + +### `updateHeaders(patch): Promise` +Merges the patch into every queued and parked entry's headers, then resumes +the entries parked on `awaiting-auth`. This is how a fresh token reaches +requests that stalled on 401. + +### `getRequests(filter?): RequestRow[]` +Synchronous. Live rows from native's in-memory index, so it works offline. +`filter` is `{ key?, id? }`. A row is +`{ id, key, vars, state, bytesSent, totalBytes, attempts, updatedAt }`, with +`state` one of `queued | running | awaiting-auth | paused | completed | error | cancelled`. +`vars` is typed `Json`, because a row does not know its definition. Narrow +it before reading a field, for example to cancel every entry of one capture: + +```ts +uploads + .getRequests({ key: 'capture.file' }) + .filter((row) => (row.vars as { captureId?: string }).captureId === captureId) + .forEach((row) => uploads.cancel(row.id)); +``` -### `ackEvents(eventIds: string[]): Promise` -Removes journaled events once processed. +### `chunkPlan(sizeBytes, { min?, max? }): Array<{ start, end }>` +Splits a byte count into contiguous, end-exclusive ranges: a deterministic +greedy walk of `max`-sized chunks (default 20 MB), with a final remainder +smaller than `min` (default 8 MB) absorbed into the previous chunk. A file +smaller than `min` is a single chunk. Also a module export. -### `getAllUploads(): Promise` -Uploads the OS still knows about, for boot-time reconciliation. +### `addListener(event, listener): EventSubscription` +See Events. Listeners are global; every event carries the entry's `id`. Call +`.remove()` on the result to unsubscribe. ### `android.addNotificationListener(listener)` Fires when the Android progress notification is pressed. No event data. @@ -301,10 +319,11 @@ Fires when the Android progress notification is pressed. No event data. | Event | Data | | --- | --- | -| `progress` | `{ id, progress: 0-100 }` | -| `completed` | `{ id, responseCode?, responseBody?, responseHeaders?, eventId? }` — response fields on simple uploads only; a chunked `completed` carries none (no single response represents N parts) | -| `error` | `{ id, error, errorKind?, partIndex?, responseCode?, responseBody?, responseHeaders? }` | -| `cancelled` | `{ id, cancelReason?: 'user' | 'system' }` | +| `state` | A full `RequestRow`, one per transition, plus `reason: 'unhandled-key'` for an outcome whose key has no definition. A consumer's reducer is one upsert. | +| `progress` | `{ id, bytesSent, totalBytes }`, byte-weighted across a chunked upload's parts. | +| `attempt` | One HTTP attempt before interpretation: `{ id, key, requestId, attempt, url, method, partIndex?, outcome, httpCode?, responseBody? (4 KB cap), responseBodyTruncated?, responseHeaders?, errorKind?, errorMessage?, cancelReason?, at }`. | + +Terminal outcomes do not appear here. They go to the definition's handlers. # Contributing diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index 206253d7..2c0ad81a 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -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 /** @@ -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 @@ -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) } @@ -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) @@ -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") @@ -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 @@ -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) - } - } } /** @@ -479,14 +356,6 @@ internal fun cancelReportsFromModule(unfinishedStates: List): Bo internal fun hasQueuedSuccessor(states: List): 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. diff --git a/ios/RNFileUploader.mm b/ios/RNFileUploader.mm index 3bf3c038..c0d0ae3b 100644 --- a/ios/RNFileUploader.mm +++ b/ios/RNFileUploader.mm @@ -66,38 +66,69 @@ + (NSString *)moduleName #pragma mark - Exported methods -// configure() carries the Android notification configuration. iOS background -// uploads have no library-owned notification. Thus there is nothing to save. +// v10 slice 1 ships the JS layer alone. Every queue method rejects with this +// code until slice 3 builds the iOS queue and executor. +static NSString *const kNotImplemented = @"E_NOT_IMPLEMENTED"; + +static void RejectNotImplemented(RCTPromiseRejectBlock reject, NSString *method) +{ + reject(kNotImplemented, + [NSString stringWithFormat:@"RNFileUploader.%@: the iOS queue is not built yet", method], + nil); +} + +// configure() carries { lifetimeMs, retry, ...androidNotificationConfig }. iOS +// background uploads have no library-owned notification, and slice 3 persists +// the queue settings. Thus there is nothing to save yet. - (void)configure:(NSDictionary *)options { } -- (void)startUpload:(NSDictionary *)options - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject +- (void)enqueue:(NSDictionary *)entry + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + RejectNotImplemented(reject, @"enqueue"); +} + +- (void)pause:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + RejectNotImplemented(reject, @"pause"); +} + +- (void)resume:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [RNBackgroundUpload.shared startUpload:options resolve:resolve reject:reject]; + RejectNotImplemented(reject, @"resume"); } -- (void)startChunkedUpload:(NSDictionary *)options - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject +- (void)cancel:(NSString *)id + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [RNBackgroundUpload.shared startChunkedUpload:options resolve:resolve reject:reject]; + RejectNotImplemented(reject, @"cancel"); } -- (void)cancelUpload:(NSString *)id - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject +- (void)setWifiOnly:(BOOL)enabled + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [RNBackgroundUpload.shared cancelUpload:id resolve:resolve reject:reject]; + RejectNotImplemented(reject, @"setWifiOnly"); } -- (void)removeUpload:(NSString *)id - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject +- (void)updateHeaders:(NSDictionary *)patch + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [RNBackgroundUpload.shared removeUpload:id resolve:resolve reject:reject]; + RejectNotImplemented(reject, @"updateHeaders"); +} + +// Synchronous. The live rows of the v10 queue. Slice 3 serializes them from +// the in-memory index; until then the queue is empty. +- (NSArray *)getRequests +{ + return @[]; } - (void)getUnacknowledgedEvents:(RCTPromiseResolveBlock)resolve @@ -113,12 +144,6 @@ - (void)ackEvents:(NSArray *)ids [RNBackgroundUpload.shared ackEvents:ids resolve:resolve reject:reject]; } -- (void)getAllUploads:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject -{ - [RNBackgroundUpload.shared getAllUploads:resolve reject:reject]; -} - #pragma mark - RNFileUploaderEventDelegate // Called synchronously on the URLSession delegate queue. That is safe and @@ -142,24 +167,25 @@ - (void)safeEmit:(void (^)(RNFileUploader *emitter))block } } +// The v9 Swift engine still reports through the delegate. The v10 spec has no +// per-outcome emitters and a different progress shape ({ id, bytesSent, +// totalBytes }), so until slice 3 rewires the engine to onState/onProgress/ +// onSettled, the live v9 payloads are dropped here. Terminal outcomes are +// journaled first, so nothing durable is lost. - (void)emitProgress:(NSDictionary *)body { - [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnProgress:body]; }]; } - (void)emitCompleted:(NSDictionary *)body { - [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnCompleted:body]; }]; } - (void)emitError:(NSDictionary *)body { - [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnError:body]; }]; } - (void)emitCancelled:(NSDictionary *)body { - [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnCancelled:body]; }]; } @end diff --git a/package.json b/package.json index 2a2b3cd6..3306407f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "files": [ "src", "!src/__tests__", + "!src/__typetests__", "android", "!android/build", "!android/.gradle", diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts index ff35ac28..07de5bdc 100644 --- a/src/NativeRNFileUploader.ts +++ b/src/NativeRNFileUploader.ts @@ -2,42 +2,57 @@ import { type CodegenTypes, type TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; // Codegen TurboModule spec (New Architecture). The typed public API lives in -// ./types and is applied at the JS edge in ./index; here the dynamic-shaped -// payloads (the options dict, journaled events, upload snapshots, and the -// terminal event payloads that carry header maps + optional fields) are declared -// as UnsafeObject because codegen can't model index signatures, Partial<>, or -// intersections. index.ts casts them back to the precise ./types shapes. +// ./types and is applied at the JS edge in ./index, ./registry and ./delivery. +// The dynamic-shaped payloads (the entry, queue rows, settled events, attempt +// events) are declared as UnsafeObject because codegen can't model index +// signatures, Partial<>, or unions. The JS layer casts them back to the +// precise ./types shapes. export interface Spec extends TurboModule { - // One-time notification configuration. Android persists it, so a headless - // WorkManager relaunch (no JS) can read it. It does nothing on iOS, because - // iOS has no library notification. + // Queue-wide settings: { lifetimeMs, retry, ...androidNotificationConfig }. + // Android persists the notification config, so a headless WorkManager + // relaunch (no JS) can read it. Each call replaces the full configuration. configure(options: CodegenTypes.UnsafeObject): void; - startUpload(options: CodegenTypes.UnsafeObject): Promise; - // Chunked uploads get their own entry point for two reasons. Codegen cannot - // model the raw/chunked discriminated union. And the native implementations - // share no parsing: startUpload dispatches one request, while - // startChunkedUpload creates or reconciles a durable part manifest. index.ts - // keeps the single public startUpload and routes on options.type. - startChunkedUpload(options: CodegenTypes.UnsafeObject): Promise; - cancelUpload(id: string): Promise; - // Releases the manifest and the bytes of a non-completed upload. A completed - // upload releases itself when you acknowledge its terminal event. - removeUpload(id: string): Promise; + // Persists { id, key, vars, descriptor } and schedules it. Resolves with the + // entry's id once the write has landed, never on the network. A same-id call + // follows the v9 resume rules: same body resumes, different parts on a + // settled entry recreate, different parts on a running entry reject. + enqueue(entry: CodegenTypes.UnsafeObject): Promise; + // Whole-queue pause. No outcome is produced; live rows move to 'paused'. + pause(): Promise; + resume(): Promise; + // A live entry settles 'cancelled' (user) and is forgotten after its ack. A + // settled entry is forgotten now, row and bytes. + cancel(id: string): Promise; + // Persisted natively. Applies to queued and future entries. + setWifiOnly(enabled: boolean): Promise; + // Merges the patch into every queued and parked entry's headers, then + // resumes the entries parked on 'awaiting-auth'. + updateHeaders(patch: CodegenTypes.UnsafeObject): Promise; + // Synchronous. Serialized from the in-memory index that native keeps + // current on every state change, never from disk. Live entries only. + getRequests(): CodegenTypes.UnsafeObject[]; + // Settled outcomes that JS has not acknowledged, in the onSettled shape, + // including ones journaled while JS was dead. Internal: ./delivery drains + // them after configure(). getUnacknowledgedEvents(): Promise; + // Removes journaled outcomes by eventId. An acknowledged 'completed' is the + // one moment native deletes the entry's row and bytes. ackEvents(ids: string[]): Promise; - getAllUploads(): Promise; - // Events. progress fires on both platforms with a fixed shape; the terminal - // events carry variant payloads (header maps, optional fields) so they're - // UnsafeObject. notification is Android-only (tapping the progress - // notification) and simply never fires on iOS. + // Events. state carries a full RequestRow per transition. progress is + // byte-weighted across a chunked upload's parts. attempt is one HTTP attempt + // before interpretation. settled is the journaled terminal outcome, emitted + // after the journal write; ./delivery routes it to the definition's + // handlers. notification is Android-only (tapping the progress notification) + // and never fires on iOS. + readonly onState: CodegenTypes.EventEmitter; readonly onProgress: CodegenTypes.EventEmitter<{ id: string; - progress: number; + bytesSent: number; + totalBytes: number; }>; - readonly onError: CodegenTypes.EventEmitter; - readonly onCancelled: CodegenTypes.EventEmitter; - readonly onCompleted: CodegenTypes.EventEmitter; + readonly onAttempt: CodegenTypes.EventEmitter; + readonly onSettled: CodegenTypes.EventEmitter; readonly onNotification: CodegenTypes.EventEmitter; } diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts new file mode 100644 index 00000000..d5e828f8 --- /dev/null +++ b/src/__tests__/client.test.ts @@ -0,0 +1,520 @@ +// Define all mocks inside the factory (no outer references) to avoid the +// import-hoisting TDZ trap, then grab handles from the mocked module below. +// The library reaches native through TurboModuleRegistry.getEnforcing, so that +// is what has to be stubbed. The codegen event emitters are plain functions +// that take a handler and return a subscription; the mock records the handlers +// so a test can fire an event. remove() drops that one subscription, as RN's +// EventEmitter does, so the same handler registered twice stays once. +jest.mock('react-native', () => { + const handlers: Record void>> = {}; + const emitter = (name: string) => + jest.fn((handler: (e: unknown) => void) => { + const entry = (e: unknown) => handler(e); + (handlers[name] ??= []).push(entry); + return { + remove: jest.fn(() => { + handlers[name] = handlers[name]!.filter((h) => h !== entry); + }), + }; + }); + const nativeModule = { + configure: jest.fn(), + enqueue: jest.fn(async (entry: { id: string }) => entry.id), + pause: jest.fn(async () => undefined), + resume: jest.fn(async () => undefined), + cancel: jest.fn(async () => undefined), + setWifiOnly: jest.fn(async () => undefined), + updateHeaders: jest.fn(async () => undefined), + getRequests: jest.fn(() => []), + getUnacknowledgedEvents: jest.fn(async () => []), + ackEvents: jest.fn(async () => true), + onState: emitter('state'), + onProgress: emitter('progress'), + onAttempt: emitter('attempt'), + onSettled: emitter('settled'), + onNotification: emitter('notification'), + __handlers: handlers, + }; + return { + Platform: { OS: 'ios' }, + TurboModuleRegistry: { + getEnforcing: jest.fn(() => nativeModule), + get: jest.fn(() => nativeModule), + }, + }; +}); + +import { TurboModuleRegistry } from 'react-native'; +import Upload, { chunkPlan, createUploadClient } from '../index'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Same object the module captured at import time. +const native = (TurboModuleRegistry as any).getEnforcing('RNFileUploader'); +const fire = (name: string, event: unknown) => + (native.__handlers[name] ?? []).forEach((h: (e: unknown) => void) => + h(event), + ); + +const flush = async (rounds = 5): Promise => { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + +const rows = [ + { + id: 'a', + key: 'k1', + vars: null, + state: 'queued', + bytesSent: 0, + totalBytes: 0, + attempts: 0, + updatedAt: 1, + }, + { + id: 'b', + key: 'k2', + vars: null, + state: 'running', + bytesSent: 1, + totalBytes: 2, + attempts: 1, + updatedAt: 2, + }, + { + id: 'c', + key: 'k1', + vars: null, + state: 'error', + bytesSent: 0, + totalBytes: 0, + attempts: 3, + updatedAt: 3, + }, +]; + +beforeEach(() => { + jest.clearAllMocks(); + Object.keys(native.__handlers).forEach((k) => delete native.__handlers[k]); +}); + +describe('client shape', () => { + it('exposes the v10 surface and nothing from v9', () => { + const client = createUploadClient(); + expect(Object.keys(client).sort()).toEqual( + [ + 'addListener', + 'android', + 'cancel', + 'chunkPlan', + 'configure', + 'define', + 'getRequests', + 'pause', + 'resume', + 'setWifiOnly', + 'updateHeaders', + ].sort(), + ); + expect(Object.keys(client.android)).toEqual(['addNotificationListener']); + expect(client.chunkPlan).toBe(chunkPlan); + }); + + it('exports a default client with the same shape', () => { + expect(Object.keys(Upload).sort()).toEqual( + Object.keys(createUploadClient()).sort(), + ); + }); + + it("keeps each client's definitions separate", async () => { + const a = createUploadClient(); + const b = createUploadClient(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + a.define({ + key: 'shared', + request: (_v: null) => ({ url: 'https://x', data: 1 }), + }); + b.define({ + key: 'shared', + request: (_v: null) => ({ url: 'https://x', data: 1 }), + }); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('configure', () => { + it('forwards lifetimeMs, retry and the flattened android config', () => { + const client = createUploadClient(); + const retry = { terminalHttp: { exempt: [] } }; + client.configure({ + lifetimeMs: 1000, + retry, + headers: () => ({}), + android: { notificationTitle: 'Backing up', notificationChannel: 'ch' }, + }); + expect(native.configure).toHaveBeenCalledWith({ + lifetimeMs: 1000, + retry, + notificationTitle: 'Backing up', + notificationChannel: 'ch', + }); + }); + + it('sends the 14 day default lifetime and no retry when neither is given', () => { + createUploadClient().configure({}); + expect(native.configure).toHaveBeenCalledWith({ + lifetimeMs: 14 * 24 * 60 * 60 * 1000, + }); + expect(native.configure.mock.calls[0][0]).not.toHaveProperty('retry'); + }); + + it('rejects a non-positive lifetime', () => { + expect(() => createUploadClient().configure({ lifetimeMs: 0 })).toThrow( + /lifetimeMs/, + ); + expect(() => createUploadClient().configure({ lifetimeMs: NaN })).toThrow( + /lifetimeMs/, + ); + }); + + it('starts replay once; a second call updates settings without replaying', async () => { + const client = createUploadClient(); + client.configure({}); + client.configure({ lifetimeMs: 5 }); + await flush(); + expect(native.getUnacknowledgedEvents).toHaveBeenCalledTimes(1); + expect(native.onSettled).toHaveBeenCalledTimes(1); + expect(native.configure).toHaveBeenCalledTimes(2); + }); + + it('does not touch the journal before configure()', async () => { + createUploadClient(); + await flush(); + expect(native.getUnacknowledgedEvents).not.toHaveBeenCalled(); + expect(native.onSettled).not.toHaveBeenCalled(); + }); + + it('applies the headers provider and lifetime to later mutates', async () => { + const client = createUploadClient(); + const send = client.define({ + key: 'k', + request: (_v: null) => ({ + url: 'https://x', + data: 1, + headers: { B: '2' }, + }), + }); + client.configure({ lifetimeMs: 1000, headers: () => ({ A: '1' }) }); + const before = Date.now(); + await send.mutate(null); + const entry = native.enqueue.mock.calls.at(-1)![0]; + expect(entry.descriptor.headers).toEqual({ A: '1', B: '2' }); + expect(entry.descriptor.expiresAt).toBeGreaterThanOrEqual(before + 1000); + expect(entry.descriptor.expiresAt).toBeLessThan(before + 1000 + 5000); + }); +}); + +describe('queue control forwards', () => { + const client = createUploadClient(); + + it('pause', async () => { + await client.pause(); + expect(native.pause).toHaveBeenCalledTimes(1); + }); + + it('resume', async () => { + await client.resume(); + expect(native.resume).toHaveBeenCalledTimes(1); + }); + + it('cancel', async () => { + await client.cancel('u1'); + expect(native.cancel).toHaveBeenCalledWith('u1'); + }); + + it('setWifiOnly', async () => { + await client.setWifiOnly(true); + expect(native.setWifiOnly).toHaveBeenCalledWith(true); + }); + + it('updateHeaders', async () => { + await client.updateHeaders({ Authorization: 'Bearer t' }); + expect(native.updateHeaders).toHaveBeenCalledWith({ + Authorization: 'Bearer t', + }); + }); + + it('propagates a native rejection', async () => { + native.pause.mockRejectedValueOnce(new Error('E_NOT_IMPLEMENTED')); + await expect(client.pause()).rejects.toThrow('E_NOT_IMPLEMENTED'); + }); +}); + +describe('getRequests', () => { + const client = createUploadClient(); + + it('returns the native rows synchronously', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests()).toEqual(rows); + }); + + it('filters by key', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({ key: 'k1' }).map((r) => r.id)).toEqual([ + 'a', + 'c', + ]); + }); + + it('filters by id', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({ id: 'b' }).map((r) => r.id)).toEqual(['b']); + }); + + it('applies both filters together', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({ key: 'k1', id: 'b' })).toEqual([]); + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({ key: 'k1', id: 'c' }).map((r) => r.id)).toEqual( + ['c'], + ); + }); + + it('treats an empty filter as no filter', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({})).toEqual(rows); + }); +}); + +describe('addListener', () => { + it('maps progress and attempt to their native emitters', () => { + const client = createUploadClient(); + const progress = jest.fn(); + const attempt = jest.fn(); + client.addListener('progress', progress); + client.addListener('attempt', attempt); + expect(native.onProgress).toHaveBeenCalledWith(progress); + expect(native.onAttempt).toHaveBeenCalledWith(attempt); + fire('progress', { id: 'u1', bytesSent: 1, totalBytes: 2 }); + fire('attempt', { id: 'u1', outcome: 'error' }); + expect(progress).toHaveBeenCalledWith({ + id: 'u1', + bytesSent: 1, + totalBytes: 2, + }); + expect(attempt).toHaveBeenCalledWith({ id: 'u1', outcome: 'error' }); + }); + + it('delivers native state rows and the JS unhandled-key rows to state listeners', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createUploadClient(); + const state = jest.fn(); + const subscription = client.addListener('state', state); + expect(native.onState).toHaveBeenCalledWith(state); + fire('state', rows[0]); + expect(state).toHaveBeenCalledWith(rows[0]); + + client.configure({}); + await flush(); + fire('settled', { + eventId: 'e1', + id: 'x', + key: 'nobody', + vars: null, + at: 5, + attempts: 1, + kind: 'completed', + response: { bodyTruncated: false }, + state: 'completed', + }); + await flush(); + expect(state).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: 'x', + key: 'nobody', + reason: 'unhandled-key', + }), + ); + expect(native.ackEvents).not.toHaveBeenCalled(); + + // remove() drops both sources. + subscription.remove(); + expect(native.__handlers.state).toEqual([]); + state.mockClear(); + fire('settled', { + eventId: 'e2', + id: 'y', + key: 'nobody', + vars: null, + at: 5, + attempts: 1, + kind: 'completed', + state: 'completed', + }); + await flush(); + expect(state).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + it('gives each subscription of one state listener its own removal', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createUploadClient(); + const state = jest.fn(); + const first = client.addListener('state', state); + client.addListener('state', state); + client.configure({}); + await flush(); + const settled = (eventId: string) => ({ + eventId, + id: 'x', + key: 'nobody', + vars: null, + at: 5, + attempts: 1, + kind: 'completed', + response: { bodyTruncated: false }, + state: 'completed', + }); + fire('state', rows[0]); + fire('settled', settled('e1')); + await flush(); + // Two subscriptions, two deliveries from each source. + expect(state.mock.calls.filter(([e]) => e === rows[0])).toHaveLength(2); + expect( + state.mock.calls.filter(([e]) => e.reason === 'unhandled-key'), + ).toHaveLength(2); + + first.remove(); + state.mockClear(); + fire('state', rows[0]); + fire('settled', settled('e2')); + await flush(); + // The surviving subscription still gets both sources, once each. + expect(state.mock.calls.filter(([e]) => e === rows[0])).toHaveLength(1); + expect( + state.mock.calls.filter(([e]) => e.reason === 'unhandled-key'), + ).toHaveLength(1); + warn.mockRestore(); + }); + + it('rejects an unknown event name', () => { + const client = createUploadClient(); + expect(() => (client.addListener as any)('completed', jest.fn())).toThrow( + /unknown event completed/, + ); + }); + + it('android.addNotificationListener subscribes to onNotification', () => { + const client = createUploadClient(); + const listener = jest.fn(); + client.android.addNotificationListener(listener); + expect(native.onNotification).toHaveBeenCalled(); + fire('notification', {}); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(); + }); +}); + +describe('end to end', () => { + it('mutate, then a settled outcome, runs the handler and acks', async () => { + const client = createUploadClient(); + const onSuccess = jest.fn(); + const create = client.define({ + key: 'item.create', + request: ({ n }: { n: number }) => ({ + url: `https://x/${n}`, + data: { n }, + }), + response: (raw) => (raw as { id: string }).id, + onSuccess, + }); + client.configure({ headers: () => ({ Authorization: 'Bearer t' }) }); + await flush(); + const { id } = await create.mutate({ n: 3 }, { id: 'local-3' }); + expect(id).toBe('local-3'); + expect(native.enqueue).toHaveBeenCalledWith({ + id: 'local-3', + key: 'item.create', + vars: { n: 3 }, + descriptor: { + url: 'https://x/3', + data: { n: 3 }, + headers: { Authorization: 'Bearer t' }, + expiresAt: expect.any(Number), + }, + }); + fire('settled', { + eventId: 'e1', + id: 'local-3', + key: 'item.create', + vars: { n: 3 }, + at: 10, + attempts: 1, + requestId: 'r1', + kind: 'completed', + response: { status: 201, body: '{"id":"srv-9"}', bodyTruncated: false }, + state: 'completed', + }); + await flush(); + expect(onSuccess).toHaveBeenCalledWith( + 'srv-9', + { n: 3 }, + { + id: 'local-3', + key: 'item.create', + at: 10, + attempts: 1, + requestId: 'r1', + }, + ); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('runs the handler only after the caller of mutate() has the id', async () => { + const client = createUploadClient(); + const order: string[] = []; + const create = client.define({ + key: 'item.create', + request: ({ n }: { n: number }) => ({ url: `https://x/${n}`, data: { n } }), + onError: (_error, _vars, meta) => { + order.push(`handler ${meta.id}`); + }, + }); + client.configure({}); + await flush(); + let resolveEnqueue!: (id: string) => void; + native.enqueue.mockImplementationOnce( + (entry: { id: string }) => + new Promise((resolve) => { + resolveEnqueue = () => resolve(entry.id); + }), + ); + const pending = create.mutate({ n: 1 }).then(({ id }) => { + order.push(`caller ${id}`); + return id; + }); + await flush(); + const { id } = native.enqueue.mock.calls.at(-1)![0]; + // Native settles the entry before the enqueue promise resolves. + fire('settled', { + eventId: 'e1', + id, + key: 'item.create', + vars: { n: 1 }, + at: 10, + attempts: 1, + kind: 'error', + error: { errorKind: 'file', message: 'gone' }, + state: 'error', + }); + resolveEnqueue(id); + await pending; + // Delivery yields a macrotask after the enqueue promise; wait it out. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(order).toEqual([`caller ${id}`, `handler ${id}`]); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); +}); diff --git a/src/__tests__/delivery.test.ts b/src/__tests__/delivery.test.ts new file mode 100644 index 00000000..c880b16c --- /dev/null +++ b/src/__tests__/delivery.test.ts @@ -0,0 +1,627 @@ +import { createDelivery, type SettledEvent } from '../delivery'; +import type { AnyDefinition } from '../registry'; +import type { StateEvent } from '../types'; + +const flush = async (rounds = 5): Promise => { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + +// Delivery yields one macrotask after a tracked mutate() settles, and Node +// does not run a setTimeout(0) inside a few setImmediate rounds. The wait is +// generous so a millisecond boundary between the two timers cannot reorder them. +const tick = (): Promise => + new Promise((resolve) => setTimeout(resolve, 20)); + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const completed = (over: Partial = {}): SettledEvent => ({ + eventId: 'e1', + id: 'u1', + key: 'k', + vars: { n: 1 }, + at: 1000, + attempts: 2, + requestId: 'req-9', + kind: 'completed', + response: { status: 200, body: '{"ok":true}', bodyTruncated: false }, + state: 'completed', + ...over, +}); + +const setup = ( + definitions: Record = {}, + journal: SettledEvent[] = [], + handlerWarningMs?: number, +) => { + const handlers: Array<(e: unknown) => void> = []; + const native = { + getUnacknowledgedEvents: jest.fn(async () => journal), + ackEvents: jest.fn(async () => true), + onSettled: jest.fn((handler: (e: unknown) => void) => { + handlers.push(handler); + return { remove: jest.fn() } as never; + }), + }; + const stateEvents: StateEvent[] = []; + const warn = jest.fn(); + const delivery = createDelivery({ + native, + lookup: (key) => definitions[key], + emitState: (e) => stateEvents.push(e), + warn, + handlerWarningMs, + }); + const emit = (event: SettledEvent) => handlers.forEach((h) => h(event)); + return { ...delivery, native, emit, stateEvents, warn, handlers }; +}; + +describe('replay', () => { + it('does nothing before start()', async () => { + const onSuccess = jest.fn(); + const { native } = setup( + { k: { key: 'k', request: jest.fn(), onSuccess } }, + [completed()], + ); + await flush(); + expect(native.getUnacknowledgedEvents).not.toHaveBeenCalled(); + expect(native.onSettled).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('drains the journal after start() and acks each delivered event', async () => { + const onSuccess = jest.fn(); + const { start, native } = setup( + { k: { key: 'k', request: jest.fn(), onSuccess } }, + [completed({ eventId: 'a' }), completed({ eventId: 'b', id: 'u2' })], + ); + start(); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(2); + expect(native.ackEvents).toHaveBeenCalledWith(['a']); + expect(native.ackEvents).toHaveBeenCalledWith(['b']); + }); + + it('subscribes to onSettled once and does not replay on a second start()', async () => { + const { start, native } = setup({}, []); + start(); + start(); + await flush(); + expect(native.onSettled).toHaveBeenCalledTimes(1); + expect(native.getUnacknowledgedEvents).toHaveBeenCalledTimes(1); + }); + + it('buffers live events that arrive during the drain until the journal has delivered', async () => { + const order: string[] = []; + const journalGate = deferred(); + const { start, native, emit } = setup({ + k: { + key: 'k', + request: jest.fn(), + onSuccess: (_d: unknown, _v: unknown, meta: { id: string }) => { + order.push(meta.id); + }, + }, + }); + native.getUnacknowledgedEvents.mockReturnValueOnce(journalGate.promise); + start(); + emit(completed({ eventId: 'live', id: 'live-id' })); + await flush(); + expect(order).toEqual([]); + journalGate.resolve([completed({ eventId: 'old', id: 'old-id' })]); + await flush(); + expect(order).toEqual(['old-id', 'live-id']); + }); + + it('delivers a live event straight away once live', async () => { + const onSuccess = jest.fn(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + emit(completed({ eventId: 'x' })); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledWith(['x']); + }); + + it('still goes live when the drain fails, and warns', async () => { + const onSuccess = jest.fn(); + const { start, emit, native, warn } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + native.getUnacknowledgedEvents.mockRejectedValueOnce(new Error('disk')); + start(); + await flush(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/getUnacknowledgedEvents failed/), + expect.any(Error), + ); + emit(completed()); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); +}); + +describe('dedupe', () => { + it('delivers one eventId once, even when journaled and emitted live', async () => { + const onSuccess = jest.fn(); + const { start, emit, native } = setup( + { k: { key: 'k', request: jest.fn(), onSuccess } }, + [completed({ eventId: 'same' })], + ); + start(); + emit(completed({ eventId: 'same' })); + await flush(); + emit(completed({ eventId: 'same' })); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledTimes(1); + }); + + it('drops and warns on an event without an eventId', async () => { + const onSuccess = jest.fn(); + const { start, emit, warn } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + emit({ ...completed(), eventId: undefined as unknown as string }); + await flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/without an eventId/), + expect.anything(), + ); + }); +}); + +describe('ordering against mutate()', () => { + it('waits for the in-flight mutate() of the same id before delivering', async () => { + const onSuccess = jest.fn(); + const { start, emit, trackMutate } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + const enqueue = deferred(); + trackMutate('u1', enqueue.promise); + emit(completed()); + await flush(); + expect(onSuccess).not.toHaveBeenCalled(); + enqueue.resolve('u1'); + await tick(); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('runs the handler after every continuation on the mutate() promise, not just after enqueue', async () => { + // The registry registers trackMutate before mutate() awaits the same + // promise, and the caller awaits mutate() after that. Both reactions are + // microtasks that run before the handler. + const order: string[] = []; + const { start, emit, trackMutate } = setup({ + k: { + key: 'k', + request: jest.fn(), + onSuccess: () => { + order.push('handler'); + }, + }, + }); + start(); + await flush(); + const enqueue = deferred(); + trackMutate('u1', enqueue.promise); + const mutateResult = enqueue.promise.then((id) => ({ id })); + void mutateResult.then(({ id }) => order.push(`caller got ${id}`)); + emit(completed()); + enqueue.resolve('u1'); + await mutateResult; + // No flush between the resolve and this read: the caller's continuation + // has to run first on its own. + await tick(); + expect(order).toEqual(['caller got u1', 'handler']); + }); + + it('delivers once a tracked mutate() rejects', async () => { + const onSuccess = jest.fn(); + const { start, emit, trackMutate } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + const enqueue = deferred(); + trackMutate('u1', enqueue.promise); + emit(completed()); + enqueue.reject(new Error('nope')); + await tick(); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not wait on a mutate() for a different id', async () => { + const onSuccess = jest.fn(); + const { start, emit, trackMutate } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + trackMutate('other', deferred().promise); + emit(completed()); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); +}); + +describe('unknown key', () => { + it('emits an unhandled-key state row and does not ack', async () => { + const { start, emit, native, stateEvents, warn } = setup({}); + start(); + await flush(); + emit( + completed({ + key: 'gone', + state: 'completed', + bytesSent: 10, + totalBytes: 10, + }), + ); + await flush(); + expect(native.ackEvents).not.toHaveBeenCalled(); + expect(stateEvents).toEqual([ + { + id: 'u1', + key: 'gone', + vars: { n: 1 }, + state: 'completed', + bytesSent: 10, + totalBytes: 10, + attempts: 2, + updatedAt: 1000, + reason: 'unhandled-key', + }, + ]); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/no definition for key "gone"/), + ); + }); + + it('defaults the byte counters to 0 when the event has none', async () => { + const { start, emit, stateEvents } = setup({}); + start(); + await flush(); + emit(completed({ key: 'gone' })); + await flush(); + expect(stateEvents[0]).toMatchObject({ bytesSent: 0, totalBytes: 0 }); + }); +}); + +describe('completed', () => { + it('passes the RawResponse to onSuccess when there is no parser, with vars and meta', async () => { + const onSuccess = jest.fn(); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + const event = completed(); + emit(event); + await flush(); + expect(onSuccess).toHaveBeenCalledWith( + event.response, + { n: 1 }, + { + id: 'u1', + key: 'k', + at: 1000, + attempts: 2, + requestId: 'req-9', + }, + ); + }); + + it('parses the JSON body, runs the parser, and passes its result to onSuccess', async () => { + const onSuccess = jest.fn(); + const response = jest.fn((raw: unknown) => (raw as { ok: boolean }).ok); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), response, onSuccess }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(response).toHaveBeenCalledWith({ ok: true }); + expect(onSuccess).toHaveBeenCalledWith(true, { n: 1 }, expect.anything()); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('gives the parser undefined when the body is absent or empty', async () => { + const response = jest.fn(() => 'parsed'); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), response }, + }); + start(); + await flush(); + emit(completed({ eventId: 'a', response: { bodyTruncated: false } })); + emit( + completed({ eventId: 'b', response: { body: '', bodyTruncated: false } }), + ); + await flush(); + expect(response).toHaveBeenCalledTimes(2); + expect(response).toHaveBeenNthCalledWith(1, undefined); + expect(response).toHaveBeenNthCalledWith(2, undefined); + }); + + it('calls onError with errorKind truncated when a parser is set and the body was cut', async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const response = jest.fn(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), response, onSuccess, onError }, + }); + start(); + await flush(); + const raw = { status: 200, body: '{"partial', bodyTruncated: true }; + emit(completed({ response: raw })); + await flush(); + expect(response).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + { + errorKind: 'truncated', + message: expect.stringMatching(/1 MB/), + response: raw, + }, + { n: 1 }, + expect.anything(), + ); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('passes a truncated body to onSuccess untouched when there is no parser', async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess, onError }, + }); + start(); + await flush(); + const raw = { status: 200, body: 'x', bodyTruncated: true }; + emit(completed({ response: raw })); + await flush(); + expect(onSuccess).toHaveBeenCalledWith(raw, { n: 1 }, expect.anything()); + expect(onError).not.toHaveBeenCalled(); + }); + + it('routes a parser throw to onError as unknown and still acks', async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const { start, emit, native } = setup({ + k: { + key: 'k', + request: jest.fn(), + response: () => { + throw new Error('bad shape'); + }, + onSuccess, + onError, + }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + { errorKind: 'unknown', message: 'bad shape' }, + { n: 1 }, + expect.anything(), + ); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('treats a body that is not JSON as a parser failure', async () => { + const onError = jest.fn(); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), response: (x) => x, onError }, + }); + start(); + await flush(); + emit(completed({ response: { body: 'not json', bodyTruncated: false } })); + await flush(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ errorKind: 'unknown' }), + { n: 1 }, + expect.anything(), + ); + }); + + it('acks a completed outcome whose definition has no onSuccess', async () => { + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn() }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); +}); + +describe('error', () => { + it('calls onError with the outcome error, vars and meta, then acks', async () => { + const onError = jest.fn(); + const onSuccess = jest.fn(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), onError, onSuccess }, + }); + start(); + await flush(); + const error = { + errorKind: 'http' as const, + message: '422', + response: { status: 422, body: '{}', bodyTruncated: false }, + }; + emit( + completed({ kind: 'error', state: 'error', response: undefined, error }), + ); + await flush(); + expect(onError).toHaveBeenCalledWith( + error, + { n: 1 }, + expect.objectContaining({ id: 'u1' }), + ); + expect(onSuccess).not.toHaveBeenCalled(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); +}); + +describe('cancelled', () => { + it('calls no handler and acks', async () => { + const onError = jest.fn(); + const onSuccess = jest.fn(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), onError, onSuccess }, + }); + start(); + await flush(); + emit( + completed({ + kind: 'cancelled', + state: 'cancelled', + response: undefined, + cancelReason: 'user', + }), + ); + await flush(); + expect(onError).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); +}); + +describe('ack after the handler', () => { + it('acks only after the handler promise resolves', async () => { + const gate = deferred(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess: () => gate.promise }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(native.ackEvents).not.toHaveBeenCalled(); + gate.resolve(); + await flush(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('does not ack when the handler rejects, and warns', async () => { + const { start, emit, native, warn } = setup({ + k: { + key: 'k', + request: jest.fn(), + onSuccess: async () => { + throw new Error('handler down'); + }, + }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(native.ackEvents).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/handler for u1 rejected/), + expect.any(Error), + ); + }); + + it('does not ack when the handler throws synchronously', async () => { + const { start, emit, native } = setup({ + k: { + key: 'k', + request: jest.fn(), + onSuccess: () => { + throw new Error('sync'); + }, + }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(native.ackEvents).not.toHaveBeenCalled(); + }); + + it('warns when ackEvents itself fails', async () => { + const { start, emit, native, warn } = setup({ + k: { key: 'k', request: jest.fn() }, + }); + native.ackEvents.mockRejectedValueOnce(new Error('journal locked')); + start(); + await flush(); + emit(completed()); + await flush(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/ackEvents failed for e1/), + expect.any(Error), + ); + }); +}); + +describe('slow handler warning', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('warns once at 30 s when the handler has not settled', async () => { + const gate = deferred(); + const { start, emit, warn, native } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess: () => gate.promise }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + await jest.advanceTimersByTimeAsync(29_999); + expect(warn).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(1); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/"k" handler for u1 has not settled after 30 s/), + ); + await jest.advanceTimersByTimeAsync(60_000); + expect(warn).toHaveBeenCalledTimes(1); + expect(native.ackEvents).not.toHaveBeenCalled(); + gate.resolve(); + await flush(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('does not warn when the handler settles in time', async () => { + const { start, emit, warn } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess: async () => undefined }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + await jest.advanceTimersByTimeAsync(60_000); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts deleted file mode 100644 index 220fe533..00000000 --- a/src/__tests__/index.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -// Define all mocks inside the factory (no outer references) to avoid the -// import-hoisting TDZ trap, then grab handles from the mocked module below. -// The library reaches native through TurboModuleRegistry.getEnforcing, so that -// is what has to be stubbed — the codegen event emitters are plain functions -// that take a handler and return a subscription. -jest.mock('react-native', () => { - const subscription = { remove: jest.fn() }; - const nativeModule = { - configure: jest.fn(), - startUpload: jest.fn(async () => 'id-1'), - startChunkedUpload: jest.fn(async () => 'id-2'), - cancelUpload: jest.fn(async () => true), - removeUpload: jest.fn(async () => undefined), - getUnacknowledgedEvents: jest.fn(async () => [ - { - eventId: 'e1', - id: 'u1', - type: 'completed', - timestamp: 1, - responseCode: 200, - }, - ]), - ackEvents: jest.fn(async () => true), - getAllUploads: jest.fn(async () => [{ id: 'u1', state: 'running' }]), - onProgress: jest.fn(() => subscription), - onError: jest.fn(() => subscription), - onCancelled: jest.fn(() => subscription), - onCompleted: jest.fn(() => subscription), - onNotification: jest.fn(() => subscription), - }; - return { - Platform: { OS: 'ios' }, - TurboModuleRegistry: { - getEnforcing: jest.fn(() => nativeModule), - get: jest.fn(() => nativeModule), - }, - }; -}); - -import { TurboModuleRegistry } from 'react-native'; -import Upload from '../index'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ -// Same object the module captured at import time. -const native = (TurboModuleRegistry as any).getEnforcing('RNFileUploader'); - -describe('journal + query API', () => { - it('getUnacknowledgedEvents returns the native events', async () => { - const events = await Upload.getUnacknowledgedEvents(); - expect(events[0].eventId).toBe('e1'); - expect(events[0].type).toBe('completed'); - }); - - it('ackEvents forwards the ids to native', async () => { - await Upload.ackEvents(['e1', 'e2']); - expect(native.ackEvents).toHaveBeenCalledWith(['e1', 'e2']); - }); - - it('getAllUploads returns the native snapshots', async () => { - const uploads = await Upload.getAllUploads(); - expect(uploads[0]).toEqual({ id: 'u1', state: 'running' }); - }); -}); - -describe('configure', () => { - it('forwards the android notification config to native, flattened', () => { - Upload.configure({ - android: { notificationTitle: 'Backing up…', notificationChannel: 'ch' }, - }); - expect(native.configure).toHaveBeenCalledWith({ - notificationTitle: 'Backing up…', - notificationChannel: 'ch', - }); - }); -}); - -describe('startUpload', () => { - it('prefixes the file path on iOS and forwards options', async () => { - await Upload.startUpload({ - url: 'https://example.com/up', - path: '/tmp/f.bin', - method: 'POST', - type: 'raw', - accept: [{ status: 409, bodyIncludes: 'already completed' }], - }); - expect(native.startUpload).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://example.com/up', - path: 'file:///tmp/f.bin', - accept: [{ status: 409, bodyIncludes: 'already completed' }], - }), - ); - }); - - it('forwards android.noNotification but no notification text', async () => { - await Upload.startUpload({ - url: 'https://example.com/up', - path: '/tmp/f.bin', - method: 'POST', - type: 'raw', - android: { noNotification: true }, - }); - const options = native.startUpload.mock.calls.at(-1)![0]; - expect(options.noNotification).toBe(true); - // configure() owns the notification text. startUpload never carries it. - expect(options).not.toHaveProperty('notificationTitle'); - expect(options).not.toHaveProperty('notificationId'); - }); -}); - -describe('startUpload (chunked)', () => { - const chunked = { - type: 'chunked' as const, - id: 'u1', - path: '/tmp/f.bin', - parts: [ - { - url: 'https://example.com/up?partNum=1', - headers: { 'Content-Range': 'bytes 0-9/20' }, - range: { start: 0, end: 10 }, - }, - { - url: 'https://example.com/up?partNum=2', - headers: { 'Content-Range': 'bytes 10-19/20' }, - range: { start: 10, end: 20 }, - }, - ], - expiresAt: 1735689600000, - }; - - it('routes to startChunkedUpload, not startUpload', async () => { - native.startUpload.mockClear(); - await Upload.startUpload(chunked); - expect(native.startUpload).not.toHaveBeenCalled(); - expect(native.startChunkedUpload).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'u1', - path: 'file:///tmp/f.bin', - parts: chunked.parts, - expiresAt: chunked.expiresAt, - }), - ); - }); - - it('rejects an empty id', () => { - expect(() => Upload.startUpload({ ...chunked, id: '' })).toThrow( - /non-empty id/, - ); - }); - - it('rejects empty parts', () => { - expect(() => Upload.startUpload({ ...chunked, parts: [] })).toThrow( - /non-empty/, - ); - }); - - it.each([ - { start: -1, end: 10 }, - { start: 10, end: 10 }, - { start: 11, end: 10 }, - { start: 0.5, end: 10 }, - { start: 0, end: NaN }, - ])('rejects range %p', (range) => { - const parts = [{ ...chunked.parts[0], range }]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /0 <= start < end/, - ); - }); - - it.each([NaN, Infinity, 0, -5])('rejects expiresAt %p', (expiresAt) => { - expect(() => Upload.startUpload({ ...chunked, expiresAt })).toThrow( - /expiresAt/, - ); - }); - - it('rejects a nonzero first start', () => { - const parts = [ - { ...chunked.parts[0], range: { start: 5, end: 10 } }, - { ...chunked.parts[1], range: { start: 10, end: 20 } }, - ]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /parts\[0\]\.range\.start must be 0/, - ); - }); - - it('rejects a gap between parts', () => { - const parts = [ - { ...chunked.parts[0], range: { start: 0, end: 8 } }, - { ...chunked.parts[1], range: { start: 10, end: 20 } }, - ]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /no gaps or overlaps/, - ); - }); - - it('rejects overlapping parts', () => { - const parts = [ - { ...chunked.parts[0], range: { start: 0, end: 12 } }, - { ...chunked.parts[1], range: { start: 10, end: 20 } }, - ]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /no gaps or overlaps/, - ); - }); - - it('rejects out-of-order parts', () => { - const parts = [chunked.parts[1], chunked.parts[0]]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /parts\[0\]\.range\.start must be 0/, - ); - }); - - it('rejects an empty part url', () => { - const parts = [{ ...chunked.parts[0], url: '' }, chunked.parts[1]]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /parts\[0\]\.url must be a non-empty string/, - ); - }); - - it('rejects non-object part headers', () => { - const parts = [ - { ...chunked.parts[0], headers: 'nope' as never }, - chunked.parts[1], - ]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /parts\[0\]\.headers must be a plain object/, - ); - }); - - it('throws before reaching native', () => { - native.startChunkedUpload.mockClear(); - expect(() => Upload.startUpload({ ...chunked, parts: [] })).toThrow(); - expect(native.startChunkedUpload).not.toHaveBeenCalled(); - }); -}); - -describe('removeUpload', () => { - it('forwards the id to native', async () => { - await Upload.removeUpload('u1'); - expect(native.removeUpload).toHaveBeenCalledWith('u1'); - }); -}); - -describe('addListener', () => { - it('subscribes to the matching codegen emitter', () => { - Upload.addListener('progress', jest.fn()); - expect(native.onProgress).toHaveBeenCalled(); - }); - - it('delivers events for every upload', () => { - const cb = jest.fn(); - Upload.addListener('completed', cb); - const handler = native.onCompleted.mock.calls.at(-1)![0] as ( - data: unknown, - ) => void; - handler({ id: 'u1', responseCode: 200 }); - handler({ id: 'someone-else', responseCode: 200 }); - expect(cb).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts new file mode 100644 index 00000000..594283f7 --- /dev/null +++ b/src/__tests__/registry.test.ts @@ -0,0 +1,488 @@ +import { + createRegistry, + DEFAULT_LIFETIME_MS, + MAX_VARS_BYTES, + utf8ByteLength, + uuidV4, + type AnyDefinition, + type EnqueueEntry, + type Settings, +} from '../registry'; + +const NOW = 1_700_000_000_000; + +const setup = (settings: Partial = {}) => { + const enqueue = jest.fn(async (entry: EnqueueEntry) => entry.id); + const definitions = new Map(); + const trackMutate = jest.fn(); + const warn = jest.fn(); + const current: Settings = { lifetimeMs: DEFAULT_LIFETIME_MS, ...settings }; + const registry = createRegistry({ + native: { enqueue }, + definitions, + getSettings: () => current, + trackMutate, + warn, + now: () => NOW, + }); + const lastEntry = (): EnqueueEntry => enqueue.mock.calls.at(-1)![0]; + return { ...registry, enqueue, definitions, trackMutate, warn, lastEntry }; +}; + +const jsonPost = (vars: { n: number }) => ({ + url: `https://example.com/items/${vars.n}`, + data: { n: vars.n }, +}); + +describe('define', () => { + it('registers the definition under its key and returns { key, mutate }', () => { + const { define, definitions } = setup(); + const defined = define({ key: 'item.create', request: jsonPost }); + expect(defined.key).toBe('item.create'); + expect(typeof defined.mutate).toBe('function'); + expect(definitions.get('item.create')?.key).toBe('item.create'); + }); + + it('replaces a duplicate key and warns', () => { + const { define, definitions, warn } = setup(); + const first = { key: 'dup', request: jsonPost }; + const second = { key: 'dup', request: jsonPost, onSuccess: jest.fn() }; + define(first); + expect(warn).not.toHaveBeenCalled(); + define(second); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/"dup" is already defined/); + expect(definitions.get('dup')).toBe(second); + }); + + it('runs the replacement request() from a mutate on the earlier handle', async () => { + const { define, lastEntry } = setup(); + const old = define({ key: 'k', request: jsonPost }); + define({ + key: 'k', + request: (vars: { n: number }) => ({ url: 'https://new', data: vars }), + }); + await old.mutate({ n: 1 }); + expect(lastEntry().descriptor.url).toBe('https://new'); + }); + + it('rejects an empty key or a missing request', () => { + const { define } = setup(); + expect(() => define({ key: '', request: jsonPost })).toThrow(/key/); + expect(() => + define({ key: 'x', request: undefined as unknown as typeof jsonPost }), + ).toThrow(/request/); + }); +}); + +describe('mutate', () => { + it('runs request(vars) exactly once and enqueues { id, key, vars, descriptor }', async () => { + const { define, enqueue, lastEntry } = setup(); + const request = jest.fn(jsonPost); + const create = define({ key: 'item.create', request }); + const result = await create.mutate({ n: 7 }, { id: 'local-7' }); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith({ n: 7 }); + expect(enqueue).toHaveBeenCalledTimes(1); + expect(lastEntry()).toMatchObject({ + id: 'local-7', + key: 'item.create', + vars: { n: 7 }, + descriptor: { url: 'https://example.com/items/7', data: { n: 7 } }, + }); + expect(result).toEqual({ id: 'local-7' }); + }); + + it('stores null vars for a mutate() with no arguments', async () => { + const request = jest.fn(() => ({ url: 'https://x', data: null })); + const { define, lastEntry } = setup(); + const ping = define({ key: 'ping', request }); + await ping.mutate(); + expect(request).toHaveBeenCalledWith(null); + expect(lastEntry().vars).toBeNull(); + await ping.mutate(undefined, { id: 'fixed' }); + expect(lastEntry()).toMatchObject({ id: 'fixed', vars: null }); + }); + + it('resolves with the id native returns', async () => { + const { define, enqueue } = setup(); + enqueue.mockResolvedValueOnce('native-id'); + const create = define({ key: 'k', request: jsonPost }); + await expect(create.mutate({ n: 1 }, { id: 'mine' })).resolves.toEqual({ + id: 'native-id', + }); + }); + + it('generates a UUID v4 when no id is given', async () => { + const { define, lastEntry } = setup(); + const create = define({ key: 'k', request: jsonPost }); + const { id } = await create.mutate({ n: 1 }); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(lastEntry().id).toBe(id); + }); + + it('hands the enqueue promise to trackMutate under the entry id', async () => { + const { define, trackMutate } = setup(); + const create = define({ key: 'k', request: jsonPost }); + await create.mutate({ n: 1 }, { id: 'abc' }); + expect(trackMutate).toHaveBeenCalledWith('abc', expect.any(Promise)); + }); + + it('rejects when native enqueue rejects', async () => { + const { define, enqueue } = setup(); + enqueue.mockRejectedValueOnce(new Error('E_NOT_IMPLEMENTED')); + const create = define({ key: 'k', request: jsonPost }); + await expect(create.mutate({ n: 1 })).rejects.toThrow('E_NOT_IMPLEMENTED'); + }); + + it('rejects when request() throws, without reaching native', async () => { + const { define, enqueue } = setup(); + const boom = define({ + key: 'k', + request: (_vars: null) => { + throw new Error('no url yet'); + }, + }); + await expect(boom.mutate(null)).rejects.toThrow('no url yet'); + expect(enqueue).not.toHaveBeenCalled(); + }); + + describe('vars cap', () => { + it('accepts vars at exactly the cap and rejects one byte over', async () => { + const { define, enqueue } = setup(); + const send = define({ + key: 'k', + request: (_vars: { s: string }) => ({ url: 'https://x', data: null }), + }); + // JSON.stringify({ s }) adds {"s":""} = 8 bytes around the payload. + const fits = 'a'.repeat(MAX_VARS_BYTES - 8); + await expect(send.mutate({ s: fits })).resolves.toBeDefined(); + await expect(send.mutate({ s: fits + 'a' })).rejects.toThrow( + /vars for "k" is 4097 bytes; the limit is 4096/, + ); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('counts UTF-8 bytes, not UTF-16 code units', async () => { + const { define } = setup(); + const send = define({ + key: 'k', + request: (_vars: { s: string }) => ({ url: 'https://x', data: null }), + }); + // 1400 three-byte characters = 4200 bytes but only 1400 code units. + await expect(send.mutate({ s: '€'.repeat(1400) })).rejects.toThrow( + /4208 bytes/, + ); + expect(utf8ByteLength('a')).toBe(1); + expect(utf8ByteLength('é')).toBe(2); + expect(utf8ByteLength('€')).toBe(3); + expect(utf8ByteLength('\u{1F600}')).toBe(4); + }); + }); + + describe('descriptor validation', () => { + const mutateWith = (descriptor: unknown) => { + const { define, enqueue } = setup(); + const d = define({ + key: 'k', + request: (_vars: null) => descriptor as ReturnType, + }); + return { promise: d.mutate(null), enqueue }; + }; + + it('requires exactly one body kind', async () => { + await expect(mutateWith({ url: 'https://x' }).promise).rejects.toThrow( + /exactly one of data, form, file; got none/, + ); + await expect( + mutateWith({ url: 'https://x', data: {}, file: '/f' }).promise, + ).rejects.toThrow(/exactly one of data, form, file; got data, file/); + }); + + it('accepts each body kind alone', async () => { + await expect( + mutateWith({ url: 'https://x', data: null }).promise, + ).resolves.toBeDefined(); + await expect( + mutateWith({ url: 'https://x', file: '/f' }).promise, + ).resolves.toBeDefined(); + await expect( + mutateWith({ + url: 'https://x', + form: [ + { name: 'meta', contentType: 'application/json', string: '{}' }, + { name: 'photo', contentType: 'image/jpeg', path: '/p.jpg' }, + ], + }).promise, + ).resolves.toBeDefined(); + }); + + it('rejects a form part without exactly one of string, path', async () => { + await expect( + mutateWith({ + url: 'https://x', + form: [{ name: 'a', contentType: 'text/plain' }], + }).promise, + ).rejects.toThrow(/form\[0\] must set exactly one of string, path/); + }); + + it('allows parts only with file', async () => { + await expect( + mutateWith({ + data: {}, + parts: [{ url: 'https://p', range: { start: 0, end: 1 } }], + }).promise, + ).rejects.toThrow(/parts requires file/); + }); + + it('requires url unless parts is set', async () => { + await expect(mutateWith({ data: {} }).promise).rejects.toThrow( + /url is required unless parts is set/, + ); + await expect(mutateWith({ url: '', data: {} }).promise).rejects.toThrow( + /url must be a non-empty string/, + ); + await expect( + mutateWith({ + file: '/f', + parts: [{ url: 'https://p', range: { start: 0, end: 1 } }], + }).promise, + ).resolves.toBeDefined(); + }); + + it('rejects a method outside the union', async () => { + await expect( + mutateWith({ url: 'https://x', data: {}, method: 'FETCH' }).promise, + ).rejects.toThrow(/method must be one of/); + }); + + it.each([NaN, Infinity, 0, -5])( + 'rejects expiresAt %p', + async (expiresAt) => { + await expect( + mutateWith({ url: 'https://x', data: {}, expiresAt }).promise, + ).rejects.toThrow(/expiresAt/); + }, + ); + + it('rejects a non-object descriptor', async () => { + await expect(mutateWith(undefined).promise).rejects.toThrow( + /request\(\) must return a descriptor object/, + ); + }); + + it('rejects an unknown descriptor field and suggests the nearest known one', async () => { + await expect( + mutateWith({ url: 'https://x', data: {}, header: { A: 'b' } }).promise, + ).rejects.toThrow( + 'mutate: unknown descriptor field "header". Did you mean "headers"?', + ); + await expect( + mutateWith({ url: 'https://x', data: {}, expiryAt: 5 }).promise, + ).rejects.toThrow(/unknown descriptor field "expiryAt".*"expiresAt"/); + await expect( + mutateWith({ url: 'https://x', data: {}, timeout: 5 }).promise, + ).rejects.toThrow(/^mutate: unknown descriptor field "timeout".$/); + }); + + it('rejects an unknown field on a part or a form part', async () => { + await expect( + mutateWith({ + file: '/f', + parts: [ + { url: 'https://p', header: {}, range: { start: 0, end: 1 } }, + ], + }).promise, + ).rejects.toThrow(/unknown parts\[0\] field "header".*"headers"/); + await expect( + mutateWith({ + url: 'https://x', + form: [ + { + name: 'photo', + contentType: 'image/jpeg', + path: '/p.jpg', + filename: 'a.jpg', + }, + ], + }).promise, + ).rejects.toThrow(/unknown form\[0\] field "filename".*"fileName"/); + }); + + it('never reaches native on a rejected descriptor', async () => { + const { promise, enqueue } = mutateWith({ data: {} }); + await expect(promise).rejects.toThrow(); + expect(enqueue).not.toHaveBeenCalled(); + }); + }); + + describe('parts tiling (v9 rules)', () => { + const parts = [ + { url: 'https://p/1', range: { start: 0, end: 10 } }, + { url: 'https://p/2', range: { start: 10, end: 20 } }, + ]; + const chunked = (override: unknown[]) => { + const { define } = setup(); + return define({ + key: 'k', + request: (_vars: null) => ({ + file: '/f', + parts: override as typeof parts, + }), + }).mutate(null); + }; + + it('accepts a tiled plan', async () => { + await expect(chunked(parts)).resolves.toBeDefined(); + }); + + it('rejects empty parts', async () => { + await expect(chunked([])).rejects.toThrow(/non-empty array/); + }); + + it.each([ + { start: -1, end: 10 }, + { start: 10, end: 10 }, + { start: 11, end: 10 }, + { start: 0.5, end: 10 }, + { start: 0, end: NaN }, + ])('rejects range %p', async (range) => { + await expect(chunked([{ ...parts[0], range }])).rejects.toThrow( + /0 <= start < end/, + ); + }); + + it('rejects a nonzero first start', async () => { + await expect( + chunked([{ ...parts[0], range: { start: 5, end: 10 } }, parts[1]]), + ).rejects.toThrow(/parts\[0\]\.range\.start must be 0/); + }); + + it('rejects a gap', async () => { + await expect( + chunked([{ ...parts[0], range: { start: 0, end: 8 } }, parts[1]]), + ).rejects.toThrow(/no gaps or overlaps/); + }); + + it('rejects an overlap', async () => { + await expect( + chunked([{ ...parts[0], range: { start: 0, end: 12 } }, parts[1]]), + ).rejects.toThrow(/no gaps or overlaps/); + }); + + it('rejects out-of-order parts', async () => { + await expect(chunked([parts[1], parts[0]])).rejects.toThrow( + /parts\[0\]\.range\.start must be 0/, + ); + }); + + it('rejects an empty part url', async () => { + await expect( + chunked([{ ...parts[0], url: '' }, parts[1]]), + ).rejects.toThrow(/parts\[0\]\.url must be a non-empty string/); + }); + + it('rejects non-object part headers', async () => { + await expect( + chunked([{ ...parts[0], headers: 'nope' }, parts[1]]), + ).rejects.toThrow(/parts\[0\]\.headers must be a plain object/); + }); + }); + + describe('headers', () => { + it('merges the descriptor headers over the configured provider', async () => { + const headers = jest.fn(() => ({ + Authorization: 'Bearer old', + 'X-App': 'app', + })); + const { define, lastEntry } = setup({ headers }); + const send = define({ + key: 'k', + request: (_vars: null) => ({ + url: 'https://x', + data: {}, + headers: { Authorization: 'Bearer mine', 'Content-Type': 'a/b' }, + }), + }); + await send.mutate(null); + expect(headers).toHaveBeenCalledTimes(1); + expect(lastEntry().descriptor.headers).toEqual({ + Authorization: 'Bearer mine', + 'X-App': 'app', + 'Content-Type': 'a/b', + }); + }); + + it('sends the provider headers alone when the descriptor has none', async () => { + const { define, lastEntry } = setup({ headers: () => ({ A: '1' }) }); + const send = define({ + key: 'k', + request: (_vars: null) => ({ url: 'https://x', data: {} }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.headers).toEqual({ A: '1' }); + }); + + it('sends an empty header map with no provider and no descriptor headers', async () => { + const { define, lastEntry } = setup(); + const send = define({ + key: 'k', + request: (_vars: null) => ({ url: 'https://x', data: {} }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.headers).toEqual({}); + }); + }); + + describe('expiresAt', () => { + it('defaults to now + lifetimeMs', async () => { + const { define, lastEntry } = setup({ lifetimeMs: 1000 }); + const send = define({ + key: 'k', + request: (_vars: null) => ({ url: 'https://x', data: {} }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.expiresAt).toBe(NOW + 1000); + }); + + it('defaults the lifetime to 14 days', async () => { + const { define, lastEntry } = setup(); + const send = define({ + key: 'k', + request: (_vars: null) => ({ url: 'https://x', data: {} }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.expiresAt).toBe( + NOW + 14 * 24 * 60 * 60 * 1000, + ); + }); + + it('keeps an explicit expiresAt', async () => { + const { define, lastEntry } = setup(); + const send = define({ + key: 'k', + request: (_vars: null) => ({ + url: 'https://x', + data: {}, + expiresAt: 42, + }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.expiresAt).toBe(42); + }); + }); +}); + +describe('uuidV4', () => { + it('produces distinct RFC 4122 v4 strings', () => { + const ids = new Set(Array.from({ length: 100 }, uuidV4)); + expect(ids.size).toBe(100); + ids.forEach((id) => + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ), + ); + }); +}); diff --git a/src/__typetests__/define.ts b/src/__typetests__/define.ts new file mode 100644 index 00000000..97296ba7 --- /dev/null +++ b/src/__typetests__/define.ts @@ -0,0 +1,230 @@ +// Type-level tests. tsc compiles this file with the library (yarn typecheck); +// nothing here runs. Each @ts-expect-error line fails the build when the +// error it expects goes away. +import type { Meta, OutcomeError, RawResponse, UploadClient } from '../types'; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B + ? 1 + : 2 + ? true + : false; +const expectType = (_actual: Expected): void => {}; +const assertEqual = ( + _proof: Equal extends true ? true : never, +) => {}; + +declare const client: UploadClient; + +type AddCommentVars = { + siteId: string; + customFieldNoteId: string; + comment: string; +}; +type Comment = { id: string; text: string }; + +// vars infer from the request parameter, data from the response parser. +const addComment = client.define({ + key: 'comment.add', + request: ({ siteId, customFieldNoteId, comment }: AddCommentVars) => ({ + url: `https://api/sites/${siteId}/notes/${customFieldNoteId}/comments`, + data: { comment }, + }), + response: (raw) => (raw as { content: Comment[] }).content, + onSuccess: (content, vars, meta) => { + expectType(content); + expectType(vars); + expectType(meta); + assertEqual(true); + assertEqual(true); + }, + onError: (error, vars) => { + expectType(error); + expectType(vars); + }, +}); + +// The vars type flows into mutate(). +void addComment.mutate({ siteId: 's', customFieldNoteId: 'n', comment: 'hi' }); +void addComment.mutate( + { siteId: 's', customFieldNoteId: 'n', comment: 'hi' }, + { id: 'local-1' }, +); +expectType>( + addComment.mutate({ siteId: 's', customFieldNoteId: 'n', comment: 'hi' }), +); +expectType(addComment.key); + +// Wrong vars are a type error. +// @ts-expect-error siteId must be a string +void addComment.mutate({ siteId: 1, customFieldNoteId: 'n', comment: 'hi' }); +// @ts-expect-error comment is required +void addComment.mutate({ siteId: 's', customFieldNoteId: 'n' }); +void addComment.mutate({ + siteId: 's', + customFieldNoteId: 'n', + comment: 'hi', + // @ts-expect-error unknown field + extra: 1, +}); + +// Without a response parser, onSuccess receives the RawResponse. +const putFile = client.define({ + key: 'file.put', + request: ({ path, url }: { path: string; url: string }) => ({ + url, + method: 'PUT', + file: path, + }), + onSuccess: (_data) => { + assertEqual(true); + }, +}); +void putFile.mutate({ path: '/tmp/a', url: 'https://x' }); + +// vars must be JSON: no functions, no Dates, no undefined fields. +client.define({ + key: 'bad.vars', + // @ts-expect-error a function is not Json + request: (_vars: { cb: () => void }) => ({ url: 'https://x', data: null }), +}); +client.define({ + key: 'bad.vars.date', + // @ts-expect-error a Date is not Json + request: (_vars: { when: Date }) => ({ url: 'https://x', data: null }), +}); + +// The descriptor is checked against RequestDescriptor. +client.define({ + key: 'bad.descriptor', + // @ts-expect-error method must be one of the union + request: (_vars: { a: string }) => ({ + url: 'https://x', + data: 1, + method: 'FETCH', + }), +}); + +// A parser that throws away its input still fixes T. +const ping = client.define({ + key: 'ping', + request: (_vars: null) => ({ url: 'https://x', data: null }), + response: () => 42 as const, + onSuccess: (_data) => { + assertEqual(true); + }, +}); +void ping.mutate(null); + +// An onSuccess annotated with another type, and no response parser, does not +// compile. At runtime the handler would receive the RawResponse. +type Dto = { total: number }; +client.define({ + key: 'annotated.no.parser', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + // @ts-expect-error onSuccess wants a Dto but there is no response parser + onSuccess: (data: Dto) => { + void data.total; + }, +}); +declare const handleDto: (data: Dto, vars: { a: string }, meta: Meta) => void; +client.define({ + key: 'named.no.parser', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + // @ts-expect-error a named handler typed for a Dto also needs the parser + onSuccess: handleDto, +}); +// With the parser, the same handler compiles. +client.define({ + key: 'named.with.parser', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + response: (raw) => raw as Dto, + onSuccess: handleDto, +}); +// An onSuccess that spells out RawResponse compiles without a parser. +client.define({ + key: 'raw.annotated', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + onSuccess: (data: RawResponse) => { + void data.bodyTruncated; + }, +}); +// A definition with only onError infers too. +const errorOnly = client.define({ + key: 'error.only', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + onError: (_error, _vars) => { + assertEqual(true); + }, +}); +void errorOnly.mutate({ a: 'x' }); + +// A zod-style parser fixes T from its return type. +declare const schema: { parse: (input: unknown) => Dto }; +const parsed = client.define({ + key: 'zod', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + response: schema.parse, + onSuccess: (_data) => { + assertEqual(true); + }, +}); +void parsed.mutate({ a: 'x' }); + +// An async parser is typed honestly: onSuccess sees the Promise. +client.define({ + key: 'async.parser', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + response: async (raw) => raw as Dto, + onSuccess: (_data) => { + assertEqual>(true); + }, +}); + +// A request that takes no vars gives mutate() no arguments, and rejects a +// stray value. +const noVars = client.define({ + key: 'no.vars', + request: () => ({ url: 'https://x', data: null }), + onSuccess: (_data, _vars) => { + assertEqual(true); + assertEqual(true); + }, +}); +void noVars.mutate(); +void noVars.mutate(null); +void noVars.mutate(undefined, { id: 'fixed' }); +// @ts-expect-error a no-vars definition takes no vars +void noVars.mutate('anything goes'); +// @ts-expect-error a no-vars definition takes no vars +void noVars.mutate({ arbitrary: [1, 2, 3] }); + +// Known limits of the Json constraint. An interface has no implicit index +// signature, and a readonly array is not a Json[]. Use a type alias with +// mutable arrays. These lines pin the limit so a change to it shows up here. +interface InterfaceVars { + a: string; +} +client.define({ + key: 'interface.vars', + // @ts-expect-error an interface does not satisfy Json; use a type alias + request: (_vars: InterfaceVars) => ({ url: 'https://x', data: null }), +}); +client.define({ + key: 'readonly.vars', + // @ts-expect-error readonly string[] is not a Json[] + request: (_vars: { ids: readonly string[] }) => ({ + url: 'https://x', + data: null, + }), +}); +// Aliases with optional fields, nested aliases and mutable arrays pass. +type NestedVars = { inner: { b: number }; ids: string[]; note?: string }; +const nested = client.define({ + key: 'nested.vars', + request: (_vars: NestedVars) => ({ url: 'https://x', data: null }), +}); +void nested.mutate({ inner: { b: 1 }, ids: ['x'] }); + +// The client's define is the same overloaded signature. +declare const define: typeof client.define; +expectType(define); diff --git a/src/delivery.ts b/src/delivery.ts new file mode 100644 index 00000000..cdc12493 --- /dev/null +++ b/src/delivery.ts @@ -0,0 +1,260 @@ +import type { EventSubscription } from 'react-native'; +import type { Spec } from './NativeRNFileUploader'; +import type { AnyDefinition } from './registry'; +import type { + Json, + Meta, + Outcome, + RawResponse, + RequestState, + StateEvent, +} from './types'; + +/** + * The journaled terminal outcome that native emits on `onSettled` and returns + * from `getUnacknowledgedEvents()`. Both carry the same shape, so a live event + * and a replayed one take the same path here. + */ +export type SettledEvent = { + eventId: string; + id: string; + key: string; + vars: Json; + /** Native outcome time, epoch ms. */ + at: number; + attempts: number; + requestId?: string; + /** The entry's real state, for the unhandled-key row. */ + state: RequestState; + bytesSent?: number; + totalBytes?: number; +} & Outcome; + +export const HANDLER_WARNING_MS = 30_000; + +type DeliveryDeps = { + native: Pick; + lookup: (key: string) => AnyDefinition | undefined; + /** Feeds the client's `state` listeners. */ + emitState: (event: StateEvent) => void; + warn?: (message: string, ...rest: unknown[]) => void; + handlerWarningMs?: number; +}; + +export type Delivery = { + /** Subscribes, drains the journal, then goes live. A second call is a no-op. */ + start: () => void; + /** Delivery for `id` waits until this promise has settled. */ + trackMutate: (id: string, pending: Promise) => void; +}; + +const errorMessage = (e: unknown): string => + e instanceof Error ? e.message : String(e); + +/** + * Routes settled outcomes to the definitions' handlers and acknowledges them + * afterwards. Rules: journal before emit is native's job; here it is dedupe by + * eventId, wait for the id's in-flight mutate(), look up the key, run the + * handler, ack after its promise resolves. An unknown key or a rejected + * handler leaves the outcome unacknowledged, so native redelivers it at the + * next launch. + */ +export const createDelivery = ({ + native, + lookup, + emitState, + warn = console.warn, + handlerWarningMs = HANDLER_WARNING_MS, +}: DeliveryDeps): Delivery => { + const seen = new Set(); + const pendingMutates = new Map>(); + let subscription: EventSubscription | undefined; + // Live events that arrive while the journal drains wait here, so replayed + // outcomes deliver first. + const buffer: SettledEvent[] = []; + let live = false; + + const trackMutate = (id: string, pending: Promise): void => { + const settled = pending.then( + () => undefined, + () => undefined, + ); + const prior = pendingMutates.get(id); + const chain = prior ? prior.then(() => settled) : settled; + pendingMutates.set(id, chain); + void chain.then(() => { + if (pendingMutates.get(id) === chain) { + pendingMutates.delete(id); + } + }); + }; + + const ack = async (eventId: string): Promise => { + try { + await native.ackEvents([eventId]); + } catch (e) { + warn(`delivery: ackEvents failed for ${eventId}`, e); + } + }; + + const unhandledRow = (event: SettledEvent): StateEvent => ({ + id: event.id, + key: event.key, + vars: event.vars, + state: event.state, + bytesSent: event.bytesSent ?? 0, + totalBytes: event.totalBytes ?? 0, + attempts: event.attempts, + updatedAt: event.at, + reason: 'unhandled-key', + }); + + const invoke = async ( + event: SettledEvent, + definition: AnyDefinition, + meta: Meta, + ): Promise => { + const { vars } = event; + if (event.kind === 'error') { + await definition.onError?.(event.error, vars, meta); + return; + } + if (event.kind !== 'completed') { + return; + } + const response: RawResponse = event.response ?? { bodyTruncated: false }; + if (!definition.response) { + await definition.onSuccess?.(response, vars, meta); + return; + } + if (response.bodyTruncated) { + await definition.onError?.( + { + errorKind: 'truncated', + message: + 'the response body exceeded the 1 MB cap, so it was not parsed', + response, + }, + vars, + meta, + ); + return; + } + let data: unknown; + try { + const parsed: unknown = + response.body === undefined || response.body === '' + ? undefined + : JSON.parse(response.body); + data = definition.response(parsed); + } catch (e) { + await definition.onError?.( + { errorKind: 'unknown', message: errorMessage(e) }, + vars, + meta, + ); + return; + } + await definition.onSuccess?.(data, vars, meta); + }; + + /** True when the handler settled, so the outcome may be acknowledged. */ + const runHandler = async ( + event: SettledEvent, + definition: AnyDefinition, + ): Promise => { + const meta: Meta = { + id: event.id, + key: event.key, + at: event.at, + attempts: event.attempts, + requestId: event.requestId, + }; + const timer = setTimeout(() => { + warn( + `delivery: the "${event.key}" handler for ${ + event.id + } has not settled after ${ + handlerWarningMs / 1000 + } s. The outcome stays unacknowledged until it does.`, + ); + }, handlerWarningMs); + try { + await invoke(event, definition, meta); + return true; + } catch (e) { + warn( + `delivery: the "${event.key}" handler for ${event.id} rejected. The outcome stays unacknowledged and redelivers at the next launch.`, + e, + ); + return false; + } finally { + clearTimeout(timer); + } + }; + + const deliver = async (event: SettledEvent): Promise => { + if (typeof event?.eventId !== 'string') { + warn('delivery: dropped a settled event without an eventId', event); + return; + } + if (seen.has(event.eventId)) { + return; + } + seen.add(event.eventId); + const pending = pendingMutates.get(event.id); + if (pending) { + await pending; + // The enqueue promise settles before mutate()'s own await and before + // the caller's continuation, both microtasks. A macrotask puts the + // handler after them, so the caller has the id before any handler + // sees it. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + const definition = lookup(event.key); + if (!definition) { + warn( + `delivery: no definition for key "${event.key}" (id ${event.id}). The outcome stays unacknowledged.`, + ); + emitState(unhandledRow(event)); + return; + } + if (event.kind === 'cancelled') { + await ack(event.eventId); + return; + } + if (await runHandler(event, definition)) { + await ack(event.eventId); + } + }; + + const start = (): void => { + if (subscription) { + return; + } + // Subscribe first, so nothing that settles during the drain is missed. + // The eventId dedupe absorbs an event that shows up in both. + subscription = native.onSettled((raw) => { + const event = raw as SettledEvent; + if (live) { + void deliver(event); + } else { + buffer.push(event); + } + }); + void native + .getUnacknowledgedEvents() + .then( + (events) => { + (events as SettledEvent[]).forEach((event) => void deliver(event)); + }, + (e) => warn('delivery: getUnacknowledgedEvents failed', e), + ) + .then(() => { + live = true; + buffer.splice(0).forEach((event) => void deliver(event)); + }); + }; + + return { start, trackMutate }; +}; diff --git a/src/index.ts b/src/index.ts index 28bc49e1..47df09f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,233 +1,183 @@ /** - * Handles HTTP background file uploads from an iOS or Android device. + * Durable HTTP requests and file uploads from an iOS or Android device. The + * consumer defines request kinds with define(), enqueues them with mutate(), + * and receives every outcome through the definition's handlers. */ -import { Platform } from 'react-native'; import type { EventSubscription } from 'react-native'; import NativeRNFileUploader from './NativeRNFileUploader'; +import { chunkPlan } from './chunkPlan'; +import { createDelivery } from './delivery'; import { + createRegistry, + DEFAULT_LIFETIME_MS, + type AnyDefinition, + type Settings, +} from './registry'; +import type { AddListener, - ChunkedUploadOptions, ConfigureOptions, - JournaledEvent, - StartUploadOptions, - UploadId, - UploadSnapshot, + RequestRow, + StateEvent, + UploadClient, } from './types'; -import { chunkPlan } from './chunkPlan'; export * from './types'; export * from './chunkPlan'; -const fileURIPrefix = 'file://'; - /** - * One-time library configuration. Call it at app startup, before an upload - * starts. Android keeps the notification configuration in native storage. Thus - * a worker that WorkManager relaunches with no JS shows the same notification - * text. The call is optional: a field that you do not configure keeps the - * library default. Each call replaces the full configuration. The call does - * nothing on iOS, because iOS has no library notification. + * Builds one client over the native queue. Each client has its own + * definitions and settings. An app needs one; the default export is one. */ -const configure = ({ android }: ConfigureOptions): void => { - NativeRNFileUploader.configure({ ...android }); -}; +export const createUploadClient = (): UploadClient => { + const native = NativeRNFileUploader; + const settings: Settings = { lifetimeMs: DEFAULT_LIFETIME_MS }; + const definitions = new Map(); + // One entry per subscription, not per function, so the same listener + // registered twice is removed one subscription at a time. + const stateListeners = new Set<{ listener: (event: StateEvent) => void }>(); -const normalizePath = (path: string): string => { - if (!path.startsWith(fileURIPrefix)) { - path = fileURIPrefix + path; - } - // Android native takes a plain filesystem path. iOS takes a file:// URL. - return Platform.OS === 'android' ? path.replace(fileURIPrefix, '') : path; -}; + const delivery = createDelivery({ + native, + lookup: (key) => definitions.get(key), + emitState: (event) => + stateListeners.forEach(({ listener }) => listener(event)), + }); + const { define } = createRegistry({ + native, + definitions, + getSettings: () => settings, + trackMutate: delivery.trackMutate, + }); -// Reject malformed chunked input before it crosses the bridge. Then native -// never creates a manifest for an upload that cannot complete. -const validateChunkedOptions = (options: ChunkedUploadOptions): void => { - if (!options.id) { - throw new Error('startUpload: a chunked upload requires a non-empty id'); - } - if (!Array.isArray(options.parts) || options.parts.length === 0) { - throw new Error('startUpload: parts must be a non-empty array'); - } - // The parts must tile the file from byte 0. They must be sorted in - // ascending order, with no gaps and no overlaps. Each plan from chunkPlan - // obeys this by construction. - let expectedStart = 0; - options.parts.forEach(({ url, headers, range }, i) => { - if (typeof url !== 'string' || url.length === 0) { - throw new Error(`startUpload: parts[${i}].url must be a non-empty string`); - } - if ( - headers !== undefined && - (typeof headers !== 'object' || - headers === null || - Array.isArray(headers)) - ) { - throw new Error( - `startUpload: parts[${i}].headers must be a plain object when present`, - ); - } - if ( - !range || - !Number.isInteger(range.start) || - !Number.isInteger(range.end) || - range.start < 0 || - range.start >= range.end - ) { + /** + * One-time setup. Call it at boot, after every define() call. Stores the + * lifetime, the headers provider and the retry defaults, forwards the + * lifetime, retry and Android notification settings to native, then starts + * replaying journaled outcomes. A second call updates the settings and does + * not replay again. Each call replaces the full configuration. + */ + const configure = (options: ConfigureOptions): void => { + const lifetimeMs = options.lifetimeMs ?? DEFAULT_LIFETIME_MS; + if (!Number.isFinite(lifetimeMs) || lifetimeMs <= 0) { throw new Error( - `startUpload: parts[${i}].range must satisfy 0 <= start < end, got ${JSON.stringify( - range, - )}`, + `configure: lifetimeMs must be a positive number, got ${options.lifetimeMs}`, ); } - if (range.start !== expectedStart) { - throw new Error( - i === 0 - ? `startUpload: parts[0].range.start must be 0, got ${range.start}` - : `startUpload: parts must be sorted ascending and tile the file with no gaps or overlaps — parts[${i}].range.start is ${range.start} but parts[${i - 1}].range.end is ${expectedStart}`, - ); + settings.lifetimeMs = lifetimeMs; + settings.headers = options.headers; + settings.retry = options.retry; + const forwarded: Record = { + lifetimeMs, + ...options.android, + }; + if (options.retry !== undefined) { + forwarded.retry = options.retry; } - expectedStart = range.end; - }); - if (!Number.isFinite(options.expiresAt) || options.expiresAt <= 0) { - throw new Error( - `startUpload: expiresAt must be a finite epoch-ms timestamp, got ${options.expiresAt}`, - ); - } -}; - -/** - * Starts an upload to an HTTP endpoint. The behavior depends on options.type. - * 'raw' sends the whole file as one request body. 'chunked' sends the parts - * that the consumer authored (see ChunkedUploadOptions). Returns a promise - * that resolves to the upload's string id. Malformed chunked input throws - * synchronously. Other bad options (for example, a missing or invalid url or - * path) reject. Transport failures and HTTP error responses arrive later as - * 'error' events. - * - * A new call with the same id and identical parts is never an error, at any - * time. The library reconciles: it skips the parts that the server accepted, - * and the other parts continue with the new call's headers. A call with a - * different parts array is a recreate. The library accepts a recreate when - * the upload is stopped, and replaces the bytes. It rejects a recreate while - * the upload runs. - */ -const startUpload = (options: StartUploadOptions): Promise => { - if (options.type === 'chunked') { - validateChunkedOptions(options); - const { path, android, ...rest } = options; - return NativeRNFileUploader.startChunkedUpload({ - ...rest, - ...android, - path: normalizePath(path), - }); - } - - const { path, android, ...rest } = options; - return NativeRNFileUploader.startUpload({ - ...rest, - ...android, - path: normalizePath(path), - }); -}; + native.configure(forwarded); + delivery.start(); + }; -/** - * Releases an upload's native manifest and bytes. Each terminal outcome other - * than an acknowledged 'completed' (expired, error, cancelled) keeps both. - * This lets the consumer resume or recreate the upload. Call this function - * when you want neither. On a raw upload id, it cancels the in-flight request - * and deliberately emits no terminal event. - */ -const removeUpload = (uploadId: string): Promise => - NativeRNFileUploader.removeUpload(uploadId); + /** Pauses the whole queue. No outcome is produced; live rows show 'paused'. */ + const pause = (): Promise => native.pause(); -/** - * Cancels active upload by string ID of the upload. - * - * Upload ID is returned in a promise after a call to startUpload method, - * use it to cancel started upload. - * Event "cancelled" will be fired when upload is cancelled. - * On iOS, resolves true if a matching in-flight upload was found and cancelled, - * false if there was nothing to cancel. Android always resolves true — the - * WorkManager cancel is fire-and-forget and does not report whether it matched. - */ -const cancelUpload = (cancelUploadId: string): Promise => - NativeRNFileUploader.cancelUpload(cancelUploadId); + /** Resumes a paused queue. */ + const resume = (): Promise => native.resume(); -/** - * Listens for one event type across all uploads. Use `data.id` to identify - * the upload. - * Events (id is always the upload ID): - * progress - { id, progress: 0-100 } - * error - { id, error, errorKind?, responseCode?, responseBody?, responseHeaders? } - * cancelled - { id, cancelReason?: 'user' | 'system' } - * completed - { id, responseCode, responseBody, responseHeaders?, eventId? } - */ -const addListener = (( - eventType: 'progress' | 'error' | 'completed' | 'cancelled', - // The payload shape varies per event; the public AddListener overloads carry - // the precise contract, so the internal forwarder stays untyped. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - listener: (data: any) => void, -): EventSubscription => { - switch (eventType) { - case 'progress': - return NativeRNFileUploader.onProgress(listener); - case 'error': - return NativeRNFileUploader.onError(listener); - case 'cancelled': - return NativeRNFileUploader.onCancelled(listener); - case 'completed': - return NativeRNFileUploader.onCompleted(listener); - default: - throw new Error(`Unknown upload event: ${eventType}`); - } -}) as AddListener; + /** + * On a live entry: settles it 'cancelled' with reason 'user', then forgets + * it after the ack. On a settled entry: forgets it now, row and bytes. + */ + const cancel = (id: string): Promise => native.cancel(id); -/** - * Terminal events (completed/error/cancelled) are journaled natively before being - * emitted, so they survive the app being killed or JS reloading. Read them on - * startup, process each, then acknowledge — unacknowledged events are re-delivered - * here on every call until you ack them. - * - * Note: `completed` fires only for 2xx (or a request's `accept` rules); other HTTP - * responses arrive as `error` with `errorKind: 'http'` and the response attached. - */ -const getUnacknowledgedEvents = async (): Promise => - (await NativeRNFileUploader.getUnacknowledgedEvents()) as JournaledEvent[]; + /** Persisted natively. Applies to queued and future entries. */ + const setWifiOnly = (enabled: boolean): Promise => + native.setWifiOnly(enabled); -/** Removes journaled events by eventId once you've processed them. */ -const ackEvents = (eventIds: string[]): Promise => - NativeRNFileUploader.ackEvents(eventIds); + /** + * Merges the patch into the headers of every queued and parked entry, then + * resumes the entries parked on 'awaiting-auth'. This is how a fresh token + * reaches requests that stalled on 401. + */ + const updateHeaders = (patch: Record): Promise => + native.updateHeaders(patch); -/** - * Enumerates uploads the OS still knows about, for reconciling in-flight work on - * boot. Terminal outcomes come from getUnacknowledgedEvents (durable), not here: - * on Android finished work is pruned after ~a day, and on iOS only live tasks are - * listed. - */ -const getAllUploads = async (): Promise => - (await NativeRNFileUploader.getAllUploads()) as UploadSnapshot[]; + /** + * The live rows of the queue, read synchronously from native's in-memory + * index. Works offline. Completed entries leave after their ack. + */ + const getRequests = (filter?: { + key?: string; + id?: string; + }): RequestRow[] => { + const rows = native.getRequests() as RequestRow[]; + if (!filter) { + return rows; + } + return rows.filter( + (row) => + (filter.key === undefined || row.key === filter.key) && + (filter.id === undefined || row.id === filter.id), + ); + }; -const android = { /** - * When the upload progress notification is pressed, it will open the app and fire this event. - * Android only — never fires on iOS. - * @param listener + * Listens for one event type across all requests. Listeners are global; use + * the event's id to tell requests apart. 'state' carries a full RequestRow + * per transition, plus a row with reason 'unhandled-key' for an outcome + * whose key has no definition. 'progress' is byte-weighted. 'attempt' is + * one HTTP attempt before interpretation. */ - addNotificationListener: (listener: () => void): EventSubscription => - NativeRNFileUploader.onNotification(() => listener()), -}; + const addListener = (( + event: 'state' | 'progress' | 'attempt', + // The public AddListener overloads carry the precise payload per event. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (data: any) => void, + ): EventSubscription => { + switch (event) { + case 'state': { + const entry = { listener }; + stateListeners.add(entry); + // One subscription covers the native rows and the JS-synthesized + // unhandled-key rows, so remove() has to drop both. + const subscription = native.onState(listener); + const removeNative = subscription.remove.bind(subscription); + subscription.remove = () => { + stateListeners.delete(entry); + removeNative(); + }; + return subscription; + } + case 'progress': + return native.onProgress(listener); + case 'attempt': + return native.onAttempt(listener); + default: + throw new Error(`addListener: unknown event ${String(event)}`); + } + }) as AddListener; -export default { - configure, - startUpload, - cancelUpload, - removeUpload, - addListener, - getUnacknowledgedEvents, - ackEvents, - getAllUploads, - chunkPlan, - android, + const android = { + /** + * Fires when the Android upload notification is pressed. It never fires + * on iOS. + */ + addNotificationListener: (listener: () => void): EventSubscription => + native.onNotification(() => listener()), + }; + + return { + configure, + define, + pause, + resume, + cancel, + setWifiOnly, + updateHeaders, + getRequests, + addListener, + chunkPlan, + android, + }; }; + +export default createUploadClient(); diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 00000000..03843c4a --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,371 @@ +import type { Spec } from './NativeRNFileUploader'; +import type { + Define, + Defined, + Definition, + FormPart, + Json, + Method, + Part, + RequestDescriptor, + RetryPolicy, +} from './types'; + +export const DEFAULT_LIFETIME_MS = 14 * 24 * 60 * 60 * 1000; +/** `vars` are persisted natively next to every entry. Only they are capped. */ +export const MAX_VARS_BYTES = 4096; + +const METHODS: readonly Method[] = ['POST', 'PUT', 'PATCH', 'DELETE', 'GET']; + +const DESCRIPTOR_KEYS = [ + 'url', + 'method', + 'headers', + 'data', + 'form', + 'file', + 'parts', + 'accept', + 'expiresAt', + 'retry', + 'android', +]; +const PART_KEYS = ['url', 'headers', 'range']; +const FORM_PART_KEYS = ['name', 'contentType', 'string', 'path', 'fileName']; + +/** The JS-side settings that `configure()` stores. */ +export type Settings = { + lifetimeMs: number; + headers?: () => Record; + retry?: Partial; +}; + +/** What crosses to native `enqueue()`. */ +export type EnqueueEntry = { + id: string; + key: string; + vars: Json; + descriptor: RequestDescriptor; +}; + +// The registry stores definitions of every shape under one map. The generic +// parameters are enforced at define() and re-applied by the delivery layer. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyDefinition = Definition; + +type RegistryDeps = { + native: Pick; + definitions: Map; + getSettings: () => Settings; + /** Called with every enqueue promise, so delivery can wait for it. */ + trackMutate: (id: string, pending: Promise) => void; + warn?: (message: string) => void; + now?: () => number; +}; + +export type Registry = { + define: Define; +}; + +declare const __DEV__: boolean | undefined; +declare const process: { env?: { NODE_ENV?: string } } | undefined; + +/** React Native sets `__DEV__`. Other hosts (Jest) fall back to NODE_ENV. */ +export const isDev = (): boolean => { + if (typeof __DEV__ === 'boolean') { + return __DEV__; + } + return ( + typeof process === 'undefined' || process?.env?.NODE_ENV !== 'production' + ); +}; + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** UTF-8 length of a string that JSON.stringify produced. */ +export const utf8ByteLength = (s: string): number => { + let bytes = 0; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + // A surrogate pair encodes one 4-byte code point. + bytes += 4; + i++; + } else { + bytes += 3; + } + } + return bytes; +}; + +/** RFC 4122 version 4, from Math.random. Ids only need to be unique per app. */ +export const uuidV4 = (): string => + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16); + const v = c === 'x' ? r : (r % 4) + 8; + return v.toString(16); + }); + +const editDistance = (a: string, b: string): number => { + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const row = [i]; + for (let j = 1; j <= b.length; j++) { + const same = a[i - 1] === b[j - 1] ? 0 : 1; + row[j] = Math.min( + (prev[j] ?? 0) + 1, + (row[j - 1] ?? 0) + 1, + (prev[j - 1] ?? 0) + same, + ); + } + prev = row; + } + return prev[b.length] ?? 0; +}; + +// TypeScript does not check an inferred arrow return for excess properties, +// so `header:` in place of `headers:` compiles. Rejecting unknown keys here +// turns that silent drop into a mutate() rejection. +const rejectUnknownKeys = ( + value: Record, + known: readonly string[], + where: string, +): void => { + Object.keys(value).forEach((key) => { + if (known.includes(key)) { + return; + } + const guess = known.find( + (candidate) => + editDistance(key.toLowerCase(), candidate.toLowerCase()) <= 2, + ); + throw new Error( + `mutate: unknown ${where} field "${key}".${ + guess ? ` Did you mean "${guess}"?` : '' + }`, + ); + }); +}; + +// The parts must tile the file from byte 0. They must be sorted in ascending +// order, with no gaps and no overlaps. Each plan from chunkPlan obeys this by +// construction. +const validateParts = (parts: unknown): void => { + if (!Array.isArray(parts) || parts.length === 0) { + throw new Error('mutate: parts must be a non-empty array'); + } + let expectedStart = 0; + (parts as Part[]).forEach((part, i) => { + if (!isPlainObject(part)) { + throw new Error(`mutate: parts[${i}] must be an object`); + } + rejectUnknownKeys(part, PART_KEYS, `parts[${i}]`); + const { url, headers, range } = part; + if (typeof url !== 'string' || url.length === 0) { + throw new Error(`mutate: parts[${i}].url must be a non-empty string`); + } + if (headers !== undefined && !isPlainObject(headers)) { + throw new Error( + `mutate: parts[${i}].headers must be a plain object when present`, + ); + } + if ( + !range || + !Number.isInteger(range.start) || + !Number.isInteger(range.end) || + range.start < 0 || + range.start >= range.end + ) { + throw new Error( + `mutate: parts[${i}].range must satisfy 0 <= start < end, got ${JSON.stringify( + range, + )}`, + ); + } + if (range.start !== expectedStart) { + throw new Error( + i === 0 + ? `mutate: parts[0].range.start must be 0, got ${range.start}` + : `mutate: parts must be sorted ascending and tile the file with no gaps or overlaps. parts[${i}].range.start is ${ + range.start + } but parts[${i - 1}].range.end is ${expectedStart}`, + ); + } + expectedStart = range.end; + }); +}; + +const validateForm = (form: unknown): void => { + if (!Array.isArray(form) || form.length === 0) { + throw new Error('mutate: form must be a non-empty array'); + } + (form as FormPart[]).forEach((part, i) => { + if (!isPlainObject(part)) { + throw new Error(`mutate: form[${i}] must be an object`); + } + rejectUnknownKeys(part, FORM_PART_KEYS, `form[${i}]`); + if (typeof part.name !== 'string' || part.name.length === 0) { + throw new Error(`mutate: form[${i}].name must be a non-empty string`); + } + if (typeof part.contentType !== 'string' || part.contentType.length === 0) { + throw new Error( + `mutate: form[${i}].contentType must be a non-empty string`, + ); + } + const hasString = typeof (part as { string?: unknown }).string === 'string'; + const hasPath = typeof (part as { path?: unknown }).path === 'string'; + if (hasString === hasPath) { + throw new Error( + `mutate: form[${i}] must set exactly one of string, path`, + ); + } + }); +}; + +/** + * Rejects a malformed descriptor before it crosses the bridge. Then native + * never persists an entry that cannot run. + */ +export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { + if (!isPlainObject(descriptor)) { + throw new Error('mutate: request() must return a descriptor object'); + } + rejectUnknownKeys(descriptor, DESCRIPTOR_KEYS, 'descriptor'); + const d = descriptor as RequestDescriptor; + const kinds = (['data', 'form', 'file'] as const).filter( + (kind) => d[kind] !== undefined, + ); + if (kinds.length !== 1) { + throw new Error( + `mutate: the descriptor must set exactly one of data, form, file; got ${ + kinds.length === 0 ? 'none' : kinds.join(', ') + }`, + ); + } + if (d.parts !== undefined && d.file === undefined) { + throw new Error('mutate: parts requires file'); + } + if (d.url === undefined) { + if (d.parts === undefined) { + throw new Error('mutate: url is required unless parts is set'); + } + } else if (typeof d.url !== 'string' || d.url.length === 0) { + throw new Error('mutate: url must be a non-empty string'); + } + if (d.method !== undefined && !METHODS.includes(d.method)) { + throw new Error( + `mutate: method must be one of ${METHODS.join(', ')}, got ${String( + d.method, + )}`, + ); + } + if (d.headers !== undefined && !isPlainObject(d.headers)) { + throw new Error('mutate: headers must be a plain object when present'); + } + if (d.file !== undefined && (typeof d.file !== 'string' || !d.file)) { + throw new Error('mutate: file must be a non-empty path'); + } + if (d.form !== undefined) { + validateForm(d.form); + } + if (d.parts !== undefined) { + validateParts(d.parts); + } + if ( + d.expiresAt !== undefined && + (!Number.isFinite(d.expiresAt) || d.expiresAt <= 0) + ) { + throw new Error( + `mutate: expiresAt must be a finite epoch-ms timestamp, got ${d.expiresAt}`, + ); + } + return d; +}; + +/** + * Holds the definitions of one client and builds `define()`. Every `mutate()` + * validates `vars` and the descriptor, merges the configured headers under the + * descriptor's, defaults `expiresAt`, and hands the entry to native. + */ +export const createRegistry = ({ + native, + definitions, + getSettings, + trackMutate, + warn = console.warn, + now = Date.now, +}: RegistryDeps): Registry => { + // The overloads on Define keep the with-parser and without-parser shapes + // apart for callers. One implementation serves both. + const define = (( + definition: Definition, + ): Defined => { + const { key } = definition; + if (typeof key !== 'string' || key.length === 0) { + throw new Error('define: key must be a non-empty string'); + } + if (typeof definition.request !== 'function') { + throw new Error(`define: "${key}" needs a request function`); + } + // Hot reload re-evaluates modules, so a duplicate key replaces instead of + // throwing. The warning catches two modules that share a key by mistake. + if (definitions.has(key) && isDev()) { + warn( + `define: "${key}" is already defined. The new definition replaces it.`, + ); + } + definitions.set(key, definition); + + const mutate = async ( + input: V | undefined, + options?: { id?: string }, + ): Promise<{ id: string }> => { + // A no-vars definition calls mutate() with nothing; native stores null. + const vars = (input === undefined ? null : input) as V; + const serialized = JSON.stringify(vars); + if (serialized === undefined) { + throw new Error('mutate: vars must be a JSON value'); + } + const bytes = utf8ByteLength(serialized); + if (bytes > MAX_VARS_BYTES) { + throw new Error( + `mutate: vars for "${key}" is ${bytes} bytes; the limit is ${MAX_VARS_BYTES}`, + ); + } + if (options?.id !== undefined && !options.id) { + throw new Error('mutate: id must be a non-empty string when given'); + } + // The current definition under this key, so a replaced definition's + // request() is the one that runs. + const current = (definitions.get(key) ?? definition) as Definition; + const descriptor = validateDescriptor(current.request(vars)); + const settings = getSettings(); + const provided = settings.headers?.() ?? {}; + if (!isPlainObject(provided)) { + throw new Error('mutate: configure().headers() must return an object'); + } + const entry: EnqueueEntry = { + id: options?.id ?? uuidV4(), + key, + vars, + descriptor: { + ...descriptor, + headers: { ...provided, ...descriptor.headers }, + expiresAt: descriptor.expiresAt ?? now() + settings.lifetimeMs, + }, + }; + const pending = native.enqueue(entry); + trackMutate(entry.id, pending); + return { id: await pending }; + }; + + return { key, mutate } as Defined; + }) as Define; + + return { define }; +}; diff --git a/src/types.ts b/src/types.ts index 77c186ea..deba6bcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,170 +1,254 @@ -import { EventSubscription } from 'react-native'; +import type { EventSubscription } from 'react-native'; -export interface EventData { - id: string; -} +/** + * Any JSON value. `vars` and `data` must be JSON, because native persists them. + * A vars type has to be a `type` alias, not an `interface`: only aliases get + * the implicit index signature that this recursive type asks for. Fields must + * be mutable arrays, not `readonly T[]`. + */ +export type Json = + | string + | number + | boolean + | null + | Json[] + | { [k: string]: Json }; -export interface ProgressData extends EventData { - progress: number; -} +export type Method = 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'GET'; /** - * `expired` means that the upload's `expiresAt` time passed before the server - * accepted every part. The library keeps the manifest and the bytes. Thus a - * new `startUpload` call with a later deadline resumes the upload. + * Why a request failed. `http` means the server answered and the status was + * not accepted. `network` is a transport failure. `file` means the payload is + * missing on disk, so a retry can never succeed. `expired` means `expiresAt` + * passed. `truncated` means the response body hit the 1 MB cap, so the + * `response` parser could not run. `unknown` covers a parser throw. */ -export type ErrorKind = 'http' | 'network' | 'file' | 'expired' | 'unknown'; +export type ErrorKind = + | 'http' + | 'network' + | 'file' + | 'expired' + | 'truncated' + | 'unknown'; +/** `user` for an explicit `cancel()`. `system` for an OS-initiated stop. */ export type CancelReason = 'user' | 'system'; -export type UploadId = string; +/** + * A non-2xx response to treat as success. `bodyIncludes` narrows the rule by + * a response-body substring. This is necessary when one status has several + * meanings, and only the message shows the difference. + */ +export type AcceptRule = { status: number; bodyIncludes?: string }; /** - * Fields carried by every terminal event (`completed` / `error` / `cancelled`). - * - * The native side emits the journal entry itself, so a live terminal event is - * the very same object `getUnacknowledgedEvents()` returns — `eventId` included, - * which is what lets you `ackEvents([eventId])` immediately after handling a - * live event instead of waiting to rediscover it on the next launch. + * One multipart/form-data field. A `path` part is a file. The library copies + * the file into its own directory at `mutate()`. */ -export interface TerminalEventData extends EventData { - eventId: string; - type: 'completed' | 'error' | 'cancelled'; - /** Epoch milliseconds, stamped natively when the outcome occurred. */ - timestamp: number; - /** - * The response, when one was received. Absent for a transport failure (the - * request never reached the server), so always narrow before using it. - */ - responseCode?: number; - responseBody?: string; - /** True when `responseBody` hit the 64KB cap and was truncated. */ - responseBodyTruncated?: boolean; - responseHeaders?: Record; -} +export type FormPart = { name: string; contentType: string } & ( + | { string: string } + | { path: string; fileName?: string } +); -/** A 2xx response, or one that matched an `accept` rule on the request. */ -export interface CompletedData extends TerminalEventData { - type: 'completed'; -} +/** + * One part of a chunked upload. The library sends the file bytes + * [range.start, range.end) as the body of a request to `url`. `headers` merge + * over the descriptor's headers. The range end is exclusive. + */ +export type Part = { + url: string; + headers?: Record; + range: { start: number; end: number }; +}; -export interface ErrorData extends TerminalEventData { - type: 'error'; - error: string; - /** - * Why it failed. `http` means the server responded and the status was not - * accepted (the response fields above are populated). `file` means the payload - * is missing or unreadable on disk, so retrying can never succeed. - */ - errorKind?: ErrorKind; - /** - * Chunked uploads: the index into `parts` of the failing part, when one - * part's response caused the error. - */ - partIndex?: number; -} +export type RetryPolicy = { + backoff: { baseMs: number; maxMs: number; jitter: number }; + /** HTTP statuses in the 4xx range that retry instead of settling. */ + terminalHttp: { exempt: number[] }; +}; -export interface CancelledData extends TerminalEventData { - type: 'cancelled'; - /** `user` for an explicit `cancelUpload`; `system` for an OS-initiated stop. */ - cancelReason?: CancelReason; -} +/** What `request(vars)` returns. Native persists it next to `vars`. */ +export type RequestDescriptor = { + /** Required unless `parts` is set. */ + url?: string; + /** Default POST. With `parts` it applies to every part. */ + method?: Method; + /** Merged over `configure().headers()`. Every part inherits the result. */ + headers?: Record; + /** JSON body. Exactly one of `data`, `form`, `file` must be set. */ + data?: Json; + /** multipart/form-data body. */ + form?: FormPart[]; + /** Whole file body. Copied. Moved when `parts` is set. */ + file?: string; + /** Chunked over `file`. Each part sends its own byte range. */ + parts?: Part[]; + accept?: AcceptRule[]; + /** Epoch ms. Default now + `lifetimeMs`. */ + expiresAt?: number; + retry?: Partial; + android?: { noNotification?: boolean }; +}; /** - * A terminal event journaled natively before being emitted, so it survives app - * death and JS reloads. Read via `getUnacknowledgedEvents`, process, then - * acknowledge via `ackEvents`. Discriminate on `type`. + * The last response of a completed request. `status` is absent for a chunked + * completion, because no single response represents N parts. `body` holds up + * to 1 MB; `bodyTruncated` says whether the cap cut it. */ -export type JournaledEvent = CompletedData | ErrorData | CancelledData; - -/** A snapshot of an upload the OS still knows about (from getAllUploads). */ -export interface UploadSnapshot { - id: UploadId; - state: 'pending' | 'running' | 'completed' | 'error' | 'cancelled'; - /** iOS: bytes sent so far. Android: a chunked upload's accepted bytes. */ - bytesSent?: number; - /** The total payload bytes. On iOS always; on Android for chunked uploads. */ - totalBytes?: number; -} +export type RawResponse = { + status?: number; + headers?: Record; + body?: string; + bodyTruncated: boolean; +}; -export type UploadOptions = { - url: string; - path: string; - method: 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'; - id?: string; - headers?: { - [index: string]: string; - }; - // Whether the upload should wait for wifi before starting - wifiOnly?: boolean; - accept?: AcceptRule[]; - // Android options that change behavior. Notification text is not a - // per-upload option. Set it one time with configure(). - android?: Partial; -} & RawUploadOptions; +/** + * `response` is set for `http` and `truncated`. `partIndex` is the index of + * the failing part of a chunked upload. + */ +export type OutcomeError = { + errorKind: ErrorKind; + message: string; + response?: RawResponse; + partIndex?: number; +}; /** - * A non-2xx response to treat as success. `bodyIncludes` narrows the rule by - * a response-body substring. This is necessary when one status has several - * meanings, and only the message shows the difference (our backend's 409). A - * non-2xx response that matches no rule emits an 'error' event with errorKind - * 'http'. + * Handler context. `at` is the native outcome time. `requestId` is the last + * attempt's X-Request-Id. */ -export type AcceptRule = { - status: number; - bodyIncludes?: string; +export type Meta = { + id: string; + key: string; + at: number; + attempts: number; + requestId?: string; }; -export type ChunkedUploadOptions = { - type: 'chunked'; - /** Required. The consumer's durable id. */ +export type Outcome = + | { kind: 'completed'; response: RawResponse } + | { kind: 'error'; error: OutcomeError } + | { kind: 'cancelled'; cancelReason: CancelReason }; + +export type RequestState = + | 'queued' + | 'running' + | 'awaiting-auth' + | 'paused' + | 'completed' + | 'error' + | 'cancelled'; + +/** + * One row of the native queue, as `getRequests()` and `state` events carry it. + * `vars` is `Json`, because the row does not know its definition. Narrow it + * with a cast before reading a field: `(row.vars as { captureId?: string })`. + */ +export type RequestRow = { id: string; - /** - * The single source file. The library takes ownership: at startUpload it - * renames the file into the library's own directory (an O(1) move). It - * deletes the file only after you acknowledge a 'completed' terminal event. - * If you must keep the file, copy it first. A keep-the-file mode is - * deliberately not part of the library. - */ - path: string; - /** - * The consumer authors this one time. The library sends the file bytes - * [range.start, range.end) as the body of a PUT to `url`, with `headers` - * unchanged. The library never derives or edits a protocol field. - */ - parts: Array<{ - url: string; - /** These headers include Content-Range, Content-Type, and auth. */ - headers: Record; - /** Byte offsets. The end is exclusive. */ - range: { start: number; end: number }; - }>; - accept?: AcceptRule[]; - /** Epoch ms. Required. After this time: terminal error, errorKind 'expired'. */ - expiresAt: number; - wifiOnly?: boolean; - android?: Partial; + key: string; + vars: Json; + state: RequestState; + bytesSent: number; + totalBytes: number; + attempts: number; + updatedAt: number; }; -export type StartUploadOptions = UploadOptions | ChunkedUploadOptions; +/** + * A `state` event. `reason: 'unhandled-key'` reports an outcome whose key has + * no definition. The row carries the entry's real state, and the library keeps + * the entry unacknowledged. + */ +export type StateEvent = RequestRow & { reason?: 'unhandled-key' }; -export type AndroidOnlyUploadOptions = { - /** - * Uploads this file without a progress notification. Default false. - * - * The notification is what puts the upload's worker in foreground mode, which - * is how it survives Doze and memory pressure, so a silent upload is easier - * for the OS to defer or stop and re-run. Reserve it for payloads small enough - * that a restart costs nothing, and keep it off for anything a user would - * expect to see progress for. - */ - noNotification?: boolean; +export type ProgressEvent = { + id: string; + bytesSent: number; + totalBytes: number; +}; + +/** One HTTP attempt, before the library interprets it. Response body is capped at 4 KB. */ +export type AttemptEvent = { + id: string; + key: string; + requestId: string; + attempt: number; + url: string; + method: Method; + partIndex?: number; + outcome: 'completed' | 'error' | 'cancelled'; + httpCode?: number; + responseBody?: string; + responseBodyTruncated?: boolean; + responseHeaders?: Record; + errorKind?: ErrorKind; + errorMessage?: string; + cancelReason?: CancelReason; + /** Native stamp, epoch ms. */ + at: number; }; -export type RawUploadOptions = { - type: 'raw'; +type DefinitionBase = { + /** Persisted with every entry, so rename it with care. */ + key: string; + /** Runs one time, at `mutate()`. */ + request: (vars: V) => RequestDescriptor; + onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise; +}; + +/** A definition with a parser. `onSuccess` receives what `response` returns. */ +export type DefinitionWithResponse = DefinitionBase & { + /** Parses the JSON body (`undefined` when there is none) before `onSuccess`. */ + response: (raw: unknown) => T; + onSuccess?: (data: T, vars: V, meta: Meta) => void | Promise; +}; + +/** A definition without a parser. `onSuccess` receives the `RawResponse`. */ +export type DefinitionWithoutResponse = DefinitionBase & { + response?: undefined; + onSuccess?: (data: RawResponse, vars: V, meta: Meta) => void | Promise; +}; + +/** + * One request kind. `key` is persisted with every entry, so rename it with + * care. `request` runs one time, at `mutate()`. `response` parses the JSON + * body before `onSuccess`. Without `response`, `onSuccess` receives the + * `RawResponse`, and the two shapes are kept apart so that an `onSuccess` + * annotated with another type does not compile. + */ +export type Definition = + | DefinitionWithResponse + | DefinitionWithoutResponse; + +/** + * What `define()` returns. `mutate()` resolves when native has persisted the + * entry. When `V` is `null` (a `request` that takes no vars), `mutate()` takes + * no arguments. `T` is carried so a `Defined` names the response type its + * handlers see, even though `mutate()` itself does not use it. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type Defined = { + key: string; + mutate: [V] extends [null] + ? (vars?: null, options?: { id?: string }) => Promise<{ id: string }> + : (vars: V, options?: { id?: string }) => Promise<{ id: string }>; }; +/** + * `define()`. `V` infers from the `request` parameter and defaults to `null` + * when `request` declares none. `T` infers from the `response` return type. + * Without `response`, the handlers see the `RawResponse`. + */ +export interface Define { + ( + definition: DefinitionWithResponse, + ): Defined; + ( + definition: DefinitionWithoutResponse, + ): Defined; +} + /** * The text and the identity of the Android upload progress notification. Set * it one time with `configure()`. The library keeps it in native storage. Thus @@ -181,24 +265,36 @@ export type AndroidNotificationConfig = { }; export type ConfigureOptions = { + /** Default 14 days. Sets the default `expiresAt` of every entry. */ + lifetimeMs?: number; + /** Defaults: base 1 s, max 2 h, jitter 0.2, exempt [404]. */ + retry?: Partial; + /** Called at `mutate()`. The descriptor's headers merge over the result. */ + headers?: () => Record; android?: Partial; }; export interface AddListener { - ( - event: 'progress', - callback: (data: ProgressData) => void, - ): EventSubscription; - - (event: 'error', callback: (data: ErrorData) => void): EventSubscription; - - ( - event: 'completed', - callback: (data: CompletedData) => void, - ): EventSubscription; - - ( - event: 'cancelled', - callback: (data: CancelledData) => void, - ): EventSubscription; + (event: 'state', listener: (e: StateEvent) => void): EventSubscription; + (event: 'progress', listener: (e: ProgressEvent) => void): EventSubscription; + (event: 'attempt', listener: (e: AttemptEvent) => void): EventSubscription; } + +export type UploadClient = { + configure: (options: ConfigureOptions) => void; + define: Define; + pause: () => Promise; + resume: () => Promise; + cancel: (id: string) => Promise; + setWifiOnly: (enabled: boolean) => Promise; + updateHeaders: (patch: Record) => Promise; + getRequests: (filter?: { key?: string; id?: string }) => RequestRow[]; + addListener: AddListener; + chunkPlan: ( + sizeBytes: number, + opts?: { min?: number; max?: number }, + ) => Array<{ start: number; end: number }>; + android: { + addNotificationListener: (listener: () => void) => EventSubscription; + }; +}; From 6c2ae5c0db9a03a48ecf832aba66728957617caf Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Wed, 9 Sep 2026 10:43:16 -0400 Subject: [PATCH 02/22] Address slice 1 review: per-id delivery order, cancelled ack, validation Review findings on the JS layer, each with a test: - Outcomes of one id now deliver in order, one handler at a time, through a per-id promise lane. Different ids stay concurrent. - A cancelled outcome acks before the definition lookup, so an entry whose key was renamed still frees its bytes. - A settled event without a string key, kind and id is dropped with one warning per eventId, neither acked nor emitted (a v9-shaped journal). - mutate() resolves with the entry id it tracked, not native's return. - Nested descriptor objects (retry, accept, android, part range) reject unknown keys and wrong value shapes, so a typo cannot silently fall back to the transient default. - Header merge matches names without regard to case; the descriptor's spelling and value win. - A throwing state listener is caught and warned, and does not block the other listeners or the ack. - CHANGELOG lists the removed UploadId type. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 9 +-- README.md | 2 +- src/__tests__/client.test.ts | 43 +++++++++++++ src/__tests__/delivery.test.ts | 108 ++++++++++++++++++++++++++++++++ src/__tests__/registry.test.ts | 90 ++++++++++++++++++++++++++- src/delivery.ts | 90 ++++++++++++++++++++------- src/index.ts | 15 ++++- src/registry.ts | 109 +++++++++++++++++++++++++++++++-- src/types.ts | 5 +- 9 files changed, 434 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e128ab1c..e6fde784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,8 +50,9 @@ Added: 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; an outcome for an id waits for that - id's in-flight `mutate()`; an outcome whose key has no definition stays +- **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 @@ -65,8 +66,8 @@ Removed: names, with their `ProgressData`, `CompletedData`, `ErrorData`, `CancelledData`, `EventData`, `TerminalEventData`, `JournaledEvent`, `UploadSnapshot`, `UploadOptions`, `ChunkedUploadOptions`, - `StartUploadOptions`, `AndroidOnlyUploadOptions`, and `RawUploadOptions` - types. + `StartUploadOptions`, `AndroidOnlyUploadOptions`, `RawUploadOptions`, and + `UploadId` types. ## 9.0.1 diff --git a/README.md b/README.md index cae49820..ccd7e4a3 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ TypeScript does not flag a misspelled key on an inferred arrow return. | --- | --- | | `url` | Required unless `parts` is set. | | `method` | `POST` (default), `PUT`, `PATCH`, `DELETE`, `GET`. With `parts` it applies to every part. | -| `headers` | Merged over `configure().headers()`. Every chunked part inherits the result. | +| `headers` | Merged over `configure().headers()`, names matched without regard to case. Every chunked part inherits the result. | | `data` | JSON body. | | `form` | `multipart/form-data`: `[{ name, contentType, string }]` or `[{ name, contentType, path, fileName? }]`. File parts are copied. | | `file` | Whole file body. Copied. Moved when `parts` is set. | diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index d5e828f8..22954cf8 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -400,6 +400,49 @@ describe('addListener', () => { warn.mockRestore(); }); + it('keeps the other state listeners and later deliveries when one listener throws', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createUploadClient(); + const second = jest.fn(); + const onSuccess = jest.fn(); + client.define({ + key: 'known', + request: (_v: null) => ({ url: 'https://x', data: 1 }), + onSuccess, + }); + client.addListener('state', () => { + throw new Error('listener down'); + }); + client.addListener('state', second); + client.configure({}); + await flush(); + const settled = (eventId: string, key: string) => ({ + eventId, + id: 'x', + key, + vars: null, + at: 5, + attempts: 1, + kind: 'completed', + response: { bodyTruncated: false }, + state: 'completed', + }); + fire('settled', settled('e1', 'nobody')); + await flush(); + expect(second).toHaveBeenCalledWith( + expect.objectContaining({ id: 'x', reason: 'unhandled-key' }), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/state listener threw/), + expect.any(Error), + ); + fire('settled', settled('e2', 'known')); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledWith(['e2']); + warn.mockRestore(); + }); + it('rejects an unknown event name', () => { const client = createUploadClient(); expect(() => (client.addListener as any)('completed', jest.fn())).toThrow( diff --git a/src/__tests__/delivery.test.ts b/src/__tests__/delivery.test.ts index c880b16c..c3b0ab4c 100644 --- a/src/__tests__/delivery.test.ts +++ b/src/__tests__/delivery.test.ts @@ -264,7 +264,115 @@ describe('ordering against mutate()', () => { }); }); +describe('ordering per id', () => { + it('runs the outcomes of one id one at a time, in order, without blocking another id', async () => { + const gate = deferred(); + const started: string[] = []; + const { start, native } = setup( + { + k: { + key: 'k', + request: jest.fn(), + onSuccess: ( + _d: unknown, + _v: unknown, + meta: { id: string; at: number }, + ) => { + started.push(`${meta.id}@${meta.at}`); + return meta.at === 1 ? gate.promise : undefined; + }, + }, + }, + [ + completed({ eventId: 'a', id: 'u1', at: 1 }), + completed({ eventId: 'b', id: 'u1', at: 2 }), + completed({ eventId: 'c', id: 'u2', at: 3 }), + ], + ); + start(); + await flush(); + expect(started).toEqual(['u1@1', 'u2@3']); + expect(native.ackEvents).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledWith(['c']); + gate.resolve(); + await flush(); + expect(started).toEqual(['u1@1', 'u2@3', 'u1@2']); + expect(native.ackEvents).toHaveBeenCalledWith(['a']); + expect(native.ackEvents).toHaveBeenCalledWith(['b']); + }); + + it('still delivers the next outcome of an id after the previous handler rejected', async () => { + const onSuccess = jest.fn(); + const { start, native } = setup( + { + k: { + key: 'k', + request: jest.fn(), + onSuccess: (_d: unknown, _v: unknown, meta: { at: number }) => { + onSuccess(meta.at); + if (meta.at === 1) { + throw new Error('first down'); + } + }, + }, + }, + [completed({ eventId: 'a', at: 1 }), completed({ eventId: 'b', at: 2 })], + ); + start(); + await flush(); + expect(onSuccess.mock.calls).toEqual([[1], [2]]); + expect(native.ackEvents).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledWith(['b']); + }); +}); + +describe('malformed event', () => { + it('drops a v9-shaped entry, warns once for its eventId, and neither acks nor emits', async () => { + const onSuccess = jest.fn(); + const { start, emit, native, warn, stateEvents } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + const legacy = { + eventId: 'v9-1', + id: 'u1', + type: 'completed', + timestamp: 5, + } as unknown as SettledEvent; + emit(legacy); + emit(legacy); + await flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(native.ackEvents).not.toHaveBeenCalled(); + expect(stateEvents).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/malformed settled event v9-1/), + legacy, + ); + }); +}); + describe('unknown key', () => { + it('acks a cancelled outcome whose key has no definition and emits no row', async () => { + const { start, emit, native, stateEvents } = setup({}); + start(); + await flush(); + emit( + completed({ + key: 'gone', + kind: 'cancelled', + state: 'cancelled', + response: undefined, + cancelReason: 'user', + }), + ); + await flush(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + expect(stateEvents).toEqual([]); + }); + it('emits an unhandled-key state row and does not ack', async () => { const { start, emit, native, stateEvents, warn } = setup({}); start(); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 594283f7..dd1d7a16 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -104,12 +104,12 @@ describe('mutate', () => { expect(lastEntry()).toMatchObject({ id: 'fixed', vars: null }); }); - it('resolves with the id native returns', async () => { + it('resolves with the entry id, not the id native returns', async () => { const { define, enqueue } = setup(); enqueue.mockResolvedValueOnce('native-id'); const create = define({ key: 'k', request: jsonPost }); await expect(create.mutate({ n: 1 }, { id: 'mine' })).resolves.toEqual({ - id: 'native-id', + id: 'mine', }); }); @@ -311,6 +311,76 @@ describe('mutate', () => { ).rejects.toThrow(/unknown form\[0\] field "filename".*"fileName"/); }); + it('rejects an unknown field inside retry, accept, android or a part range', async () => { + const base = { url: 'https://x', data: {} }; + await expect( + mutateWith({ ...base, retry: { terminalHTTP: {} } }).promise, + ).rejects.toThrow(/unknown retry field "terminalHTTP".*"terminalHttp"/); + await expect( + mutateWith({ ...base, retry: { backoff: { base: 1 } } }).promise, + ).rejects.toThrow(/unknown retry\.backoff field "base".*"baseMs"/); + await expect( + mutateWith({ ...base, retry: { terminalHttp: { exmpt: [] } } }).promise, + ).rejects.toThrow(/unknown retry\.terminalHttp field "exmpt".*"exempt"/); + await expect( + mutateWith({ ...base, accept: [{ statsu: 409 }] }).promise, + ).rejects.toThrow(/unknown accept\[0\] field "statsu".*"status"/); + await expect( + mutateWith({ ...base, android: { noNotifications: true } }).promise, + ).rejects.toThrow(/unknown android field "noNotifications"/); + await expect( + mutateWith({ + file: '/f', + parts: [{ url: 'https://p', range: { start: 0, end: 1, length: 1 } }], + }).promise, + ).rejects.toThrow(/unknown parts\[0\]\.range field "length"/); + }); + + it('rejects the wrong value shape inside retry, accept and android', async () => { + const base = { url: 'https://x', data: {} }; + await expect(mutateWith({ ...base, accept: {} }).promise).rejects.toThrow( + /accept must be an array/, + ); + await expect( + mutateWith({ ...base, accept: [{ status: '409' }] }).promise, + ).rejects.toThrow(/accept\[0\]\.status must be a number/); + await expect( + mutateWith({ ...base, accept: [{ status: 409, bodyIncludes: 5 }] }) + .promise, + ).rejects.toThrow(/accept\[0\]\.bodyIncludes must be a string/); + await expect(mutateWith({ ...base, retry: [] }).promise).rejects.toThrow( + /retry must be a plain object/, + ); + await expect( + mutateWith({ ...base, retry: { terminalHttp: { exempt: [404, 'x'] } } }) + .promise, + ).rejects.toThrow( + /retry\.terminalHttp\.exempt must be an array of numbers/, + ); + await expect( + mutateWith({ ...base, android: { noNotification: 'yes' } }).promise, + ).rejects.toThrow(/android\.noNotification must be a boolean/); + }); + + it('accepts valid retry, accept and android shapes', async () => { + await expect( + mutateWith({ + url: 'https://x', + data: {}, + retry: { + backoff: { baseMs: 1000, maxMs: 60_000, jitter: 0.2 }, + terminalHttp: { exempt: [] }, + }, + accept: [{ status: 409 }, { status: 400, bodyIncludes: 'dup' }], + android: { noNotification: true }, + }).promise, + ).resolves.toBeDefined(); + await expect( + mutateWith({ url: 'https://x', data: {}, retry: {}, accept: [] }) + .promise, + ).resolves.toBeDefined(); + }); + it('never reaches native on a rejected descriptor', async () => { const { promise, enqueue } = mutateWith({ data: {} }); await expect(promise).rejects.toThrow(); @@ -415,6 +485,22 @@ describe('mutate', () => { }); }); + it('matches header names without regard to case and keeps the descriptor spelling', async () => { + const { define, lastEntry } = setup({ + headers: () => ({ Authorization: 'a' }), + }); + const send = define({ + key: 'k', + request: (_vars: null) => ({ + url: 'https://x', + data: {}, + headers: { authorization: 'b' }, + }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.headers).toEqual({ authorization: 'b' }); + }); + it('sends the provider headers alone when the descriptor has none', async () => { const { define, lastEntry } = setup({ headers: () => ({ A: '1' }) }); const send = define({ diff --git a/src/delivery.ts b/src/delivery.ts index cdc12493..38b56d25 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -54,8 +54,9 @@ const errorMessage = (e: unknown): string => /** * Routes settled outcomes to the definitions' handlers and acknowledges them * afterwards. Rules: journal before emit is native's job; here it is dedupe by - * eventId, wait for the id's in-flight mutate(), look up the key, run the - * handler, ack after its promise resolves. An unknown key or a rejected + * eventId, drop a malformed event, run one id's outcomes in order, wait for + * the id's in-flight mutate(), ack a cancelled outcome, look up the key, run + * the handler, ack after its promise resolves. An unknown key or a rejected * handler leaves the outcome unacknowledged, so native redelivers it at the * next launch. */ @@ -68,6 +69,9 @@ export const createDelivery = ({ }: DeliveryDeps): Delivery => { const seen = new Set(); const pendingMutates = new Map>(); + // The tail of each id's delivery chain. Outcomes of one id run in order, + // one handler at a time. Different ids run concurrently. + const lanes = new Map>(); let subscription: EventSubscription | undefined; // Live events that arrive while the journal drains wait here, so replayed // outcomes deliver first. @@ -193,24 +197,43 @@ export const createDelivery = ({ } }; - const deliver = async (event: SettledEvent): Promise => { - if (typeof event?.eventId !== 'string') { - warn('delivery: dropped a settled event without an eventId', event); + /** Runs `task` after the previous delivery for `id` has settled. */ + const enqueueForId = (id: string, task: () => Promise): void => { + const prior = lanes.get(id) ?? Promise.resolve(); + const next = prior.then(task).catch((e) => { + warn(`delivery: unexpected failure while delivering for ${id}`, e); + }); + lanes.set(id, next); + void next.then(() => { + if (lanes.get(id) === next) { + lanes.delete(id); + } + }); + }; + + /** Delivery for `id` waits until the caller of mutate() has the id. */ + const waitForMutate = async (id: string): Promise => { + const pending = pendingMutates.get(id); + if (!pending) { return; } - if (seen.has(event.eventId)) { + await pending; + // The enqueue promise settles before mutate()'s own await and before + // the caller's continuation, both microtasks. A macrotask puts the + // handler after them, so the caller has the id before any handler + // sees it. + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + /** The outcome's own steps, run inside its id's lane. */ + const route = async (event: SettledEvent): Promise => { + await waitForMutate(event.id); + // A cancelled outcome has no handler, so it acks whether or not the key + // is still defined. + if (event.kind === 'cancelled') { + await ack(event.eventId); return; } - seen.add(event.eventId); - const pending = pendingMutates.get(event.id); - if (pending) { - await pending; - // The enqueue promise settles before mutate()'s own await and before - // the caller's continuation, both microtasks. A macrotask puts the - // handler after them, so the caller has the id before any handler - // sees it. - await new Promise((resolve) => setTimeout(resolve, 0)); - } const definition = lookup(event.key); if (!definition) { warn( @@ -219,13 +242,36 @@ export const createDelivery = ({ emitState(unhandledRow(event)); return; } - if (event.kind === 'cancelled') { + if (await runHandler(event, definition)) { await ack(event.eventId); + } + }; + + const isWellFormed = (event: SettledEvent): boolean => + typeof event.key === 'string' && + typeof event.kind === 'string' && + typeof event.id === 'string'; + + const deliver = (event: SettledEvent): void => { + if (typeof event?.eventId !== 'string') { + warn('delivery: dropped a settled event without an eventId', event); return; } - if (await runHandler(event, definition)) { - await ack(event.eventId); + if (seen.has(event.eventId)) { + return; + } + seen.add(event.eventId); + // A journal written by an older native build can carry another shape. + // Such an entry is neither acked nor routed. The seen set keeps the + // warning to one per eventId. + if (!isWellFormed(event)) { + warn( + `delivery: dropped a malformed settled event ${event.eventId}. It has no string key, kind and id.`, + event, + ); + return; } + enqueueForId(event.id, () => route(event)); }; const start = (): void => { @@ -237,7 +283,7 @@ export const createDelivery = ({ subscription = native.onSettled((raw) => { const event = raw as SettledEvent; if (live) { - void deliver(event); + deliver(event); } else { buffer.push(event); } @@ -246,13 +292,13 @@ export const createDelivery = ({ .getUnacknowledgedEvents() .then( (events) => { - (events as SettledEvent[]).forEach((event) => void deliver(event)); + (events as SettledEvent[]).forEach(deliver); }, (e) => warn('delivery: getUnacknowledgedEvents failed', e), ) .then(() => { live = true; - buffer.splice(0).forEach((event) => void deliver(event)); + buffer.splice(0).forEach(deliver); }); }; diff --git a/src/index.ts b/src/index.ts index 47df09f2..1881edf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,11 +36,22 @@ export const createUploadClient = (): UploadClient => { // registered twice is removed one subscription at a time. const stateListeners = new Set<{ listener: (event: StateEvent) => void }>(); + // A listener that throws must not stop the others or fail the delivery + // that produced the row. + const emitState = (event: StateEvent): void => { + stateListeners.forEach(({ listener }) => { + try { + listener(event); + } catch (e) { + console.warn('addListener: a state listener threw', e); + } + }); + }; + const delivery = createDelivery({ native, lookup: (key) => definitions.get(key), - emitState: (event) => - stateListeners.forEach(({ listener }) => listener(event)), + emitState, }); const { define } = createRegistry({ native, diff --git a/src/registry.ts b/src/registry.ts index 03843c4a..91d4a204 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -31,7 +31,13 @@ const DESCRIPTOR_KEYS = [ 'android', ]; const PART_KEYS = ['url', 'headers', 'range']; +const RANGE_KEYS = ['start', 'end']; const FORM_PART_KEYS = ['name', 'contentType', 'string', 'path', 'fileName']; +const RETRY_KEYS = ['backoff', 'terminalHttp']; +const BACKOFF_KEYS = ['baseMs', 'maxMs', 'jitter']; +const TERMINAL_HTTP_KEYS = ['exempt']; +const ACCEPT_RULE_KEYS = ['status', 'bodyIncludes']; +const ANDROID_KEYS = ['noNotification']; /** The JS-side settings that `configure()` stores. */ export type Settings = { @@ -174,8 +180,11 @@ const validateParts = (parts: unknown): void => { `mutate: parts[${i}].headers must be a plain object when present`, ); } + if (!isPlainObject(range)) { + throw new Error(`mutate: parts[${i}].range must be an object`); + } + rejectUnknownKeys(range, RANGE_KEYS, `parts[${i}].range`); if ( - !range || !Number.isInteger(range.start) || !Number.isInteger(range.end) || range.start < 0 || @@ -227,9 +236,86 @@ const validateForm = (form: unknown): void => { }); }; +const requireObject = ( + value: unknown, + where: string, +): Record => { + if (!isPlainObject(value)) { + throw new Error(`mutate: ${where} must be a plain object`); + } + return value; +}; + +const validateRetry = (retry: unknown): void => { + const r = requireObject(retry, 'retry'); + rejectUnknownKeys(r, RETRY_KEYS, 'retry'); + if (r.backoff !== undefined) { + const backoff = requireObject(r.backoff, 'retry.backoff'); + rejectUnknownKeys(backoff, BACKOFF_KEYS, 'retry.backoff'); + } + if (r.terminalHttp !== undefined) { + const terminal = requireObject(r.terminalHttp, 'retry.terminalHttp'); + rejectUnknownKeys(terminal, TERMINAL_HTTP_KEYS, 'retry.terminalHttp'); + const { exempt } = terminal; + if ( + !Array.isArray(exempt) || + !exempt.every((status) => typeof status === 'number') + ) { + throw new Error( + 'mutate: retry.terminalHttp.exempt must be an array of numbers', + ); + } + } +}; + +const validateAccept = (accept: unknown): void => { + if (!Array.isArray(accept)) { + throw new Error('mutate: accept must be an array'); + } + accept.forEach((rule, i) => { + const r = requireObject(rule, `accept[${i}]`); + rejectUnknownKeys(r, ACCEPT_RULE_KEYS, `accept[${i}]`); + if (typeof r.status !== 'number') { + throw new Error(`mutate: accept[${i}].status must be a number`); + } + if (r.bodyIncludes !== undefined && typeof r.bodyIncludes !== 'string') { + throw new Error(`mutate: accept[${i}].bodyIncludes must be a string`); + } + }); +}; + +const validateAndroid = (android: unknown): void => { + const a = requireObject(android, 'android'); + rejectUnknownKeys(a, ANDROID_KEYS, 'android'); + if (a.noNotification !== undefined && typeof a.noNotification !== 'boolean') { + throw new Error('mutate: android.noNotification must be a boolean'); + } +}; + +/** + * The descriptor's headers over the provider's. Names match without regard + * to case, and the descriptor's spelling is the one kept. + */ +const mergeHeaders = ( + provided: Record, + own: Record = {}, +): Record => { + const overridden = new Set( + Object.keys(own).map((name) => name.toLowerCase()), + ); + const merged: Record = {}; + Object.entries(provided).forEach(([name, value]) => { + if (!overridden.has(name.toLowerCase())) { + merged[name] = value; + } + }); + return { ...merged, ...own }; +}; + /** * Rejects a malformed descriptor before it crosses the bridge. Then native - * never persists an entry that cannot run. + * never persists an entry that cannot run. Nested objects are checked for + * unknown keys too, so a misspelled field cannot be dropped in silence. */ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { if (!isPlainObject(descriptor)) { @@ -276,6 +362,15 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { if (d.parts !== undefined) { validateParts(d.parts); } + if (d.retry !== undefined) { + validateRetry(d.retry); + } + if (d.accept !== undefined) { + validateAccept(d.accept); + } + if (d.android !== undefined) { + validateAndroid(d.android); + } if ( d.expiresAt !== undefined && (!Number.isFinite(d.expiresAt) || d.expiresAt <= 0) @@ -290,7 +385,8 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { /** * Holds the definitions of one client and builds `define()`. Every `mutate()` * validates `vars` and the descriptor, merges the configured headers under the - * descriptor's, defaults `expiresAt`, and hands the entry to native. + * descriptor's (names matched without regard to case), defaults `expiresAt`, + * hands the entry to native, and resolves with the entry's own id. */ export const createRegistry = ({ native, @@ -355,13 +451,16 @@ export const createRegistry = ({ vars, descriptor: { ...descriptor, - headers: { ...provided, ...descriptor.headers }, + headers: mergeHeaders(provided, descriptor.headers), expiresAt: descriptor.expiresAt ?? now() + settings.lifetimeMs, }, }; const pending = native.enqueue(entry); trackMutate(entry.id, pending); - return { id: await pending }; + // The JS id is the one the caller may have chosen and the one the + // entry carries. Native's return value is not trusted for it. + await pending; + return { id: entry.id }; }; return { key, mutate } as Defined; diff --git a/src/types.ts b/src/types.ts index deba6bcf..5ec1cf53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,7 +73,10 @@ export type RequestDescriptor = { url?: string; /** Default POST. With `parts` it applies to every part. */ method?: Method; - /** Merged over `configure().headers()`. Every part inherits the result. */ + /** + * Merged over `configure().headers()`, with names matched without regard + * to case. Every part inherits the result. + */ headers?: Record; /** JSON body. Exactly one of `data`, `form`, `file` must be set. */ data?: Json; From 8c574a1fa7c662b0a594aa4df011392df5fb00fd Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Wed, 23 Sep 2026 15:09:31 -0400 Subject: [PATCH 03/22] Allow a descriptor with no body A DELETE, or a POST whose meaning is in the URL, has no body. Diana has several of these today (delete comment, delete attachment, convert photo, watchers). The validator now allows at most one of data, form, file, and none is valid. Docs and the test follow. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 ++-- README.md | 3 ++- src/__tests__/registry.test.ts | 14 +++++++++----- src/registry.ts | 7 +++---- src/types.ts | 2 +- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fde784..4f52b5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,8 +42,8 @@ Added: `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 + headers under the descriptor's, validates the descriptor (at most one of + `data` / `form` / `file`, none for a bodiless DELETE; `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 diff --git a/README.md b/README.md index ccd7e4a3..e9167693 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,8 @@ outcome at the next launch. ## The request descriptor -`request(vars)` returns a plain object. Exactly one body kind is required. +`request(vars)` returns a plain object. Set at most one body kind. A DELETE, or a +POST whose meaning is in the URL, sets none. A field outside this table makes `mutate()` reject and name the field, because TypeScript does not flag a misspelled key on an inferred arrow return. diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index dd1d7a16..5690233c 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -192,13 +192,17 @@ describe('mutate', () => { return { promise: d.mutate(null), enqueue }; }; - it('requires exactly one body kind', async () => { - await expect(mutateWith({ url: 'https://x' }).promise).rejects.toThrow( - /exactly one of data, form, file; got none/, - ); + it('allows at most one body kind', async () => { + // A DELETE has no body. So does a POST whose meaning is in the URL. + const { promise, enqueue } = mutateWith({ + url: 'https://x', + method: 'DELETE', + }); + await expect(promise).resolves.toEqual({ id: expect.any(String) }); + expect(enqueue).toHaveBeenCalledTimes(1); await expect( mutateWith({ url: 'https://x', data: {}, file: '/f' }).promise, - ).rejects.toThrow(/exactly one of data, form, file; got data, file/); + ).rejects.toThrow(/at most one of data, form, file; got data, file/); }); it('accepts each body kind alone', async () => { diff --git a/src/registry.ts b/src/registry.ts index 91d4a204..45e92129 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -326,11 +326,10 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { const kinds = (['data', 'form', 'file'] as const).filter( (kind) => d[kind] !== undefined, ); - if (kinds.length !== 1) { + // No body is valid: a DELETE, or a POST that carries its meaning in the URL. + if (kinds.length > 1) { throw new Error( - `mutate: the descriptor must set exactly one of data, form, file; got ${ - kinds.length === 0 ? 'none' : kinds.join(', ') - }`, + `mutate: the descriptor must set at most one of data, form, file; got ${kinds.join(', ')}`, ); } if (d.parts !== undefined && d.file === undefined) { diff --git a/src/types.ts b/src/types.ts index 5ec1cf53..2dfbae7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,7 +78,7 @@ export type RequestDescriptor = { * to case. Every part inherits the result. */ headers?: Record; - /** JSON body. Exactly one of `data`, `form`, `file` must be set. */ + /** JSON body. At most one of `data`, `form`, `file`. None is a bodiless request. */ data?: Json; /** multipart/form-data body. */ form?: FormPart[]; From 1f4b69a60d730918fb092e31678aa304d2f49060 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Wed, 23 Sep 2026 15:29:34 -0400 Subject: [PATCH 04/22] Accept generated API types as vars and data vars is now any object or null, and data is any JSON-serializable value. Generated OpenAPI request types and DTOs have optional fields, object-typed values, and nullable strings, none of which satisfy a recursive Json type. TypeScript cannot prove serializability for those shapes, so mutate() validates at runtime: a cycle, a function, a Date, or a value that does not serialize to an object is rejected with a named error. The 4 KB cap stays. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 ++-- README.md | 19 ++++---- src/__tests__/registry.test.ts | 87 ++++++++++++++++++++++++++++++++++ src/__typetests__/define.ts | 54 +++++++++++++-------- src/registry.ts | 69 +++++++++++++++++++++++---- src/types.ts | 43 ++++++++++------- 6 files changed, 223 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f52b5ee..02c8fa03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,9 +45,11 @@ Added: headers under the descriptor's, validates the descriptor (at most one of `data` / `form` / `file`, none for a bodiless DELETE; `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. + `lifetimeMs`, and resolves when the entry is durable. `vars` is any + JSON-serializable object, so generated API request types work as they are; + `mutate()` rejects vars or `data` that do not serialize (a cycle, a function, + a BigInt) and caps `vars` 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 diff --git a/README.md b/README.md index e9167693..7ffa1dbb 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ import { createUploadClient } from 'react-native-background-upload'; export const uploads = createUploadClient(); // One definition per request kind. `request` runs one time, at mutate(). -// `vars` must be JSON and at most 4 KB; native persists them next to the entry. -// Declare the vars as a `type` alias: an `interface` fails the Json constraint. +// `vars` is any JSON-serializable object, at most 4 KB; native persists it +// next to the entry. Generated API request types work as they are. type AddCommentVars = { siteId: string; noteId: string; comment: string }; export const addComment = uploads.define({ key: 'note.comment.add', // persisted with every entry; rename with care @@ -112,7 +112,7 @@ TypeScript does not flag a misspelled key on an inferred arrow return. | `url` | Required unless `parts` is set. | | `method` | `POST` (default), `PUT`, `PATCH`, `DELETE`, `GET`. With `parts` it applies to every part. | | `headers` | Merged over `configure().headers()`, names matched without regard to case. Every chunked part inherits the result. | -| `data` | JSON body. | +| `data` | JSON body. Any JSON-serializable value. | | `form` | `multipart/form-data`: `[{ name, contentType, string }]` or `[{ name, contentType, path, fileName? }]`. File parts are copied. | | `file` | Whole file body. Copied. Moved when `parts` is set. | | `parts` | Chunked over `file`: `[{ url, headers?, range: { start, end } }]`, bytes, end exclusive, tiling the file from 0. | @@ -217,7 +217,7 @@ an app needs one. ### `define(definition): { key, mutate }` ```ts -type Definition = +type Definition = | { key: string; request: (vars: V) => RequestDescriptor; @@ -237,11 +237,12 @@ type Definition = `V` infers from the `request` parameter annotation, `T` from the `response` return type. Without `response`, `onSuccess` receives the `RawResponse` (`{ status?, headers?, body?, bodyTruncated }`), and an `onSuccess` annotated -with any other type is a compile error. `V` must be a `type` alias with -mutable arrays: an `interface` or a `readonly T[]` field fails the `Json` -constraint, and the compiler error names `null` rather than the cause. A -`request` that declares no parameter gives `V = null`, and `mutate()` then -takes no arguments. When `response` is set and +with any other type is a compile error. `V` is any object or `null`, so a +generated API request type works as it is. `mutate()` rejects vars and +`data` that do not serialize: a cycle, a function, a BigInt, or a value that +`JSON.stringify` turns into a primitive. Methods on a class instance are +dropped. A `request` that declares no parameter gives `V = null`, and +`mutate()` then takes no arguments. When `response` is set and the body was truncated, `onError` gets `errorKind: 'truncated'`. When `response` throws, `onError` gets `errorKind: 'unknown'` with the thrown message; the entry still settles as completed. A key that is already defined diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 5690233c..1b06bc94 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -182,6 +182,70 @@ describe('mutate', () => { }); }); + describe('vars serializability', () => { + const anyVars = () => { + const { define, enqueue, lastEntry } = setup(); + const send = define({ + key: 'k', + request: (_vars: object) => ({ url: 'https://x', data: null }), + }); + return { send, enqueue, lastEntry }; + }; + + it('rejects a cycle', async () => { + const { send, enqueue } = anyVars(); + const loop: { self?: object } = {}; + loop.self = loop; + await expect(send.mutate(loop)).rejects.toThrow( + /^mutate: vars is not JSON-serializable: /, + ); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('rejects a function or a primitive as vars', async () => { + const { send, enqueue } = anyVars(); + await expect(send.mutate(() => 1)).rejects.toThrow( + 'mutate: vars must be an object, an array, or null, got function', + ); + await expect(send.mutate('s' as unknown as object)).rejects.toThrow( + /got string/, + ); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('rejects an object that serializes to a primitive', async () => { + const { send } = anyVars(); + await expect(send.mutate(new Date(0))).rejects.toThrow( + 'mutate: vars must serialize to a JSON object, an array, or null', + ); + await expect(send.mutate({ toJSON: () => undefined })).rejects.toThrow( + /must serialize to a JSON object/, + ); + }); + + it('accepts a class instance and passes it through unchanged', async () => { + class Point { + constructor(public x: number, public y: number) {} + norm() { + return Math.hypot(this.x, this.y); + } + } + const { send, lastEntry } = anyVars(); + const point = new Point(1, 2); + await expect(send.mutate(point)).resolves.toBeDefined(); + // Only validated. Native stringifies, which drops the method. + expect(lastEntry().vars).toBe(point); + }); + + it('accepts nested undefined fields and an array', async () => { + const { send, lastEntry } = anyVars(); + const vars = { title: undefined, ids: ['a'] as readonly string[] }; + await expect(send.mutate(vars)).resolves.toBeDefined(); + expect(lastEntry().vars).toBe(vars); + await expect(send.mutate([1, 2])).resolves.toBeDefined(); + }); + }); + describe('descriptor validation', () => { const mutateWith = (descriptor: unknown) => { const { define, enqueue } = setup(); @@ -223,6 +287,29 @@ describe('mutate', () => { ).resolves.toBeDefined(); }); + it('rejects data that cannot serialize', async () => { + await expect( + mutateWith({ url: 'https://x', data: () => 1 }).promise, + ).rejects.toThrow( + 'mutate: data must be a JSON-serializable value, got function', + ); + const loop: { self?: object } = {}; + loop.self = loop; + await expect( + mutateWith({ url: 'https://x', data: loop }).promise, + ).rejects.toThrow(/^mutate: data is not JSON-serializable: /); + await expect( + mutateWith({ url: 'https://x', data: { n: 1n } }).promise, + ).rejects.toThrow(/data is not JSON-serializable/); + }); + + it('passes data with nested undefined fields through unchanged', async () => { + const data = { title: undefined, value: { any: 1 } }; + const { promise, enqueue } = mutateWith({ url: 'https://x', data }); + await expect(promise).resolves.toBeDefined(); + expect(enqueue.mock.calls[0][0].descriptor.data).toBe(data); + }); + it('rejects a form part without exactly one of string, path', async () => { await expect( mutateWith({ diff --git a/src/__typetests__/define.ts b/src/__typetests__/define.ts index 97296ba7..d741f720 100644 --- a/src/__typetests__/define.ts +++ b/src/__typetests__/define.ts @@ -81,16 +81,12 @@ const putFile = client.define({ }); void putFile.mutate({ path: '/tmp/a', url: 'https://x' }); -// vars must be JSON: no functions, no Dates, no undefined fields. +// vars must be an object or null. A primitive is a type error. Whether the +// object serializes is checked at mutate(), not here. client.define({ - key: 'bad.vars', - // @ts-expect-error a function is not Json - request: (_vars: { cb: () => void }) => ({ url: 'https://x', data: null }), -}); -client.define({ - key: 'bad.vars.date', - // @ts-expect-error a Date is not Json - request: (_vars: { when: Date }) => ({ url: 'https://x', data: null }), + key: 'bad.vars.primitive', + // @ts-expect-error a string is not an object + request: (_vars: string) => ({ url: 'https://x', data: null }), }); // The descriptor is checked against RequestDescriptor. @@ -198,26 +194,46 @@ void noVars.mutate('anything goes'); // @ts-expect-error a no-vars definition takes no vars void noVars.mutate({ arbitrary: [1, 2, 3] }); -// Known limits of the Json constraint. An interface has no implicit index -// signature, and a readonly array is not a Json[]. Use a type alias with -// mutable arrays. These lines pin the limit so a change to it shows up here. +// Generated API types pass as they are: an interface with optional fields, a +// readonly array, a field typed `object`, a nullable string, a nested DTO. interface InterfaceVars { - a: string; + siteId: string; + title?: string | null; } -client.define({ +const interfaceVars = client.define({ key: 'interface.vars', - // @ts-expect-error an interface does not satisfy Json; use a type alias request: (_vars: InterfaceVars) => ({ url: 'https://x', data: null }), }); -client.define({ +void interfaceVars.mutate({ siteId: 's' }); +void interfaceVars.mutate({ siteId: 's', title: null }); +const readonlyVars = client.define({ key: 'readonly.vars', - // @ts-expect-error readonly string[] is not a Json[] - request: (_vars: { ids: readonly string[] }) => ({ + request: (_vars: { readonly ids: readonly string[] }) => ({ url: 'https://x', data: null, }), }); -// Aliases with optional fields, nested aliases and mutable arrays pass. +void readonlyVars.mutate({ ids: ['a'] }); +interface UpsertDto { + valuesToUpsert: Array<{ propertyKey: string; value?: object }>; + title?: string | null; +} +type UpsertRequest = { readonly siteId: string; readonly body: UpsertDto }; +const upsert = client.define({ + key: 'upsert.vars', + request: ({ siteId, body }: UpsertRequest) => ({ + url: `https://x/${siteId}`, + data: body, + }), + onSuccess: (_data, _vars) => { + assertEqual(true); + }, +}); +void upsert.mutate({ + siteId: 's', + body: { valuesToUpsert: [{ propertyKey: 'k', value: { any: 1 } }] }, +}); +// Aliases with optional fields, nested aliases and arrays pass too. type NestedVars = { inner: { b: number }; ids: string[]; note?: string }; const nested = client.define({ key: 'nested.vars', diff --git a/src/registry.ts b/src/registry.ts index 45e92129..5356ce2c 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -4,11 +4,11 @@ import type { Defined, Definition, FormPart, - Json, Method, Part, RequestDescriptor, RetryPolicy, + Vars, } from './types'; export const DEFAULT_LIFETIME_MS = 14 * 24 * 60 * 60 * 1000; @@ -50,7 +50,7 @@ export type Settings = { export type EnqueueEntry = { id: string; key: string; - vars: Json; + vars: Vars; descriptor: RequestDescriptor; }; @@ -89,6 +89,56 @@ export const isDev = (): boolean => { const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); +/** + * `JSON.stringify` as native will run it. A throw (a cycle, a BigInt, a + * `toJSON` that throws) becomes a `mutate:` error. The result is `undefined` + * for a function, a symbol, or a `toJSON` that returns nothing. + */ +const stringifyForNative = ( + value: unknown, + where: string, +): string | undefined => { + try { + return JSON.stringify(value); + } catch (e) { + const reason = e instanceof Error ? e.message : String(e); + throw new Error(`mutate: ${where} is not JSON-serializable: ${reason}`); + } +}; + +/** + * TypeScript accepts any object as vars, so the runtime checks what native + * will persist. Methods on a class instance are dropped, as JSON.stringify + * drops them; a Date or a `toJSON` that yields a primitive is rejected. + */ +const serializeVars = (vars: unknown): string => { + if (typeof vars !== 'object') { + throw new Error( + `mutate: vars must be an object, an array, or null, got ${typeof vars}`, + ); + } + const serialized = stringifyForNative(vars, 'vars'); + if ( + serialized === undefined || + (serialized !== 'null' && + !serialized.startsWith('{') && + !serialized.startsWith('[')) + ) { + throw new Error( + 'mutate: vars must serialize to a JSON object, an array, or null', + ); + } + return serialized; +}; + +const validateData = (data: unknown): void => { + if (stringifyForNative(data, 'data') === undefined) { + throw new Error( + `mutate: data must be a JSON-serializable value, got ${typeof data}`, + ); + } +}; + /** UTF-8 length of a string that JSON.stringify produced. */ export const utf8ByteLength = (s: string): number => { let bytes = 0; @@ -329,7 +379,9 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { // No body is valid: a DELETE, or a POST that carries its meaning in the URL. if (kinds.length > 1) { throw new Error( - `mutate: the descriptor must set at most one of data, form, file; got ${kinds.join(', ')}`, + `mutate: the descriptor must set at most one of data, form, file; got ${kinds.join( + ', ', + )}`, ); } if (d.parts !== undefined && d.file === undefined) { @@ -355,6 +407,9 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { if (d.file !== undefined && (typeof d.file !== 'string' || !d.file)) { throw new Error('mutate: file must be a non-empty path'); } + if (d.data !== undefined) { + validateData(d.data); + } if (d.form !== undefined) { validateForm(d.form); } @@ -397,7 +452,7 @@ export const createRegistry = ({ }: RegistryDeps): Registry => { // The overloads on Define keep the with-parser and without-parser shapes // apart for callers. One implementation serves both. - const define = (( + const define = (( definition: Definition, ): Defined => { const { key } = definition; @@ -422,11 +477,7 @@ export const createRegistry = ({ ): Promise<{ id: string }> => { // A no-vars definition calls mutate() with nothing; native stores null. const vars = (input === undefined ? null : input) as V; - const serialized = JSON.stringify(vars); - if (serialized === undefined) { - throw new Error('mutate: vars must be a JSON value'); - } - const bytes = utf8ByteLength(serialized); + const bytes = utf8ByteLength(serializeVars(vars)); if (bytes > MAX_VARS_BYTES) { throw new Error( `mutate: vars for "${key}" is ${bytes} bytes; the limit is ${MAX_VARS_BYTES}`, diff --git a/src/types.ts b/src/types.ts index 2dfbae7e..3f58d127 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,6 @@ import type { EventSubscription } from 'react-native'; -/** - * Any JSON value. `vars` and `data` must be JSON, because native persists them. - * A vars type has to be a `type` alias, not an `interface`: only aliases get - * the implicit index signature that this recursive type asks for. Fields must - * be mutable arrays, not `readonly T[]`. - */ +/** Any JSON value, as parsed from what native stores. */ export type Json = | string | number @@ -14,6 +9,16 @@ export type Json = | Json[] | { [k: string]: Json }; +/** + * The `vars` of a definition: any JSON-serializable object, or null. Native + * persists it with `JSON.stringify`. TypeScript cannot prove that an object + * serializes, so generated API request types are accepted as they are, and + * `mutate()` rejects functions, cycles, and other values that do not + * serialize. Methods on a class instance are dropped, as `JSON.stringify` + * drops them. + */ +export type Vars = object | null; + export type Method = 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'GET'; /** @@ -78,8 +83,11 @@ export type RequestDescriptor = { * to case. Every part inherits the result. */ headers?: Record; - /** JSON body. At most one of `data`, `form`, `file`. None is a bodiless request. */ - data?: Json; + /** + * JSON body. Any JSON-serializable value. At most one of `data`, `form`, + * `file`. None is a bodiless request. + */ + data?: unknown; /** multipart/form-data body. */ form?: FormPart[]; /** Whole file body. Copied. Moved when `parts` is set. */ @@ -192,7 +200,7 @@ export type AttemptEvent = { at: number; }; -type DefinitionBase = { +type DefinitionBase = { /** Persisted with every entry, so rename it with care. */ key: string; /** Runs one time, at `mutate()`. */ @@ -201,14 +209,14 @@ type DefinitionBase = { }; /** A definition with a parser. `onSuccess` receives what `response` returns. */ -export type DefinitionWithResponse = DefinitionBase & { +export type DefinitionWithResponse = DefinitionBase & { /** Parses the JSON body (`undefined` when there is none) before `onSuccess`. */ response: (raw: unknown) => T; onSuccess?: (data: T, vars: V, meta: Meta) => void | Promise; }; /** A definition without a parser. `onSuccess` receives the `RawResponse`. */ -export type DefinitionWithoutResponse = DefinitionBase & { +export type DefinitionWithoutResponse = DefinitionBase & { response?: undefined; onSuccess?: (data: RawResponse, vars: V, meta: Meta) => void | Promise; }; @@ -220,7 +228,7 @@ export type DefinitionWithoutResponse = DefinitionBase & { * `RawResponse`, and the two shapes are kept apart so that an `onSuccess` * annotated with another type does not compile. */ -export type Definition = +export type Definition = | DefinitionWithResponse | DefinitionWithoutResponse; @@ -231,7 +239,7 @@ export type Definition = * handlers see, even though `mutate()` itself does not use it. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars -export type Defined = { +export type Defined = { key: string; mutate: [V] extends [null] ? (vars?: null, options?: { id?: string }) => Promise<{ id: string }> @@ -244,12 +252,13 @@ export type Defined = { * Without `response`, the handlers see the `RawResponse`. */ export interface Define { - ( + ( definition: DefinitionWithResponse, ): Defined; - ( - definition: DefinitionWithoutResponse, - ): Defined; + (definition: DefinitionWithoutResponse): Defined< + V, + RawResponse + >; } /** From 9c080f6c81e16ae8933ab521ee29afa00ac7be16 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Wed, 23 Sep 2026 15:51:27 -0400 Subject: [PATCH 05/22] Add an enqueue watchdog to mutate() Native enqueue() is one synchronous disk write, so a promise that never settles is a native bug. mutate() now races it against a timer, 10 s by default (configure({ enqueueTimeoutMs })). On timeout it rejects with an error naming the key and id and logs a warning, and delivery for that id is not held open. If native did persist the entry, its outcome still reaches the handlers, and a same-id retry resumes rather than duplicates. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 6 ++-- README.md | 1 + src/__tests__/registry.test.ts | 51 +++++++++++++++++++++++++++++++++- src/index.ts | 14 +++++++++- src/registry.ts | 42 +++++++++++++++++++++++++++- src/types.ts | 5 ++++ 6 files changed, 114 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c8fa03..17738f88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,10 @@ Breaking: - **`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()`. + the replay of journaled outcomes. It also takes `lifetimeMs`, `retry`, a + `headers` provider that runs at `mutate()`, and `enqueueTimeoutMs` (default + 10 s): `mutate()` rejects with a named error and warns when the native write + has not settled by then, so a native bug cannot hang a caller in silence. - **`ErrorKind` gains `'truncated'`.** With a `response` parser set and a body over the 1 MB cap, `onError` fires with it instead of `onSuccess`. diff --git a/README.md b/README.md index 7ffa1dbb..4eb33de6 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,7 @@ outcomes. A second call updates the settings and does not replay again. | `lifetimeMs` | Default `expiresAt` distance. Default 14 days. | | `retry` | `{ backoff?: { baseMs, maxMs, jitter }, terminalHttp?: { exempt } }`. Each of the two objects is optional, but one you give must be complete. Defaults 1 s, 2 h, 0.2, `[404]`. | | `headers` | `() => Record`, called at `mutate()`. The descriptor merges over it. | +| `enqueueTimeoutMs` | Default 10 s. `mutate()` rejects and warns when the native write has not settled by then. A watchdog for a native bug, not a tuning knob. | | `android` | Notification text and identity: `notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`. Persisted natively. | ### `pause(): Promise` and `resume(): Promise` diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 1b06bc94..96380089 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -16,7 +16,11 @@ const setup = (settings: Partial = {}) => { const definitions = new Map(); const trackMutate = jest.fn(); const warn = jest.fn(); - const current: Settings = { lifetimeMs: DEFAULT_LIFETIME_MS, ...settings }; + const current: Settings = { + lifetimeMs: DEFAULT_LIFETIME_MS, + enqueueTimeoutMs: 10_000, + ...settings, + }; const registry = createRegistry({ native: { enqueue }, definitions, @@ -663,3 +667,48 @@ describe('uuidV4', () => { ); }); }); + +describe('enqueue watchdog', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('rejects and warns when native enqueue never settles', async () => { + const { define, enqueue, warn } = setup({ enqueueTimeoutMs: 10_000 }); + enqueue.mockImplementation(() => new Promise(() => undefined)); + const defined = define({ key: 'item.create', request: jsonPost }); + const promise = defined.mutate({ n: 1 }); + // Attach the handler before the clock moves, so the rejection is observed. + const outcome = promise.then( + () => 'resolved', + (e: Error) => e.message, + ); + await jest.advanceTimersByTimeAsync(9_999); + expect(warn).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(1); + expect(await outcome).toMatch( + /native enqueue for "item.create" \(id [^)]+\) did not settle within 10000 ms/, + ); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('clears the watchdog when native settles in time', async () => { + const { define, warn } = setup({ enqueueTimeoutMs: 10_000 }); + const defined = define({ key: 'item.create', request: jsonPost }); + await expect(defined.mutate({ n: 1 })).resolves.toEqual({ + id: expect.any(String), + }); + await jest.advanceTimersByTimeAsync(20_000); + expect(warn).not.toHaveBeenCalled(); + }); + + it('hands delivery the raced promise, so a timed-out id does not block delivery', async () => { + const { define, enqueue, trackMutate } = setup({ enqueueTimeoutMs: 1_000 }); + enqueue.mockImplementation(() => new Promise(() => undefined)); + const defined = define({ key: 'item.create', request: jsonPost }); + const promise = defined.mutate({ n: 1 }).catch(() => 'timed out'); + await jest.advanceTimersByTimeAsync(1_000); + expect(await promise).toBe('timed out'); + const tracked = trackMutate.mock.calls[0]![1] as Promise; + await expect(tracked).rejects.toThrow(/did not settle/); + }); +}); diff --git a/src/index.ts b/src/index.ts index 1881edf7..849ea4f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { chunkPlan } from './chunkPlan'; import { createDelivery } from './delivery'; import { createRegistry, + DEFAULT_ENQUEUE_TIMEOUT_MS, DEFAULT_LIFETIME_MS, type AnyDefinition, type Settings, @@ -30,7 +31,10 @@ export * from './chunkPlan'; */ export const createUploadClient = (): UploadClient => { const native = NativeRNFileUploader; - const settings: Settings = { lifetimeMs: DEFAULT_LIFETIME_MS }; + const settings: Settings = { + lifetimeMs: DEFAULT_LIFETIME_MS, + enqueueTimeoutMs: DEFAULT_ENQUEUE_TIMEOUT_MS, + }; const definitions = new Map(); // One entry per subscription, not per function, so the same listener // registered twice is removed one subscription at a time. @@ -75,6 +79,14 @@ export const createUploadClient = (): UploadClient => { ); } settings.lifetimeMs = lifetimeMs; + const enqueueTimeoutMs = + options.enqueueTimeoutMs ?? DEFAULT_ENQUEUE_TIMEOUT_MS; + if (!Number.isFinite(enqueueTimeoutMs) || enqueueTimeoutMs <= 0) { + throw new Error( + `configure: enqueueTimeoutMs must be a positive number, got ${options.enqueueTimeoutMs}`, + ); + } + settings.enqueueTimeoutMs = enqueueTimeoutMs; settings.headers = options.headers; settings.retry = options.retry; const forwarded: Record = { diff --git a/src/registry.ts b/src/registry.ts index 5356ce2c..7a701d40 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -12,6 +12,12 @@ import type { } from './types'; export const DEFAULT_LIFETIME_MS = 14 * 24 * 60 * 60 * 1000; +/** + * How long mutate() waits for native enqueue() before it rejects. The native + * write is one synchronous disk write, so this is a watchdog for a native + * bug (a path that never settles), not a tuning knob. + */ +export const DEFAULT_ENQUEUE_TIMEOUT_MS = 10_000; /** `vars` are persisted natively next to every entry. Only they are capped. */ export const MAX_VARS_BYTES = 4096; @@ -42,10 +48,31 @@ const ANDROID_KEYS = ['noNotification']; /** The JS-side settings that `configure()` stores. */ export type Settings = { lifetimeMs: number; + enqueueTimeoutMs: number; headers?: () => Record; retry?: Partial; }; +/** Rejects with `makeError()` when `promise` has not settled after `ms`. */ +export const withTimeout = ( + promise: Promise, + ms: number, + makeError: () => Error, +): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(makeError()), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); + /** What crosses to native `enqueue()`. */ export type EnqueueEntry = { id: string; @@ -505,7 +532,20 @@ export const createRegistry = ({ expiresAt: descriptor.expiresAt ?? now() + settings.lifetimeMs, }, }; - const pending = native.enqueue(entry); + // Watchdog. Native enqueue() is one synchronous write, so a promise that + // never settles is a native bug. Turn it into a rejection with a name, + // and let delivery for this id proceed instead of waiting forever. If + // native did persist the entry, its outcome still reaches the handlers, + // and a same-id retry resumes instead of duplicating. + const pending = withTimeout( + native.enqueue(entry), + settings.enqueueTimeoutMs, + () => { + const message = `mutate: native enqueue for "${key}" (id ${entry.id}) did not settle within ${settings.enqueueTimeoutMs} ms`; + warn(message); + return new Error(message); + }, + ); trackMutate(entry.id, pending); // The JS id is the one the caller may have chosen and the one the // entry carries. Native's return value is not trusted for it. diff --git a/src/types.ts b/src/types.ts index 3f58d127..2a4ac735 100644 --- a/src/types.ts +++ b/src/types.ts @@ -283,6 +283,11 @@ export type ConfigureOptions = { retry?: Partial; /** Called at `mutate()`. The descriptor's headers merge over the result. */ headers?: () => Record; + /** + * Default 10 s. How long `mutate()` waits for the native write before it + * rejects. A watchdog for a native bug, not a tuning knob. + */ + enqueueTimeoutMs?: number; android?: Partial; }; From cd27dd95d3c90c1e91cc3904b8a1a08d253b1bce Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 13:07:24 -0400 Subject: [PATCH 06/22] Lock the interface: 1 MB vars cap, deliveries, response(raw, vars), native contract From the JS interface review. The 4 KB vars cap was a leftover from the old ctx field; the generated-ops design puts the request body in vars, so the default is 1 MB and configure({ maxVarsBytes }) sets it. Meta gains deliveries, native's count of how many times an outcome was delivered, so an app can decide a poison policy; the library never gives up on its own. The response parser receives vars, so a decoder that needs an id runs in the parser path, where a throw is a terminal onError, not a replay. The codegen spec comments now pin the contract the native slices implement: enqueue resolves after every staged copy is on disk and rejects with E_RUNNING, E_FILE_MISSING, or E_STORAGE; same-id rules for a changed body; the exact set of rows getRequests returns; cancel of an unknown id is a no-op; ackEvents is void and idempotent; updateHeaders bumps a header generation; attempt events are live-only. RequestRow gains nextAttemptAt. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 28 ++++-- README.md | 86 +++++++++++++------ .../backgroundupload/UploaderModule.kt | 5 +- ios/RNBackgroundUpload.swift | 3 +- src/NativeRNFileUploader.ts | 68 +++++++++++---- src/__tests__/client.test.ts | 29 ++++++- src/__tests__/delivery.test.ts | 77 ++++++++++++++++- src/__tests__/registry.test.ts | 24 +++++- src/__typetests__/define.ts | 26 ++++++ src/delivery.ts | 18 +++- src/index.ts | 23 +++-- src/registry.ts | 14 +-- src/types.ts | 22 ++++- 13 files changed, 346 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17738f88..85a4f904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,10 @@ Breaking: percentage. - **`configure()` must be called at boot, after every `define()`.** It starts the replay of journaled outcomes. It also takes `lifetimeMs`, `retry`, a - `headers` provider that runs at `mutate()`, and `enqueueTimeoutMs` (default - 10 s): `mutate()` rejects with a named error and warns when the native write - has not settled by then, so a native bug cannot hang a caller in silence. + `headers` provider that runs at `mutate()`, `maxVarsBytes` (default 1 MB), + and `enqueueTimeoutMs` (default 10 s): `mutate()` rejects with a named + error and warns when the native write has not settled by then, so a native + bug cannot hang a caller in silence. - **`ErrorKind` gains `'truncated'`.** With a `response` parser set and a body over the 1 MB cap, `onError` fires with it instead of `onSuccess`. @@ -41,8 +42,9 @@ Added: 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. + `response` return. `response(raw, vars)` also receives the entry's `vars`; + a one-argument parser such as `schema.parse` still fits. 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 (at most one of `data` / `form` / `file`, none for a bodiless DELETE; `parts` only with `file`; parts must tile the @@ -50,15 +52,25 @@ Added: `lifetimeMs`, and resolves when the entry is durable. `vars` is any JSON-serializable object, so generated API request types work as they are; `mutate()` rejects vars or `data` that do not serialize (a cycle, a function, - a BigInt) and caps `vars` at 4 KB. A definition whose `request` takes no - vars calls `mutate()` with no arguments. + a BigInt) and caps `vars` at `configure().maxVarsBytes`, 1 MB by default. A + definition whose `request` takes no vars calls `mutate()` with no arguments. + It resolves after the row and every staged body copy are on disk, so the + caller may delete its source file then; native failures reject with + `E_RUNNING`, `E_FILE_MISSING`, or `E_STORAGE`. - **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. + a handler that has not settled after 30 s logs a warning. No ordering is + promised between different ids. +- **`Meta.deliveries`**: how many times an outcome has reached JS, including + boot replays. A handler that keeps throwing sees it grow; the library never + gives up on its own, so the app decides a poison policy. +- **`RequestRow.nextAttemptAt`**: epoch ms, set while an entry waits out a + retry backoff. `getRequests()` returns every entry native has not yet + forgotten, so completed and cancelled rows appear until their ack. - **`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. diff --git a/README.md b/README.md index 4eb33de6..af91297b 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ import { createUploadClient } from 'react-native-background-upload'; export const uploads = createUploadClient(); // One definition per request kind. `request` runs one time, at mutate(). -// `vars` is any JSON-serializable object, at most 4 KB; native persists it -// next to the entry. Generated API request types work as they are. +// `vars` is any JSON-serializable object, at most 1 MB by default; native +// persists it next to the entry. Generated API request types work as they are. type AddCommentVars = { siteId: string; noteId: string; comment: string }; export const addComment = uploads.define({ key: 'note.comment.add', // persisted with every entry; rename with care @@ -72,7 +72,8 @@ export const addComment = uploads.define({ url: `https://api.example.com/sites/${siteId}/notes/${noteId}/comments`, data: { comment }, // JSON body. Default method is POST. }), - // Parses the JSON body before onSuccess. Zod users pass schema.parse. + // Parses the JSON body before onSuccess. Zod users pass schema.parse. The + // parser also receives the entry's vars as a second argument. response: (raw) => (raw as { content: Comment[] }).content, onSuccess: (content, { noteId }, meta) => { // Runs after the server accepted the request, possibly on a later launch. @@ -89,7 +90,8 @@ uploads.configure({ android: { notificationTitle: 'Uploading', notificationChannel: 'uploads' }, }); -// Anywhere. Resolves when the entry is durable, never on the network. +// Anywhere. Resolves when the entry and its staged body are on disk, never +// on the network. You may delete a source file after this resolves. const { id } = await addComment.mutate( { siteId, noteId, comment }, { id: localCommentId }, // optional; makes a re-dispatch idempotent @@ -160,11 +162,20 @@ const captureFile = uploads.define({ are deleted after a `completed` outcome is acknowledged, or on `cancel()`. Nothing else deletes them. -**Same id, again.** `mutate()` with an id that exists follows the v9 rules. -Same parts or body: resume; new headers and `expiresAt` replace the stored -ones, and a settled entry reopens and settles once more. Settled entry with -different parts: recreate over the same bytes (the new parts must tile the -same size). Running entry with different parts: reject. +**Same id, again.** `mutate()` with an id that exists: + +- Same body: resume. New headers, `expiresAt`, and `vars` replace the stored + ones. +- Different body (`data`, `form`, `file`, or `parts`) and the entry is not + running (queued, paused, awaiting-auth, or settled): the descriptor, staged + body, `vars`, headers, and `expiresAt` are replaced and the entry reopens. + It settles once more. +- Different body, entry running: `mutate()` rejects with `E_RUNNING`. +- A cancelled entry whose outcome is not yet acknowledged: a fresh + generation. The old outcome's ack forgets the entry only when the + generation matches. +- A completed entry whose outcome is not yet acknowledged: the journaled + outcome is emitted again. The request does not run again. ### Silent uploads (Android) @@ -176,12 +187,18 @@ the OS may defer or restart it. Reserve it for small payloads. # Reliable delivery 1. **Write-ahead.** Entry, descriptor, and staged body persist before any - attempt. `mutate()` resolves when the write lands. + attempt. `mutate()` resolves after the row and every staged body copy are + durably on disk (temp file plus rename), so the caller may delete its + source file then. A native failure rejects with a code: `E_RUNNING`, + `E_FILE_MISSING`, or `E_STORAGE`. 2. **Journal before emit, ack after the handler.** Every terminal outcome is journaled natively, then delivered. The library acknowledges after the handler's promise resolves. A rejection, or app death before the ack, redelivers at the next launch. A handler that has not settled after 30 s - gets a console warning and keeps waiting. + gets a console warning and keeps waiting. `Meta.deliveries` counts the + deliveries of one outcome, including boot replays, so a handler that + keeps throwing sees it grow. The library never gives up on its own; the + app decides a poison policy from that number. 3. **One outcome per settle cycle.** `pause()` produces none. A same-id `mutate()` on a settled entry reopens it, and it settles once more. 4. **Never before `mutate()` resolves.** Delivery for an id waits for the @@ -192,6 +209,9 @@ the OS may defer or restart it. Reserve it for small payloads. unacknowledged and reaches `state` listeners with `reason: 'unhandled-key'`. 7. **Completed entries are forgotten after ack.** Row and bytes go. An `error` or `expired` entry keeps both until `cancel()` or a same-id `mutate()`. +8. **Ordering holds per id only.** The outcomes of one id are delivered in + order. There is no ordering guarantee between different ids. +9. **`attempt` events are live-only.** They are never journaled or replayed. Retry classes: @@ -221,7 +241,7 @@ type Definition = | { key: string; request: (vars: V) => RequestDescriptor; - response: (raw: unknown) => T; // JSON-parsed body, or undefined when there is none + response: (raw: unknown, vars: V) => T; // JSON-parsed body, or undefined when there is none onSuccess?: (data: T, vars: V, meta: Meta) => void | Promise; onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise; } @@ -235,7 +255,9 @@ type Definition = ``` `V` infers from the `request` parameter annotation, `T` from the `response` -return type. Without `response`, `onSuccess` receives the `RawResponse` +return type. `response` also receives the entry's `vars`, for a parser that +needs the request context; a one-argument parser such as `schema.parse` is +assignable as it is. Without `response`, `onSuccess` receives the `RawResponse` (`{ status?, headers?, body?, bodyTruncated }`), and an `onSuccess` annotated with any other type is a compile error. `V` is any object or `null`, so a generated API request type works as it is. `mutate()` rejects vars and @@ -249,16 +271,19 @@ message; the entry still settles as completed. A key that is already defined is replaced, with a warning in development. A `cancelled` outcome calls no handler. -`Meta` is `{ id, key, at, attempts, requestId? }`; `at` is the native outcome -time. +`Meta` is `{ id, key, at, attempts, requestId?, deliveries }`; `at` is the +native outcome time. `deliveries` is 1 on the first delivery of an outcome and +grows by one on every redelivery, including a boot replay. ### `mutate(vars, { id? }): Promise<{ id }>` Runs `request(vars)` once, merges `configure().headers()` under the descriptor's headers, validates the descriptor, defaults `expiresAt`, and -persists the entry. Resolves with the id when the write lands. Rejects on a -malformed descriptor, an unknown descriptor field, a missing file, or `vars` -over 4 KB. Only `vars` are capped. `id` defaults to a UUID. For a definition +persists the entry. Resolves with the id after the row and every staged body +copy are on disk. Rejects on a malformed descriptor, an unknown descriptor +field, a missing file (`E_FILE_MISSING`), a storage failure (`E_STORAGE`), a +running entry with a different body (`E_RUNNING`), or `vars` over +`maxVarsBytes` (1 MB by default). Only `vars` are capped. `id` defaults to a UUID. For a definition whose `request` takes no vars, call `mutate()` with no arguments; native stores `null`. @@ -270,6 +295,7 @@ outcomes. A second call updates the settings and does not replay again. | Option | Notes | | --- | --- | | `lifetimeMs` | Default `expiresAt` distance. Default 14 days. | +| `maxVarsBytes` | Cap on the JSON length of `vars`. Default 1 MB. `mutate()` rejects above it. JS-side only. | | `retry` | `{ backoff?: { baseMs, maxMs, jitter }, terminalHttp?: { exempt } }`. Each of the two objects is optional, but one you give must be complete. Defaults 1 s, 2 h, 0.2, `[404]`. | | `headers` | `() => Record`, called at `mutate()`. The descriptor merges over it. | | `enqueueTimeoutMs` | Default 10 s. `mutate()` rejects and warns when the native write has not settled by then. A watchdog for a native bug, not a tuning knob. | @@ -280,21 +306,27 @@ Whole-queue pause. No outcome is produced; live rows show `paused`. ### `cancel(id): Promise` A live entry settles `cancelled` with reason `user` and is forgotten after -its ack. A settled entry is forgotten now, row and bytes. +its ack. A settled entry is forgotten now, row and bytes. An unknown id +resolves and does nothing. ### `setWifiOnly(enabled): Promise` Persisted natively. Applies to queued and future entries. ### `updateHeaders(patch): Promise` -Merges the patch into every queued and parked entry's headers, then resumes -the entries parked on `awaiting-auth`. This is how a fresh token reaches -requests that stalled on 401. +Merges the patch into the headers of every entry not yet forgotten and +resumes the entries parked on `awaiting-auth`. This is how a fresh token +reaches requests that stalled on 401. Each call bumps a header generation: a +401 or 403 from an attempt issued under an older generation re-issues at once +instead of parking. Parking emits one `state` event per entry. ### `getRequests(filter?): RequestRow[]` -Synchronous. Live rows from native's in-memory index, so it works offline. -`filter` is `{ key?, id? }`. A row is -`{ id, key, vars, state, bytesSent, totalBytes, attempts, updatedAt }`, with -`state` one of `queued | running | awaiting-auth | paused | completed | error | cancelled`. +Synchronous, from native's in-memory index, so it works offline. Returns +every entry native has not yet forgotten: `queued`, `running`, +`awaiting-auth`, and `paused` entries; `completed` and `cancelled` entries +until their ack; `error` entries until `cancel()` or a same-id `mutate()`; +and imported legacy rows. `filter` is `{ key?, id? }`. A row is +`{ id, key, vars, state, bytesSent, totalBytes, attempts, updatedAt, nextAttemptAt? }`; +`nextAttemptAt` (epoch ms) is set while the entry waits out a retry backoff. `vars` is typed `Json`, because a row does not know its definition. Narrow it before reading a field, for example to cancel every entry of one capture: @@ -324,7 +356,7 @@ Fires when the Android progress notification is pressed. No event data. | --- | --- | | `state` | A full `RequestRow`, one per transition, plus `reason: 'unhandled-key'` for an outcome whose key has no definition. A consumer's reducer is one upsert. | | `progress` | `{ id, bytesSent, totalBytes }`, byte-weighted across a chunked upload's parts. | -| `attempt` | One HTTP attempt before interpretation: `{ id, key, requestId, attempt, url, method, partIndex?, outcome, httpCode?, responseBody? (4 KB cap), responseBodyTruncated?, responseHeaders?, errorKind?, errorMessage?, cancelReason?, at }`. | +| `attempt` | One HTTP attempt before interpretation: `{ id, key, requestId, attempt, url, method, partIndex?, outcome, httpCode?, responseBody? (4 KB cap), responseBodyTruncated?, responseHeaders?, errorKind?, errorMessage?, cancelReason?, at }`. Live-only; never journaled or replayed. | Terminal outcomes do not appear here. They go to the definition's handlers. diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index 2c0ad81a..cbd26a85 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -120,7 +120,8 @@ class UploaderModule(context: ReactApplicationContext) : /** - * Removes journaled events by eventId once JS has processed them. + * Removes journaled events by eventId once JS has processed them. Resolves + * void. Idempotent: an unknown id is ignored. */ override fun ackEvents(ids: ReadableArray, promise: Promise) { try { @@ -138,7 +139,7 @@ class UploaderModule(context: ReactApplicationContext) : completedUploadIds, ChunkedManifestStore.get(reactApplicationContext), ) { id -> workManager.cancelUniqueWork(id) } - promise.resolve(true) + promise.resolve(null) } catch (exc: Throwable) { Log.e(TAG, exc.message, exc) promise.reject(exc) diff --git a/ios/RNBackgroundUpload.swift b/ios/RNBackgroundUpload.swift index 6bfff804..cd967f8a 100644 --- a/ios/RNBackgroundUpload.swift +++ b/ios/RNBackgroundUpload.swift @@ -455,7 +455,8 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { .map { $0.id } EventJournal.ack(eventIds) chunked.releaseCompleted(completedUploadIds) { // no-op for simple uploads - resolve(true) + // The spec resolves void. Idempotent: an unknown id is ignored. + resolve(nil) } } diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts index 07de5bdc..f5b9e05d 100644 --- a/src/NativeRNFileUploader.ts +++ b/src/NativeRNFileUploader.ts @@ -7,51 +7,85 @@ import { TurboModuleRegistry } from 'react-native'; // events) are declared as UnsafeObject because codegen can't model index // signatures, Partial<>, or unions. The JS layer casts them back to the // precise ./types shapes. +// +// The comments on each method are the contract native must implement. The +// README's "API" and "Reliable delivery" sections and the plan's sections 5.4 +// and 6 mirror them. export interface Spec extends TurboModule { // Queue-wide settings: { lifetimeMs, retry, ...androidNotificationConfig }. // Android persists the notification config, so a headless WorkManager // relaunch (no JS) can read it. Each call replaces the full configuration. configure(options: CodegenTypes.UnsafeObject): void; - // Persists { id, key, vars, descriptor } and schedules it. Resolves with the - // entry's id once the write has landed, never on the network. A same-id call - // follows the v9 resume rules: same body resumes, different parts on a - // settled entry recreate, different parts on a running entry reject. + // Persists { id, key, vars, descriptor } and schedules it. Resolves AFTER + // the row and every staged body copy are durably on disk (tmp file + + // rename), never on the network. The caller may delete its source file + // once mutate() resolves. Every failure path rejects with a code: + // E_RUNNING, E_FILE_MISSING, E_STORAGE. + // + // Same id, again: + // - Same body: resume. New headers, expiresAt and vars replace the stored + // ones. + // - Different body (data/form/file/parts) and the entry is NOT running + // (queued, paused, awaiting-auth, or settled): replace the descriptor, + // staged body, vars, headers and expiresAt, and reopen the entry. It + // settles once more. + // - Different body, entry running: reject E_RUNNING. + // - Cancelled-but-unacked entry: a fresh generation. ack forgets the entry + // only when the generation matches. + // - Completed-but-unacked entry: re-emit the journaled outcome. Do not + // re-run. + // + // The resolved value is the entry's id. The JS layer does not read it. enqueue(entry: CodegenTypes.UnsafeObject): Promise; // Whole-queue pause. No outcome is produced; live rows move to 'paused'. pause(): Promise; resume(): Promise; - // A live entry settles 'cancelled' (user) and is forgotten after its ack. A - // settled entry is forgotten now, row and bytes. + // Live entry: journal 'cancelled' (user), forget after its ack. Settled + // entry: forget now, row and bytes. Unknown id: resolve, no-op. cancel(id: string): Promise; // Persisted natively. Applies to queued and future entries. setWifiOnly(enabled: boolean): Promise; - // Merges the patch into every queued and parked entry's headers, then - // resumes the entries parked on 'awaiting-auth'. + // Merges the patch into every entry not yet forgotten and bumps a header + // generation. A 401/403 from an attempt issued under an older generation + // re-issues at once instead of parking. Parking emits one 'state' event per + // entry. updateHeaders(patch: CodegenTypes.UnsafeObject): Promise; // Synchronous. Serialized from the in-memory index that native keeps - // current on every state change, never from disk. Live entries only. + // current on every state change, never from disk. Returns every entry not + // yet forgotten: states queued, running, awaiting-auth, paused; completed + // and cancelled until acked; error until cancel() or a same-id enqueue(); + // imported legacy rows. Rows carry `vars` as the object native stored, and + // `nextAttemptAt` (epoch ms) while an entry waits out a backoff. getRequests(): CodegenTypes.UnsafeObject[]; // Settled outcomes that JS has not acknowledged, in the onSettled shape, // including ones journaled while JS was dead. Internal: ./delivery drains // them after configure(). getUnacknowledgedEvents(): Promise; - // Removes journaled outcomes by eventId. An acknowledged 'completed' is the - // one moment native deletes the entry's row and bytes. - ackEvents(ids: string[]): Promise; + // Removes journaled outcomes by eventId. Resolves void. Idempotent; unknown + // ids are ignored. An acknowledged 'completed' is the one moment native + // deletes the entry's row and bytes. + ackEvents(ids: string[]): Promise; // Events. state carries a full RequestRow per transition. progress is - // byte-weighted across a chunked upload's parts. attempt is one HTTP attempt - // before interpretation. settled is the journaled terminal outcome, emitted - // after the journal write; ./delivery routes it to the definition's - // handlers. notification is Android-only (tapping the progress notification) - // and never fires on iOS. + // byte-weighted across a chunked upload's parts. notification is + // Android-only (tapping the progress notification) and never fires on iOS. readonly onState: CodegenTypes.EventEmitter; readonly onProgress: CodegenTypes.EventEmitter<{ id: string; bytesSent: number; totalBytes: number; }>; + // One HTTP attempt before interpretation, in the AttemptEvent shape. + // Live-only: never journaled, never replayed. readonly onAttempt: CodegenTypes.EventEmitter; + // The journaled terminal outcome, emitted after the journal write, in the + // ./delivery SettledEvent shape: { eventId, id, key, vars, at, attempts, + // requestId?, deliveries, state, bytesSent?, totalBytes?, url, method, + // partIndex? } plus the outcome fields. eventId is a UUID string native + // mints. deliveries is 1 on the first emit and increments on every later + // delivery of the same eventId, including boot replays. No ordering + // guarantee between different ids; the JS layer orders one id's outcomes. + // ./delivery routes it to the definition's handlers. readonly onSettled: CodegenTypes.EventEmitter; readonly onNotification: CodegenTypes.EventEmitter; } diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 22954cf8..849cf365 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -27,7 +27,7 @@ jest.mock('react-native', () => { updateHeaders: jest.fn(async () => undefined), getRequests: jest.fn(() => []), getUnacknowledgedEvents: jest.fn(async () => []), - ackEvents: jest.fn(async () => true), + ackEvents: jest.fn(async () => undefined), onState: emitter('state'), onProgress: emitter('progress'), onAttempt: emitter('attempt'), @@ -179,6 +179,32 @@ describe('configure', () => { ); }); + it('rejects a non-positive maxVarsBytes and keeps it on the JS side', () => { + expect(() => + createUploadClient().configure({ maxVarsBytes: 0 }), + ).toThrow(/maxVarsBytes must be a positive number, got 0/); + expect(() => + createUploadClient().configure({ maxVarsBytes: Infinity }), + ).toThrow(/maxVarsBytes/); + createUploadClient().configure({ maxVarsBytes: 64 }); + expect(native.configure.mock.calls.at(-1)![0]).not.toHaveProperty( + 'maxVarsBytes', + ); + }); + + it('applies maxVarsBytes to later mutates', async () => { + const client = createUploadClient(); + const send = client.define({ + key: 'k', + request: (_v: { s: string }) => ({ url: 'https://x', data: 1 }), + }); + client.configure({ maxVarsBytes: 16 }); + await expect(send.mutate({ s: 'a'.repeat(8) })).resolves.toBeDefined(); + await expect(send.mutate({ s: 'a'.repeat(9) })).rejects.toThrow( + /is 17 bytes; the limit is 16/, + ); + }); + it('starts replay once; a second call updates settings without replaying', async () => { const client = createUploadClient(); client.configure({}); @@ -511,6 +537,7 @@ describe('end to end', () => { at: 10, attempts: 1, requestId: 'r1', + deliveries: 1, }, ); expect(native.ackEvents).toHaveBeenCalledWith(['e1']); diff --git a/src/__tests__/delivery.test.ts b/src/__tests__/delivery.test.ts index c3b0ab4c..ef82730f 100644 --- a/src/__tests__/delivery.test.ts +++ b/src/__tests__/delivery.test.ts @@ -32,6 +32,8 @@ const completed = (over: Partial = {}): SettledEvent => ({ at: 1000, attempts: 2, requestId: 'req-9', + url: 'https://example.com/items/1', + method: 'POST', kind: 'completed', response: { status: 200, body: '{"ok":true}', bodyTruncated: false }, state: 'completed', @@ -46,7 +48,7 @@ const setup = ( const handlers: Array<(e: unknown) => void> = []; const native = { getUnacknowledgedEvents: jest.fn(async () => journal), - ackEvents: jest.fn(async () => true), + ackEvents: jest.fn(async () => undefined), onSettled: jest.fn((handler: (e: unknown) => void) => { handlers.push(handler); return { remove: jest.fn() } as never; @@ -435,10 +437,60 @@ describe('completed', () => { at: 1000, attempts: 2, requestId: 'req-9', + deliveries: 1, }, ); }); + it('passes the event deliveries count through meta', async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess, onError }, + }); + start(); + await flush(); + emit(completed({ eventId: 'a', deliveries: 3 })); + emit( + completed({ + eventId: 'b', + kind: 'error', + state: 'error', + response: undefined, + error: { errorKind: 'network', message: 'offline' }, + deliveries: 7, + }), + ); + await flush(); + expect(onSuccess).toHaveBeenCalledWith( + expect.anything(), + { n: 1 }, + expect.objectContaining({ deliveries: 3 }), + ); + expect(onError).toHaveBeenCalledWith( + expect.anything(), + { n: 1 }, + expect.objectContaining({ deliveries: 7 }), + ); + }); + + it('treats a missing or malformed deliveries field as 1', async () => { + const onSuccess = jest.fn(); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + emit(completed({ eventId: 'a' })); + emit(completed({ eventId: 'b', deliveries: 0 })); + emit(completed({ eventId: 'c', deliveries: 'x' as unknown as number })); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(3); + onSuccess.mock.calls.forEach(([, , meta]) => { + expect(meta).toMatchObject({ deliveries: 1 }); + }); + }); + it('parses the JSON body, runs the parser, and passes its result to onSuccess', async () => { const onSuccess = jest.fn(); const response = jest.fn((raw: unknown) => (raw as { ok: boolean }).ok); @@ -449,11 +501,28 @@ describe('completed', () => { await flush(); emit(completed()); await flush(); - expect(response).toHaveBeenCalledWith({ ok: true }); + expect(response).toHaveBeenCalledWith({ ok: true }, { n: 1 }); expect(onSuccess).toHaveBeenCalledWith(true, { n: 1 }, expect.anything()); expect(native.ackEvents).toHaveBeenCalledWith(['e1']); }); + it('gives the parser the entry vars as its second argument', async () => { + const onSuccess = jest.fn(); + const response = jest.fn( + (raw: unknown, vars: { n: number }) => + `${(raw as { ok: boolean }).ok}:${vars.n}`, + ); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), response, onSuccess }, + }); + start(); + await flush(); + emit(completed({ vars: { n: 42 } })); + await flush(); + expect(response).toHaveBeenCalledWith({ ok: true }, { n: 42 }); + expect(onSuccess).toHaveBeenCalledWith('true:42', { n: 42 }, expect.anything()); + }); + it('gives the parser undefined when the body is absent or empty', async () => { const response = jest.fn(() => 'parsed'); const { start, emit } = setup({ @@ -467,8 +536,8 @@ describe('completed', () => { ); await flush(); expect(response).toHaveBeenCalledTimes(2); - expect(response).toHaveBeenNthCalledWith(1, undefined); - expect(response).toHaveBeenNthCalledWith(2, undefined); + expect(response).toHaveBeenNthCalledWith(1, undefined, { n: 1 }); + expect(response).toHaveBeenNthCalledWith(2, undefined, { n: 1 }); }); it('calls onError with errorKind truncated when a parser is set and the body was cut', async () => { diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 96380089..470b5a32 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -19,6 +19,7 @@ const setup = (settings: Partial = {}) => { const current: Settings = { lifetimeMs: DEFAULT_LIFETIME_MS, enqueueTimeoutMs: 10_000, + maxVarsBytes: MAX_VARS_BYTES, ...settings, }; const registry = createRegistry({ @@ -154,7 +155,11 @@ describe('mutate', () => { }); describe('vars cap', () => { - it('accepts vars at exactly the cap and rejects one byte over', async () => { + it('defaults to 1 MB', () => { + expect(MAX_VARS_BYTES).toBe(1_048_576); + }); + + it('accepts vars at exactly the default cap and rejects one byte over', async () => { const { define, enqueue } = setup(); const send = define({ key: 'k', @@ -164,13 +169,26 @@ describe('mutate', () => { const fits = 'a'.repeat(MAX_VARS_BYTES - 8); await expect(send.mutate({ s: fits })).resolves.toBeDefined(); await expect(send.mutate({ s: fits + 'a' })).rejects.toThrow( - /vars for "k" is 4097 bytes; the limit is 4096/, + /vars for "k" is 1048577 bytes; the limit is 1048576 \(configure\(\)\.maxVarsBytes\)/, + ); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('reads the configured cap at mutate()', async () => { + const { define, enqueue } = setup({ maxVarsBytes: 16 }); + const send = define({ + key: 'k', + request: (_vars: { s: string }) => ({ url: 'https://x', data: null }), + }); + await expect(send.mutate({ s: 'a'.repeat(8) })).resolves.toBeDefined(); + await expect(send.mutate({ s: 'a'.repeat(9) })).rejects.toThrow( + /is 17 bytes; the limit is 16/, ); expect(enqueue).toHaveBeenCalledTimes(1); }); it('counts UTF-8 bytes, not UTF-16 code units', async () => { - const { define } = setup(); + const { define } = setup({ maxVarsBytes: 4096 }); const send = define({ key: 'k', request: (_vars: { s: string }) => ({ url: 'https://x', data: null }), diff --git a/src/__typetests__/define.ts b/src/__typetests__/define.ts index d741f720..a05567a3 100644 --- a/src/__typetests__/define.ts +++ b/src/__typetests__/define.ts @@ -166,6 +166,32 @@ const parsed = client.define({ }); void parsed.mutate({ a: 'x' }); +// A parser may take the entry vars as its second argument. V still infers +// from request, and T from the parser's return. +const withVars = client.define({ + key: 'parser.vars', + request: ({ siteId }: { siteId: string; page: number }) => ({ + url: `https://x/${siteId}`, + data: null, + }), + response: (raw, vars) => { + assertEqual(true); + return { page: vars.page, rows: raw as string[] }; + }, + onSuccess: (_data, _vars) => { + assertEqual(true); + assertEqual(true); + }, +}); +void withVars.mutate({ siteId: 's', page: 1 }); +// A parser annotated with the wrong vars type does not compile. +client.define({ + key: 'parser.wrong.vars', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + // @ts-expect-error the parser's vars must match the request's + response: (_raw: unknown, vars: { b: number }) => vars.b, +}); + // An async parser is typed honestly: onSuccess sees the Promise. client.define({ key: 'async.parser', diff --git a/src/delivery.ts b/src/delivery.ts index 38b56d25..062951fd 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -4,6 +4,7 @@ import type { AnyDefinition } from './registry'; import type { Json, Meta, + Method, Outcome, RawResponse, RequestState, @@ -16,6 +17,7 @@ import type { * and a replayed one take the same path here. */ export type SettledEvent = { + /** A UUID string that native mints when it journals the outcome. */ eventId: string; id: string; key: string; @@ -24,10 +26,20 @@ export type SettledEvent = { at: number; attempts: number; requestId?: string; + /** + * Native sets it to 1 on the first emit of an outcome and increments it on + * every later delivery of the same eventId, including boot replays. When + * native omits it, the JS layer treats it as 1. + */ + deliveries?: number; /** The entry's real state, for the unhandled-key row. */ state: RequestState; bytesSent?: number; totalBytes?: number; + /** The last attempt's target. `partIndex` is set for a chunked upload. */ + url: string; + method: Method; + partIndex?: number; } & Outcome; export const HANDLER_WARNING_MS = 30_000; @@ -150,7 +162,7 @@ export const createDelivery = ({ response.body === undefined || response.body === '' ? undefined : JSON.parse(response.body); - data = definition.response(parsed); + data = definition.response(parsed, vars); } catch (e) { await definition.onError?.( { errorKind: 'unknown', message: errorMessage(e) }, @@ -173,6 +185,10 @@ export const createDelivery = ({ at: event.at, attempts: event.attempts, requestId: event.requestId, + deliveries: + typeof event.deliveries === 'number' && event.deliveries >= 1 + ? event.deliveries + : 1, }; const timer = setTimeout(() => { warn( diff --git a/src/index.ts b/src/index.ts index 849ea4f5..648306d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { createRegistry, DEFAULT_ENQUEUE_TIMEOUT_MS, DEFAULT_LIFETIME_MS, + MAX_VARS_BYTES, type AnyDefinition, type Settings, } from './registry'; @@ -34,6 +35,7 @@ export const createUploadClient = (): UploadClient => { const settings: Settings = { lifetimeMs: DEFAULT_LIFETIME_MS, enqueueTimeoutMs: DEFAULT_ENQUEUE_TIMEOUT_MS, + maxVarsBytes: MAX_VARS_BYTES, }; const definitions = new Map(); // One entry per subscription, not per function, so the same listener @@ -66,7 +68,7 @@ export const createUploadClient = (): UploadClient => { /** * One-time setup. Call it at boot, after every define() call. Stores the - * lifetime, the headers provider and the retry defaults, forwards the + * lifetime, the vars cap, the headers provider and the retry defaults, forwards the * lifetime, retry and Android notification settings to native, then starts * replaying journaled outcomes. A second call updates the settings and does * not replay again. Each call replaces the full configuration. @@ -87,6 +89,13 @@ export const createUploadClient = (): UploadClient => { ); } settings.enqueueTimeoutMs = enqueueTimeoutMs; + const maxVarsBytes = options.maxVarsBytes ?? MAX_VARS_BYTES; + if (!Number.isFinite(maxVarsBytes) || maxVarsBytes <= 0) { + throw new Error( + `configure: maxVarsBytes must be a positive number, got ${options.maxVarsBytes}`, + ); + } + settings.maxVarsBytes = maxVarsBytes; settings.headers = options.headers; settings.retry = options.retry; const forwarded: Record = { @@ -117,16 +126,20 @@ export const createUploadClient = (): UploadClient => { native.setWifiOnly(enabled); /** - * Merges the patch into the headers of every queued and parked entry, then + * Merges the patch into the headers of every entry not yet forgotten and * resumes the entries parked on 'awaiting-auth'. This is how a fresh token - * reaches requests that stalled on 401. + * reaches requests that stalled on 401. Native bumps a header generation, so + * a 401 from an attempt issued under the old headers re-issues at once + * instead of parking. */ const updateHeaders = (patch: Record): Promise => native.updateHeaders(patch); /** - * The live rows of the queue, read synchronously from native's in-memory - * index. Works offline. Completed entries leave after their ack. + * Every entry native has not yet forgotten, read synchronously from its + * in-memory index. Works offline. Completed and cancelled entries leave + * after their ack; an error entry stays until cancel() or a same-id + * mutate(). */ const getRequests = (filter?: { key?: string; diff --git a/src/registry.ts b/src/registry.ts index 7a701d40..cea44514 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -18,8 +18,11 @@ export const DEFAULT_LIFETIME_MS = 14 * 24 * 60 * 60 * 1000; * bug (a path that never settles), not a tuning knob. */ export const DEFAULT_ENQUEUE_TIMEOUT_MS = 10_000; -/** `vars` are persisted natively next to every entry. Only they are capped. */ -export const MAX_VARS_BYTES = 4096; +/** + * Default `configure().maxVarsBytes`. `vars` are persisted natively next to + * every entry. Only they are capped. + */ +export const MAX_VARS_BYTES = 1_048_576; const METHODS: readonly Method[] = ['POST', 'PUT', 'PATCH', 'DELETE', 'GET']; @@ -49,6 +52,7 @@ const ANDROID_KEYS = ['noNotification']; export type Settings = { lifetimeMs: number; enqueueTimeoutMs: number; + maxVarsBytes: number; headers?: () => Record; retry?: Partial; }; @@ -504,10 +508,11 @@ export const createRegistry = ({ ): Promise<{ id: string }> => { // A no-vars definition calls mutate() with nothing; native stores null. const vars = (input === undefined ? null : input) as V; + const settings = getSettings(); const bytes = utf8ByteLength(serializeVars(vars)); - if (bytes > MAX_VARS_BYTES) { + if (bytes > settings.maxVarsBytes) { throw new Error( - `mutate: vars for "${key}" is ${bytes} bytes; the limit is ${MAX_VARS_BYTES}`, + `mutate: vars for "${key}" is ${bytes} bytes; the limit is ${settings.maxVarsBytes} (configure().maxVarsBytes)`, ); } if (options?.id !== undefined && !options.id) { @@ -517,7 +522,6 @@ export const createRegistry = ({ // request() is the one that runs. const current = (definitions.get(key) ?? definition) as Definition; const descriptor = validateDescriptor(current.request(vars)); - const settings = getSettings(); const provided = settings.headers?.() ?? {}; if (!isPlainObject(provided)) { throw new Error('mutate: configure().headers() must return an object'); diff --git a/src/types.ts b/src/types.ts index 2a4ac735..82bc5c34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -126,7 +126,11 @@ export type OutcomeError = { /** * Handler context. `at` is the native outcome time. `requestId` is the last - * attempt's X-Request-Id. + * attempt's X-Request-Id. `deliveries` counts how many times this outcome has + * reached JS: 1 on the first delivery, more after a handler rejection, an app + * death before the ack, or a boot replay. A handler that keeps throwing sees + * it grow. The library never gives up on its own, so the app decides a poison + * policy from this number. */ export type Meta = { id: string; @@ -134,6 +138,7 @@ export type Meta = { at: number; attempts: number; requestId?: string; + deliveries: number; }; export type Outcome = @@ -164,6 +169,8 @@ export type RequestRow = { totalBytes: number; attempts: number; updatedAt: number; + /** Epoch ms. Set while the entry waits out a retry backoff. */ + nextAttemptAt?: number; }; /** @@ -210,8 +217,12 @@ type DefinitionBase = { /** A definition with a parser. `onSuccess` receives what `response` returns. */ export type DefinitionWithResponse = DefinitionBase & { - /** Parses the JSON body (`undefined` when there is none) before `onSuccess`. */ - response: (raw: unknown) => T; + /** + * Parses the JSON body (`undefined` when there is none) before `onSuccess`. + * `vars` is the entry's own, for a parser that needs the request context. A + * one-argument parser such as `schema.parse` is assignable as it is. + */ + response: (raw: unknown, vars: V) => T; onSuccess?: (data: T, vars: V, meta: Meta) => void | Promise; }; @@ -279,6 +290,11 @@ export type AndroidNotificationConfig = { export type ConfigureOptions = { /** Default 14 days. Sets the default `expiresAt` of every entry. */ lifetimeMs?: number; + /** + * Default 1 MB. `mutate()` rejects `vars` whose JSON is longer. Only `vars` + * are capped, because native persists them next to every entry. + */ + maxVarsBytes?: number; /** Defaults: base 1 s, max 2 h, jitter 0.2, exempt [404]. */ retry?: Partial; /** Called at `mutate()`. The descriptor's headers merge over the result. */ From 4bee32dbe1d2fdaf0ad90c8b715776f8d5cd8c77 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 15:39:04 -0400 Subject: [PATCH 07/22] Pin the native contract after the slice review Findings from the Android and iOS review that land on the JS side: - vars and data cross the bridge as JSON strings (varsJson, dataJson). React Native on iOS drops null-valued object keys, so an object form loses fields such as { status: null }. A "null" body is a real body; a bodiless request omits data. - A GET with a body is rejected at mutate(). DELETE with a body stays. - E_INVALID joins the enqueue rejection codes for input native cannot send: non-http(s) URL, bad header names or values, GET with a body, parts that do not tile the moved file. - deliveries counts deliveries that reached a JS listener; an outcome journaled with no listener starts at 0 and is not emitted live. - A different url or method is a different body. attempts count the current generation. cancel of a settled entry also forgets its unacknowledged outcomes. updateHeaders also replaces same-named part headers. A paused entry past expiresAt settles at resume. - AttemptEvent.outcome narrows to completed | error; pause, cancel, and supersede emit no attempt event. - The watchdog text says a timeout means native did not answer, and that staging a large file body takes time. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 16 +++--- README.md | 65 +++++++++++++++-------- src/NativeRNFileUploader.ts | 42 ++++++++++----- src/__tests__/client.test.ts | 4 +- src/__tests__/registry.test.ts | 96 +++++++++++++++++++++++++++++----- src/__typetests__/define.ts | 7 +++ src/delivery.ts | 6 +-- src/index.ts | 13 +++-- src/registry.ts | 70 +++++++++++++++++-------- src/types.ts | 40 ++++++++++---- 10 files changed, 265 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a4f904..c66ec282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,9 @@ Added: 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 (at most one of - `data` / `form` / `file`, none for a bodiless DELETE; `parts` only with `file`; parts must tile the - file; no field outside the descriptor shape), defaults `expiresAt` to now + + `data` / `form` / `file`, none for a bodiless DELETE; no body on a GET; + `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` is any JSON-serializable object, so generated API request types work as they are; `mutate()` rejects vars or `data` that do not serialize (a cycle, a function, @@ -56,7 +57,7 @@ Added: definition whose `request` takes no vars calls `mutate()` with no arguments. It resolves after the row and every staged body copy are on disk, so the caller may delete its source file then; native failures reject with - `E_RUNNING`, `E_FILE_MISSING`, or `E_STORAGE`. + `E_INVALID`, `E_RUNNING`, `E_FILE_MISSING`, or `E_STORAGE`. - **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 @@ -65,15 +66,16 @@ Added: unacknowledged and reaches `state` listeners with `reason: 'unhandled-key'`; a handler that has not settled after 30 s logs a warning. No ordering is promised between different ids. -- **`Meta.deliveries`**: how many times an outcome has reached JS, including - boot replays. A handler that keeps throwing sees it grow; the library never - gives up on its own, so the app decides a poison policy. +- **`Meta.deliveries`**: counts deliveries that reached a JS listener: 1 on + the first, +1 per replay. A handler that keeps throwing sees it grow; the + library never gives up on its own, so the app decides a poison policy. - **`RequestRow.nextAttemptAt`**: epoch ms, set while an entry waits out a retry backoff. `getRequests()` returns every entry native has not yet forgotten, so completed and cancelled rows appear until their ack. - **`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. + attempt. Its `outcome` is `completed` or `error`; pause, cancel, and + supersede emit none. Removed: - `startUpload`, `startChunkedUpload` (native), `cancelUpload`, diff --git a/README.md b/README.md index af91297b..ca2501d3 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,9 @@ outcome at the next launch. ## The request descriptor -`request(vars)` returns a plain object. Set at most one body kind. A DELETE, or a -POST whose meaning is in the URL, sets none. +`request(vars)` returns a plain object. Set at most one body kind. A GET sets +none, and `mutate()` rejects a GET with a body. A DELETE, or a POST whose +meaning is in the URL, may also set none. A field outside this table makes `mutate()` reject and name the field, because TypeScript does not flag a misspelled key on an inferred arrow return. @@ -114,7 +115,7 @@ TypeScript does not flag a misspelled key on an inferred arrow return. | `url` | Required unless `parts` is set. | | `method` | `POST` (default), `PUT`, `PATCH`, `DELETE`, `GET`. With `parts` it applies to every part. | | `headers` | Merged over `configure().headers()`, names matched without regard to case. Every chunked part inherits the result. | -| `data` | JSON body. Any JSON-serializable value. | +| `data` | JSON body. Any JSON-serializable value. `null` sends the JSON body `null`; omit `data` for no body. | | `form` | `multipart/form-data`: `[{ name, contentType, string }]` or `[{ name, contentType, path, fileName? }]`. File parts are copied. | | `file` | Whole file body. Copied. Moved when `parts` is set. | | `parts` | Chunked over `file`: `[{ url, headers?, range: { start, end } }]`, bytes, end exclusive, tiling the file from 0. | @@ -123,6 +124,10 @@ TypeScript does not flag a misspelled key on an inferred arrow return. | `retry` | Per-request override of the `configure()` retry defaults. | | `android` | `{ noNotification?: boolean }`. See Silent uploads. | +`vars` and `data` cross to native as JSON strings, and native parses them. +React Native on iOS drops object keys whose value is `null`, so a string is +the only form in which `{ status: null }` arrives intact. + ### Chunked uploads A descriptor with `file` and `parts` sends one file as many part requests but @@ -162,7 +167,9 @@ const captureFile = uploads.define({ are deleted after a `completed` outcome is acknowledged, or on `cancel()`. Nothing else deletes them. -**Same id, again.** `mutate()` with an id that exists: +**Same id, again.** The body is `data`, `form`, `file`, or `parts`, and a +different `url` or `method` counts as a different body. `mutate()` with an +id that exists: - Same body: resume. New headers, `expiresAt`, and `vars` replace the stored ones. @@ -189,18 +196,25 @@ the OS may defer or restart it. Reserve it for small payloads. 1. **Write-ahead.** Entry, descriptor, and staged body persist before any attempt. `mutate()` resolves after the row and every staged body copy are durably on disk (temp file plus rename), so the caller may delete its - source file then. A native failure rejects with a code: `E_RUNNING`, - `E_FILE_MISSING`, or `E_STORAGE`. + source file then. A native failure rejects with a code: `E_INVALID`, + `E_RUNNING`, `E_FILE_MISSING`, or `E_STORAGE`. `E_INVALID` is malformed + input native cannot send: a non-http(s) URL, header names or values the + platform HTTP client rejects, a GET with a body, or parts that do not + tile the moved file. 2. **Journal before emit, ack after the handler.** Every terminal outcome is journaled natively, then delivered. The library acknowledges after the handler's promise resolves. A rejection, or app death before the ack, redelivers at the next launch. A handler that has not settled after 30 s - gets a console warning and keeps waiting. `Meta.deliveries` counts the - deliveries of one outcome, including boot replays, so a handler that - keeps throwing sees it grow. The library never gives up on its own; the - app decides a poison policy from that number. -3. **One outcome per settle cycle.** `pause()` produces none. A same-id - `mutate()` on a settled entry reopens it, and it settles once more. + gets a console warning and keeps waiting. `Meta.deliveries` counts + deliveries that reached a JS listener: 1 on the first, +1 per replay. An + outcome journaled while no listener exists starts at 0 and is not emitted + live. A handler that keeps throwing sees the number grow. The library + never gives up on its own; the app decides a poison policy from that + number. +3. **One outcome per settle cycle.** `pause()` produces none. A paused entry + past `expiresAt` settles `error` with `errorKind: 'expired'` at + `resume()`. A same-id `mutate()` on a settled entry reopens it, and it + settles once more. 4. **Never before `mutate()` resolves.** Delivery for an id waits for the caller's promise. 5. **Replay starts after `configure()`.** Outcomes journaled by a dead session @@ -272,8 +286,11 @@ is replaced, with a warning in development. A `cancelled` outcome calls no handler. `Meta` is `{ id, key, at, attempts, requestId?, deliveries }`; `at` is the -native outcome time. `deliveries` is 1 on the first delivery of an outcome and -grows by one on every redelivery, including a boot replay. +native outcome time. `attempts` counts attempts in the current generation; a +same-id `mutate()` that reopens the entry starts a new one. `deliveries` +counts deliveries that reached a JS listener: 1 on the first, +1 per replay. +An outcome journaled while no listener exists starts at 0 and is not emitted +live, so its first delivery, at the boot replay, is 1. ### `mutate(vars, { id? }): Promise<{ id }>` @@ -281,8 +298,9 @@ Runs `request(vars)` once, merges `configure().headers()` under the descriptor's headers, validates the descriptor, defaults `expiresAt`, and persists the entry. Resolves with the id after the row and every staged body copy are on disk. Rejects on a malformed descriptor, an unknown descriptor -field, a missing file (`E_FILE_MISSING`), a storage failure (`E_STORAGE`), a -running entry with a different body (`E_RUNNING`), or `vars` over +field, a GET with a body, input native cannot send (`E_INVALID`), a missing +file (`E_FILE_MISSING`), a storage failure (`E_STORAGE`), a running entry +with a different body (`E_RUNNING`), or `vars` over `maxVarsBytes` (1 MB by default). Only `vars` are capped. `id` defaults to a UUID. For a definition whose `request` takes no vars, call `mutate()` with no arguments; native stores `null`. @@ -298,23 +316,26 @@ outcomes. A second call updates the settings and does not replay again. | `maxVarsBytes` | Cap on the JSON length of `vars`. Default 1 MB. `mutate()` rejects above it. JS-side only. | | `retry` | `{ backoff?: { baseMs, maxMs, jitter }, terminalHttp?: { exempt } }`. Each of the two objects is optional, but one you give must be complete. Defaults 1 s, 2 h, 0.2, `[404]`. | | `headers` | `() => Record`, called at `mutate()`. The descriptor merges over it. | -| `enqueueTimeoutMs` | Default 10 s. `mutate()` rejects and warns when the native write has not settled by then. A watchdog for a native bug, not a tuning knob. | +| `enqueueTimeoutMs` | Default 10 s. `mutate()` rejects and warns when native enqueue has not settled by then. Enqueue includes the time to stage a copy of a `file` body and of form `path` parts, so a large file takes longer. A timeout means native did not answer, not that the request failed. A watchdog for a native bug, not a tuning knob. | | `android` | Notification text and identity: `notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`. Persisted natively. | ### `pause(): Promise` and `resume(): Promise` -Whole-queue pause. No outcome is produced; live rows show `paused`. +Whole-queue pause. No outcome is produced; live rows show `paused`. A paused +entry past `expiresAt` settles `error` with `errorKind: 'expired'` at +`resume()`. ### `cancel(id): Promise` A live entry settles `cancelled` with reason `user` and is forgotten after -its ack. A settled entry is forgotten now, row and bytes. An unknown id -resolves and does nothing. +its ack. A settled entry is forgotten now: row, bytes, and its +unacknowledged outcomes. An unknown id resolves and does nothing. ### `setWifiOnly(enabled): Promise` Persisted natively. Applies to queued and future entries. ### `updateHeaders(patch): Promise` Merges the patch into the headers of every entry not yet forgotten and -resumes the entries parked on `awaiting-auth`. This is how a fresh token +resumes the entries parked on `awaiting-auth`. The patch also replaces +same-named headers a part carries. This is how a fresh token reaches requests that stalled on 401. Each call bumps a header generation: a 401 or 403 from an attempt issued under an older generation re-issues at once instead of parking. Parking emits one `state` event per entry. @@ -356,7 +377,7 @@ Fires when the Android progress notification is pressed. No event data. | --- | --- | | `state` | A full `RequestRow`, one per transition, plus `reason: 'unhandled-key'` for an outcome whose key has no definition. A consumer's reducer is one upsert. | | `progress` | `{ id, bytesSent, totalBytes }`, byte-weighted across a chunked upload's parts. | -| `attempt` | One HTTP attempt before interpretation: `{ id, key, requestId, attempt, url, method, partIndex?, outcome, httpCode?, responseBody? (4 KB cap), responseBodyTruncated?, responseHeaders?, errorKind?, errorMessage?, cancelReason?, at }`. Live-only; never journaled or replayed. | +| `attempt` | One HTTP attempt: `{ id, key, requestId, attempt, url, method, partIndex?, outcome, httpCode?, responseBody? (4 KB cap), responseBodyTruncated?, responseHeaders?, errorKind?, errorMessage?, at }`. `outcome` is `completed` for an accepted response (2xx or a matching `accept` rule). Any other HTTP response is `error` with `errorKind: 'http'`; a transport failure is `error` with its own `errorKind`. Pause, cancel, and supersede emit no attempt event. Live-only; never journaled or replayed. | Terminal outcomes do not appear here. They go to the definition's handlers. diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts index f5b9e05d..0323e729 100644 --- a/src/NativeRNFileUploader.ts +++ b/src/NativeRNFileUploader.ts @@ -16,13 +16,24 @@ export interface Spec extends TurboModule { // Android persists the notification config, so a headless WorkManager // relaunch (no JS) can read it. Each call replaces the full configuration. configure(options: CodegenTypes.UnsafeObject): void; - // Persists { id, key, vars, descriptor } and schedules it. Resolves AFTER - // the row and every staged body copy are durably on disk (tmp file + - // rename), never on the network. The caller may delete its source file - // once mutate() resolves. Every failure path rejects with a code: - // E_RUNNING, E_FILE_MISSING, E_STORAGE. + // Persists { id, key, varsJson, descriptor } and schedules it. varsJson is + // JSON.stringify(vars). descriptor.dataJson is JSON.stringify(data) and + // replaces data; a bodiless request omits it. Both cross as strings + // because React Native on iOS drops object keys whose value is null, so + // { status: null } would arrive as {}. Native parses them. dataJson + // "null" is the JSON body null, a real body, not an absent one. // - // Same id, again: + // Resolves AFTER the row and every staged body copy are durably on disk + // (tmp file + rename), never on the network. Staging copies a `file` body + // and form `path` parts, so a large file takes longer. The caller may + // delete its source file once mutate() resolves. Every failure path + // rejects with a code: E_INVALID, E_RUNNING, E_FILE_MISSING, E_STORAGE. + // E_INVALID is malformed input native cannot send: non-http(s) URL, header + // names or values the platform HTTP client rejects, a GET with a body, + // parts that do not tile the moved file. + // + // Same id, again. The body is data/form/file/parts, and a different url or + // method is a different body. // - Same body: resume. New headers, expiresAt and vars replace the stored // ones. // - Different body (data/form/file/parts) and the entry is NOT running @@ -38,15 +49,17 @@ export interface Spec extends TurboModule { // The resolved value is the entry's id. The JS layer does not read it. enqueue(entry: CodegenTypes.UnsafeObject): Promise; // Whole-queue pause. No outcome is produced; live rows move to 'paused'. + // A paused entry past expiresAt settles error/expired at resume. pause(): Promise; resume(): Promise; // Live entry: journal 'cancelled' (user), forget after its ack. Settled - // entry: forget now, row and bytes. Unknown id: resolve, no-op. + // entry: forget now: row, bytes, and its unacknowledged outcomes. Unknown + // id: resolve, no-op. cancel(id: string): Promise; // Persisted natively. Applies to queued and future entries. setWifiOnly(enabled: boolean): Promise; // Merges the patch into every entry not yet forgotten and bumps a header - // generation. A 401/403 from an attempt issued under an older generation + // generation. The patch also replaces same-named headers a part carries. A 401/403 from an attempt issued under an older generation // re-issues at once instead of parking. Parking emits one 'state' event per // entry. updateHeaders(patch: CodegenTypes.UnsafeObject): Promise; @@ -75,15 +88,20 @@ export interface Spec extends TurboModule { bytesSent: number; totalBytes: number; }>; - // One HTTP attempt before interpretation, in the AttemptEvent shape. - // Live-only: never journaled, never replayed. + // One HTTP attempt, in the AttemptEvent shape. outcome 'completed' is an + // accepted response. Any other HTTP response is 'error' with errorKind + // 'http'. A transport failure is 'error' with its own errorKind. Pause, + // cancel, and supersede emit no attempt event. Live-only: never journaled, + // never replayed. readonly onAttempt: CodegenTypes.EventEmitter; // The journaled terminal outcome, emitted after the journal write, in the // ./delivery SettledEvent shape: { eventId, id, key, vars, at, attempts, // requestId?, deliveries, state, bytesSent?, totalBytes?, url, method, // partIndex? } plus the outcome fields. eventId is a UUID string native - // mints. deliveries is 1 on the first emit and increments on every later - // delivery of the same eventId, including boot replays. No ordering + // mints. deliveries counts deliveries that reached a JS listener: 1 on the + // first, +1 per replay; an outcome journaled while no listener exists + // starts at 0 and is not emitted live. When the field is missing, the JS + // layer treats it as 1. No ordering // guarantee between different ids; the JS layer orders one id's outcomes. // ./delivery routes it to the definition's handlers. readonly onSettled: CodegenTypes.EventEmitter; diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 849cf365..b1d7a670 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -507,10 +507,10 @@ describe('end to end', () => { expect(native.enqueue).toHaveBeenCalledWith({ id: 'local-3', key: 'item.create', - vars: { n: 3 }, + varsJson: '{"n":3}', descriptor: { url: 'https://x/3', - data: { n: 3 }, + dataJson: '{"n":3}', headers: { Authorization: 'Bearer t' }, expiresAt: expect.any(Number), }, diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 470b5a32..89cf1512 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -81,7 +81,7 @@ describe('define', () => { }); describe('mutate', () => { - it('runs request(vars) exactly once and enqueues { id, key, vars, descriptor }', async () => { + it('runs request(vars) exactly once and enqueues { id, key, varsJson, descriptor }', async () => { const { define, enqueue, lastEntry } = setup(); const request = jest.fn(jsonPost); const create = define({ key: 'item.create', request }); @@ -92,21 +92,57 @@ describe('mutate', () => { expect(lastEntry()).toMatchObject({ id: 'local-7', key: 'item.create', - vars: { n: 7 }, - descriptor: { url: 'https://example.com/items/7', data: { n: 7 } }, + varsJson: '{"n":7}', + descriptor: { url: 'https://example.com/items/7', dataJson: '{"n":7}' }, }); + expect(lastEntry()).not.toHaveProperty('vars'); + expect(lastEntry().descriptor).not.toHaveProperty('data'); expect(result).toEqual({ id: 'local-7' }); }); + it('sends vars and data as JSON strings, so null-valued keys survive the bridge', async () => { + const { define, lastEntry } = setup(); + const patch = define({ + key: 'k', + request: (vars: { status: string | null }) => ({ + url: 'https://x', + method: 'PATCH', + data: { status: vars.status, tags: [null] }, + }), + }); + await patch.mutate({ status: null }); + expect(lastEntry().varsJson).toBe('{"status":null}'); + expect(lastEntry().descriptor.dataJson).toBe( + '{"status":null,"tags":[null]}', + ); + }); + + it('sends data: null as the JSON body "null" and omits dataJson without a body', async () => { + const { define, lastEntry } = setup(); + const withNull = define({ + key: 'null-body', + request: (_vars: null) => ({ url: 'https://x', data: null }), + }); + await withNull.mutate(); + expect(lastEntry().descriptor.dataJson).toBe('null'); + const bodiless = define({ + key: 'bodiless', + request: (_vars: null) => ({ url: 'https://x', method: 'DELETE' }), + }); + await bodiless.mutate(); + expect(lastEntry().descriptor).not.toHaveProperty('dataJson'); + expect(lastEntry().descriptor).not.toHaveProperty('data'); + }); + it('stores null vars for a mutate() with no arguments', async () => { const request = jest.fn(() => ({ url: 'https://x', data: null })); const { define, lastEntry } = setup(); const ping = define({ key: 'ping', request }); await ping.mutate(); expect(request).toHaveBeenCalledWith(null); - expect(lastEntry().vars).toBeNull(); + expect(lastEntry().varsJson).toBe('null'); await ping.mutate(undefined, { id: 'fixed' }); - expect(lastEntry()).toMatchObject({ id: 'fixed', vars: null }); + expect(lastEntry()).toMatchObject({ id: 'fixed', varsJson: 'null' }); }); it('resolves with the entry id, not the id native returns', async () => { @@ -245,7 +281,7 @@ describe('mutate', () => { ); }); - it('accepts a class instance and passes it through unchanged', async () => { + it('accepts a class instance and sends its fields without its methods', async () => { class Point { constructor(public x: number, public y: number) {} norm() { @@ -255,15 +291,14 @@ describe('mutate', () => { const { send, lastEntry } = anyVars(); const point = new Point(1, 2); await expect(send.mutate(point)).resolves.toBeDefined(); - // Only validated. Native stringifies, which drops the method. - expect(lastEntry().vars).toBe(point); + expect(lastEntry().varsJson).toBe('{"x":1,"y":2}'); }); it('accepts nested undefined fields and an array', async () => { const { send, lastEntry } = anyVars(); const vars = { title: undefined, ids: ['a'] as readonly string[] }; await expect(send.mutate(vars)).resolves.toBeDefined(); - expect(lastEntry().vars).toBe(vars); + expect(lastEntry().varsJson).toBe('{"ids":["a"]}'); await expect(send.mutate([1, 2])).resolves.toBeDefined(); }); }); @@ -325,11 +360,48 @@ describe('mutate', () => { ).rejects.toThrow(/data is not JSON-serializable/); }); - it('passes data with nested undefined fields through unchanged', async () => { + it('drops nested undefined fields from data, as JSON.stringify does', async () => { const data = { title: undefined, value: { any: 1 } }; const { promise, enqueue } = mutateWith({ url: 'https://x', data }); await expect(promise).resolves.toBeDefined(); - expect(enqueue.mock.calls[0][0].descriptor.data).toBe(data); + expect(enqueue.mock.calls[0][0].descriptor.dataJson).toBe( + '{"value":{"any":1}}', + ); + }); + + it('rejects a GET with a body and accepts a GET without one', async () => { + await expect( + mutateWith({ url: 'https://x', method: 'GET', data: {} }).promise, + ).rejects.toThrow('mutate: a GET request cannot have a body; got data'); + await expect( + mutateWith({ url: 'https://x', method: 'GET', file: '/f' }).promise, + ).rejects.toThrow(/GET request cannot have a body; got file/); + await expect( + mutateWith({ + url: 'https://x', + method: 'GET', + form: [{ name: 'a', contentType: 'text/plain', string: 'b' }], + }).promise, + ).rejects.toThrow(/GET request cannot have a body; got form/); + const { promise, enqueue } = mutateWith({ + url: 'https://x', + method: 'GET', + }); + await expect(promise).resolves.toBeDefined(); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('accepts a DELETE with a body', async () => { + const { promise, enqueue } = mutateWith({ + url: 'https://x', + method: 'DELETE', + data: { ids: ['a'] }, + }); + await expect(promise).resolves.toBeDefined(); + expect(enqueue.mock.calls[0][0].descriptor).toMatchObject({ + method: 'DELETE', + dataJson: '{"ids":["a"]}', + }); }); it('rejects a form part without exactly one of string, path', async () => { @@ -704,7 +776,7 @@ describe('enqueue watchdog', () => { expect(warn).not.toHaveBeenCalled(); await jest.advanceTimersByTimeAsync(1); expect(await outcome).toMatch( - /native enqueue for "item.create" \(id [^)]+\) did not settle within 10000 ms/, + /native enqueue for "item.create" \(id [^)]+\) did not settle within 10000 ms\. Native did not answer; the entry may still be persisted\.$/, ); expect(warn).toHaveBeenCalledTimes(1); }); diff --git a/src/__typetests__/define.ts b/src/__typetests__/define.ts index a05567a3..2240a258 100644 --- a/src/__typetests__/define.ts +++ b/src/__typetests__/define.ts @@ -270,3 +270,10 @@ void nested.mutate({ inner: { b: 1 }, ids: ['x'] }); // The client's define is the same overloaded signature. declare const define: typeof client.define; expectType(define); + +// An attempt is completed or error. Pause, cancel and supersede emit none. +client.addListener('attempt', (e) => { + assertEqual(true); + // @ts-expect-error an attempt event carries no cancelReason + void e.cancelReason; +}); diff --git a/src/delivery.ts b/src/delivery.ts index 062951fd..03eb9628 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -27,9 +27,9 @@ export type SettledEvent = { attempts: number; requestId?: string; /** - * Native sets it to 1 on the first emit of an outcome and increments it on - * every later delivery of the same eventId, including boot replays. When - * native omits it, the JS layer treats it as 1. + * Counts deliveries that reached a JS listener: 1 on the first, +1 per + * replay. An outcome journaled while no listener exists starts at 0 and is + * not emitted live. When native omits it, the JS layer treats it as 1. */ deliveries?: number; /** The entry's real state, for the unhandled-key row. */ diff --git a/src/index.ts b/src/index.ts index 648306d8..ec7a9e23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,7 +109,10 @@ export const createUploadClient = (): UploadClient => { delivery.start(); }; - /** Pauses the whole queue. No outcome is produced; live rows show 'paused'. */ + /** + * Pauses the whole queue. No outcome is produced; live rows show 'paused'. + * A paused entry past its expiresAt settles error/expired at resume. + */ const pause = (): Promise => native.pause(); /** Resumes a paused queue. */ @@ -117,7 +120,8 @@ export const createUploadClient = (): UploadClient => { /** * On a live entry: settles it 'cancelled' with reason 'user', then forgets - * it after the ack. On a settled entry: forgets it now, row and bytes. + * it after the ack. On a settled entry: forgets it now: row, bytes, and its + * unacknowledged outcomes. */ const cancel = (id: string): Promise => native.cancel(id); @@ -127,7 +131,8 @@ export const createUploadClient = (): UploadClient => { /** * Merges the patch into the headers of every entry not yet forgotten and - * resumes the entries parked on 'awaiting-auth'. This is how a fresh token + * resumes the entries parked on 'awaiting-auth'. The patch also replaces + * same-named headers a part carries. This is how a fresh token * reaches requests that stalled on 401. Native bumps a header generation, so * a 401 from an attempt issued under the old headers re-issues at once * instead of parking. @@ -161,7 +166,7 @@ export const createUploadClient = (): UploadClient => { * the event's id to tell requests apart. 'state' carries a full RequestRow * per transition, plus a row with reason 'unhandled-key' for an outcome * whose key has no definition. 'progress' is byte-weighted. 'attempt' is - * one HTTP attempt before interpretation. + * one HTTP attempt, emitted live before the library settles the entry. */ const addListener = (( event: 'state' | 'progress' | 'attempt', diff --git a/src/registry.ts b/src/registry.ts index cea44514..405be906 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -13,9 +13,11 @@ import type { export const DEFAULT_LIFETIME_MS = 14 * 24 * 60 * 60 * 1000; /** - * How long mutate() waits for native enqueue() before it rejects. The native - * write is one synchronous disk write, so this is a watchdog for a native - * bug (a path that never settles), not a tuning knob. + * How long mutate() waits for native enqueue() before it rejects. Enqueue + * includes the time to stage a copy of a `file` body and of form `path` + * parts, so a large file takes longer. A timeout means native did not + * answer, not that the request failed. This is a watchdog for a native path + * that never settles, not a tuning knob. */ export const DEFAULT_ENQUEUE_TIMEOUT_MS = 10_000; /** @@ -77,12 +79,24 @@ export const withTimeout = ( ); }); -/** What crosses to native `enqueue()`. */ +/** + * The descriptor as it crosses to native. `dataJson` is `JSON.stringify(data)` + * and replaces `data`. It is absent for a bodiless request. + */ +export type NativeDescriptor = Omit & { + dataJson?: string; +}; + +/** + * What crosses to native `enqueue()`. `vars` and `data` cross as JSON + * strings, because React Native on iOS drops object keys whose value is + * null. Native parses them. + */ export type EnqueueEntry = { id: string; key: string; - vars: Vars; - descriptor: RequestDescriptor; + varsJson: string; + descriptor: NativeDescriptor; }; // The registry stores definitions of every shape under one map. The generic @@ -162,12 +176,15 @@ const serializeVars = (vars: unknown): string => { return serialized; }; -const validateData = (data: unknown): void => { - if (stringifyForNative(data, 'data') === undefined) { +/** The JSON that native sends as the body. `null` is the JSON body `null`. */ +const serializeData = (data: unknown): string => { + const serialized = stringifyForNative(data, 'data'); + if (serialized === undefined) { throw new Error( `mutate: data must be a JSON-serializable value, got ${typeof data}`, ); } + return serialized; }; /** UTF-8 length of a string that JSON.stringify produced. */ @@ -397,8 +414,9 @@ const mergeHeaders = ( * Rejects a malformed descriptor before it crosses the bridge. Then native * never persists an entry that cannot run. Nested objects are checked for * unknown keys too, so a misspelled field cannot be dropped in silence. + * Returns the descriptor in its bridge form, with `data` as `dataJson`. */ -export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { +export const validateDescriptor = (descriptor: unknown): NativeDescriptor => { if (!isPlainObject(descriptor)) { throw new Error('mutate: request() must return a descriptor object'); } @@ -407,7 +425,8 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { const kinds = (['data', 'form', 'file'] as const).filter( (kind) => d[kind] !== undefined, ); - // No body is valid: a DELETE, or a POST that carries its meaning in the URL. + // No body is valid: a GET, a DELETE, or a POST that carries its meaning in + // the URL. if (kinds.length > 1) { throw new Error( `mutate: the descriptor must set at most one of data, form, file; got ${kinds.join( @@ -432,15 +451,18 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { )}`, ); } + // A DELETE may carry a body, because some APIs take one. + if (d.method === 'GET' && kinds.length > 0) { + throw new Error( + `mutate: a GET request cannot have a body; got ${kinds.join(', ')}`, + ); + } if (d.headers !== undefined && !isPlainObject(d.headers)) { throw new Error('mutate: headers must be a plain object when present'); } if (d.file !== undefined && (typeof d.file !== 'string' || !d.file)) { throw new Error('mutate: file must be a non-empty path'); } - if (d.data !== undefined) { - validateData(d.data); - } if (d.form !== undefined) { validateForm(d.form); } @@ -464,7 +486,8 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { `mutate: expiresAt must be a finite epoch-ms timestamp, got ${d.expiresAt}`, ); } - return d; + const { data, ...rest } = d; + return data === undefined ? rest : { ...rest, dataJson: serializeData(data) }; }; /** @@ -509,7 +532,8 @@ export const createRegistry = ({ // A no-vars definition calls mutate() with nothing; native stores null. const vars = (input === undefined ? null : input) as V; const settings = getSettings(); - const bytes = utf8ByteLength(serializeVars(vars)); + const varsJson = serializeVars(vars); + const bytes = utf8ByteLength(varsJson); if (bytes > settings.maxVarsBytes) { throw new Error( `mutate: vars for "${key}" is ${bytes} bytes; the limit is ${settings.maxVarsBytes} (configure().maxVarsBytes)`, @@ -529,23 +553,25 @@ export const createRegistry = ({ const entry: EnqueueEntry = { id: options?.id ?? uuidV4(), key, - vars, + varsJson, descriptor: { ...descriptor, headers: mergeHeaders(provided, descriptor.headers), expiresAt: descriptor.expiresAt ?? now() + settings.lifetimeMs, }, }; - // Watchdog. Native enqueue() is one synchronous write, so a promise that - // never settles is a native bug. Turn it into a rejection with a name, - // and let delivery for this id proceed instead of waiting forever. If - // native did persist the entry, its outcome still reaches the handlers, - // and a same-id retry resumes instead of duplicating. + // Watchdog. Turn a native enqueue() that never settles into a rejection + // with a name, and let delivery for this id proceed instead of waiting + // forever. Enqueue includes the time to stage a copy of a `file` body + // and of form `path` parts, so a large file takes longer. A timeout + // means native did not answer, not that the request failed. If native + // did persist the entry, its outcome still reaches the handlers, and a + // same-id retry resumes instead of duplicating. const pending = withTimeout( native.enqueue(entry), settings.enqueueTimeoutMs, () => { - const message = `mutate: native enqueue for "${key}" (id ${entry.id}) did not settle within ${settings.enqueueTimeoutMs} ms`; + const message = `mutate: native enqueue for "${key}" (id ${entry.id}) did not settle within ${settings.enqueueTimeoutMs} ms. Native did not answer; the entry may still be persisted.`; warn(message); return new Error(message); }, diff --git a/src/types.ts b/src/types.ts index 82bc5c34..045d4b59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -85,7 +85,8 @@ export type RequestDescriptor = { headers?: Record; /** * JSON body. Any JSON-serializable value. At most one of `data`, `form`, - * `file`. None is a bodiless request. + * `file`. None is a bodiless request. `null` is the JSON body `null`, not + * an absent body. A GET takes no body. */ data?: unknown; /** multipart/form-data body. */ @@ -126,18 +127,25 @@ export type OutcomeError = { /** * Handler context. `at` is the native outcome time. `requestId` is the last - * attempt's X-Request-Id. `deliveries` counts how many times this outcome has - * reached JS: 1 on the first delivery, more after a handler rejection, an app - * death before the ack, or a boot replay. A handler that keeps throwing sees - * it grow. The library never gives up on its own, so the app decides a poison + * attempt's X-Request-Id. A handler that keeps throwing sees `deliveries` + * grow. The library never gives up on its own, so the app decides a poison * policy from this number. */ export type Meta = { id: string; key: string; at: number; + /** + * Attempts in the current generation. A same-id `mutate()` that reopens + * the entry starts a new generation. + */ attempts: number; requestId?: string; + /** + * Counts deliveries that reached a JS listener: 1 on the first, +1 per + * replay. An outcome journaled while no listener exists starts at 0 and is + * not emitted live, so its first delivery, at the boot replay, is 1. + */ deliveries: number; }; @@ -167,6 +175,10 @@ export type RequestRow = { state: RequestState; bytesSent: number; totalBytes: number; + /** + * Attempts in the current generation. A same-id `mutate()` that reopens + * the entry starts a new generation. + */ attempts: number; updatedAt: number; /** Epoch ms. Set while the entry waits out a retry backoff. */ @@ -186,7 +198,13 @@ export type ProgressEvent = { totalBytes: number; }; -/** One HTTP attempt, before the library interprets it. Response body is capped at 4 KB. */ +/** + * One HTTP attempt, before the library settles the entry. `completed` is an + * accepted response: 2xx, or a matching `accept` rule. Any other HTTP + * response is `error` with `errorKind: 'http'`. A transport failure is + * `error` with its own `errorKind`. Pause, cancel, and supersede emit no + * attempt event. The response body is capped at 4 KB. + */ export type AttemptEvent = { id: string; key: string; @@ -195,14 +213,13 @@ export type AttemptEvent = { url: string; method: Method; partIndex?: number; - outcome: 'completed' | 'error' | 'cancelled'; + outcome: 'completed' | 'error'; httpCode?: number; responseBody?: string; responseBodyTruncated?: boolean; responseHeaders?: Record; errorKind?: ErrorKind; errorMessage?: string; - cancelReason?: CancelReason; /** Native stamp, epoch ms. */ at: number; }; @@ -300,8 +317,11 @@ export type ConfigureOptions = { /** Called at `mutate()`. The descriptor's headers merge over the result. */ headers?: () => Record; /** - * Default 10 s. How long `mutate()` waits for the native write before it - * rejects. A watchdog for a native bug, not a tuning knob. + * Default 10 s. How long `mutate()` waits for native enqueue before it + * rejects. Enqueue includes the time to stage a copy of a `file` body and + * of form `path` parts, so a large file takes longer. A timeout means + * native did not answer, not that the request failed. A watchdog for a + * native bug, not a tuning knob. */ enqueueTimeoutMs?: number; android?: Partial; From 202c9dafafbd9a52c42d999548de6d9d8b445262 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 16:41:04 -0400 Subject: [PATCH 08/22] Contract: cancel rejects E_STORAGE when the journal or store cannot be written Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +++- src/NativeRNFileUploader.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ca2501d3..b146c38c 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,9 @@ entry past `expiresAt` settles `error` with `errorKind: 'expired'` at ### `cancel(id): Promise` A live entry settles `cancelled` with reason `user` and is forgotten after its ack. A settled entry is forgotten now: row, bytes, and its -unacknowledged outcomes. An unknown id resolves and does nothing. +unacknowledged outcomes. An unknown id resolves and does nothing. If the +journal or the store cannot be written, `cancel()` rejects with `E_STORAGE` and changes +nothing; the caller may call again. ### `setWifiOnly(enabled): Promise` Persisted natively. Applies to queued and future entries. diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts index 0323e729..bd97302b 100644 --- a/src/NativeRNFileUploader.ts +++ b/src/NativeRNFileUploader.ts @@ -54,7 +54,9 @@ export interface Spec extends TurboModule { resume(): Promise; // Live entry: journal 'cancelled' (user), forget after its ack. Settled // entry: forget now: row, bytes, and its unacknowledged outcomes. Unknown - // id: resolve, no-op. + // id: resolve, no-op. If the journal or the store cannot be written, + // cancel rejects with E_STORAGE and changes nothing; the caller may call + // again. cancel(id: string): Promise; // Persisted natively. Applies to queued and future entries. setWifiOnly(enabled: boolean): Promise; From dfd5d6df2b14bd08474929eeb7e3e30821a0d6af Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Tue, 6 Oct 2026 13:36:36 -0400 Subject: [PATCH 09/22] Contract: a live entry's cancel succeeds once its outcome is journaled Co-Authored-By: Claude Opus 5.5 --- src/NativeRNFileUploader.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts index bd97302b..19251079 100644 --- a/src/NativeRNFileUploader.ts +++ b/src/NativeRNFileUploader.ts @@ -52,9 +52,11 @@ export interface Spec extends TurboModule { // A paused entry past expiresAt settles error/expired at resume. pause(): Promise; resume(): Promise; - // Live entry: journal 'cancelled' (user), forget after its ack. Settled - // entry: forget now: row, bytes, and its unacknowledged outcomes. Unknown - // id: resolve, no-op. If the journal or the store cannot be written, + // Live entry: journal 'cancelled' (user), forget after its ack. The + // journaled record is the cancel: it resolves even if the entry save then + // fails, and the record is applied later. Settled entry: forget now: row, + // bytes, and its unacknowledged outcomes. Unknown id: resolve, no-op. If + // the journal (live entry) or the store (settled entry) cannot be written, // cancel rejects with E_STORAGE and changes nothing; the caller may call // again. cancel(id: string): Promise; From 18104ddf0cf20507cf97bbf693ac36bba79668ae Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 14:30:31 -0400 Subject: [PATCH 10/22] v10 slice 2: Android queue store, executor, and events Android implements the v10 contract pinned in the codegen spec. The v9 chunked manifest store generalizes into QueueStore: one durable entry per mutate() (id, key, vars, descriptor, staged body, state, attempts, bytes, expiresAt, header generation, deliveries) written tmp+fsync+rename before any attempt. BodyStaging writes JSON and multipart bodies to library files, copies single-file bodies, and moves chunked sources as v9 did. WorkManager runs one worker per entry. WorkerOps applies the retry table from the plan: accept rules with bodyIncludes, transient network/5xx/408/ 429 with jittered backoff and nextAttemptAt, 401/403 parking with a header generation that updateHeaders bumps, per-request exempt lists, expiry at expiresAt with bytes kept. The transfer semaphore (4) and chunked window (3) stay. Pause gates the queue; cancel settles a live entry and forgets a settled one; ack is void and idempotent and forgets only a matching generation. The journal writes every outcome before onSettled emits it; onState carries a full RequestRow, onProgress is throttled 1 s / 10 min, onAttempt is live-only. RequestIndex backs the synchronous getRequests. First launch imports v9 journal entries as legacy rows and cancels v9 work. Logic lives in JVM-testable classes (QueueController, WorkerOps, EnqueueRules, EntryTransitions); the module and workers are thin shells. 217 unit tests, including crash-mid-write cases for the store. Co-Authored-By: Claude Fable 5.1 --- android/consumer-rules.pro | 55 +- .../openspace/backgroundupload/AtomicFiles.kt | 53 ++ .../backgroundupload/AttemptEvent.kt | 91 +++ .../openspace/backgroundupload/BodyStaging.kt | 212 ++++++ .../backgroundupload/ChunkedEngine.kt | 106 +-- .../backgroundupload/ChunkedManifest.kt | 298 --------- .../backgroundupload/ChunkedParts.kt | 60 ++ .../backgroundupload/ChunkedUploadWorker.kt | 488 ++++---------- .../backgroundupload/ChunkedWorkerGate.kt | 40 -- .../ai/openspace/backgroundupload/Diag.kt | 19 + .../backgroundupload/EnqueueRules.kt | 156 +++++ .../backgroundupload/EntryParsing.kt | 184 ++++++ .../backgroundupload/EntryTransitions.kt | 86 +++ .../openspace/backgroundupload/EntryWorker.kt | 297 +++++++++ .../backgroundupload/EventJournal.kt | 278 +++++--- .../backgroundupload/EventReporter.kt | 88 ++- .../openspace/backgroundupload/JsonBridge.kt | 176 +++++ .../backgroundupload/LegacyImport.kt | 101 +++ .../backgroundupload/ProgressThrottle.kt | 55 ++ .../backgroundupload/QueueController.kt | 422 ++++++++++++ .../openspace/backgroundupload/QueueEntry.kt | 228 +++++++ .../backgroundupload/QueueSettings.kt | 97 +++ .../openspace/backgroundupload/QueueStore.kt | 215 ++++++ .../backgroundupload/RequestIndex.kt | 49 ++ .../backgroundupload/RetryClassifier.kt | 83 +++ .../openspace/backgroundupload/Scheduler.kt | 111 ++++ .../ai/openspace/backgroundupload/Upload.kt | 107 --- .../backgroundupload/UploadOutcome.kt | 3 +- .../openspace/backgroundupload/UploadUtils.kt | 139 ++-- .../backgroundupload/UploadWorker.kt | 354 +++------- .../backgroundupload/UploaderModule.kt | 451 ++++--------- .../backgroundupload/UserCancellations.kt | 17 - .../openspace/backgroundupload/WorkerGate.kt | 32 + .../openspace/backgroundupload/WorkerOps.kt | 271 ++++++++ .../backgroundupload/AckReleaseTest.kt | 69 -- .../backgroundupload/BodyStagingTest.kt | 207 ++++++ .../backgroundupload/ChunkedEngineTest.kt | 124 +--- .../backgroundupload/ChunkedManifestTest.kt | 384 ----------- .../backgroundupload/ChunkedPartsTest.kt | 56 ++ .../backgroundupload/ChunkedWorkerGateTest.kt | 57 -- .../backgroundupload/EntryParsingTest.kt | 124 ++++ .../backgroundupload/EntryTransitionsTest.kt | 176 +++++ .../backgroundupload/EventJournalTest.kt | 167 +++-- .../backgroundupload/JsonBridgeTest.kt | 81 +++ .../backgroundupload/LegacyImportTest.kt | 82 +++ .../backgroundupload/QueueControllerTest.kt | 617 ++++++++++++++++++ .../backgroundupload/QueueEntryTest.kt | 103 +++ .../backgroundupload/QueueSettingsTest.kt | 68 ++ .../backgroundupload/QueueStoreTest.kt | 245 +++++++ .../backgroundupload/RetryClassifierTest.kt | 102 +++ .../backgroundupload/SmallPartsTest.kt | 167 +++++ .../openspace/backgroundupload/TestSupport.kt | 123 ++++ .../backgroundupload/UploadStatesTest.kt | 84 --- .../openspace/backgroundupload/UploadTest.kt | 112 ---- .../backgroundupload/WorkerGateTest.kt | 51 ++ .../backgroundupload/WorkerOpsTest.kt | 283 ++++++++ 56 files changed, 6321 insertions(+), 2583 deletions(-) create mode 100644 android/src/main/java/ai/openspace/backgroundupload/AtomicFiles.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/AttemptEvent.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/BodyStaging.kt delete mode 100644 android/src/main/java/ai/openspace/backgroundupload/ChunkedManifest.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/ChunkedParts.kt delete mode 100644 android/src/main/java/ai/openspace/backgroundupload/ChunkedWorkerGate.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/Diag.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/EnqueueRules.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/EntryParsing.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/EntryTransitions.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/EntryWorker.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/JsonBridge.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/LegacyImport.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/ProgressThrottle.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/QueueController.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/QueueEntry.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/QueueSettings.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/QueueStore.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/RequestIndex.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/RetryClassifier.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/Scheduler.kt delete mode 100644 android/src/main/java/ai/openspace/backgroundupload/Upload.kt delete mode 100644 android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/WorkerGate.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/WorkerOps.kt delete mode 100644 android/src/test/java/ai/openspace/backgroundupload/AckReleaseTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/BodyStagingTest.kt delete mode 100644 android/src/test/java/ai/openspace/backgroundupload/ChunkedManifestTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/ChunkedPartsTest.kt delete mode 100644 android/src/test/java/ai/openspace/backgroundupload/ChunkedWorkerGateTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/EntryParsingTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/EntryTransitionsTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/JsonBridgeTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/LegacyImportTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/QueueEntryTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/QueueSettingsTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/QueueStoreTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/RetryClassifierTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/SmallPartsTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/TestSupport.kt delete mode 100644 android/src/test/java/ai/openspace/backgroundupload/UploadStatesTest.kt delete mode 100644 android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/WorkerGateTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro index 754b74ca..d7627d06 100644 --- a/android/consumer-rules.pro +++ b/android/consumer-rules.pro @@ -1,32 +1,43 @@ # These rules go to consumers through consumerProguardFiles. Thus a minified # release build of the host app keeps these guarantees. # -# Gson persists Upload, NotificationConfig, and EventJournal.Entry. Upload goes -# into WorkManager input data. NotificationConfig goes into SharedPreferences. -# Entry goes into the on-disk event journal. The library reads them back later, -# across app restarts AND across app updates. Gson finds fields by name through -# reflection. Gson also needs the generic Signature attribute to rebuild typed -# collections. Thus, if R8 renames a field or removes Signature, it corrupts the -# persisted state silently: +# Gson persists the queue entry (entry.json), the queue settings +# (settings.json), the settled-outcome journal, NotificationConfig +# (SharedPreferences), and reads the v9 journal and v9 chunked manifests at +# the first v10 launch. The library reads them back later, across app +# restarts AND across app updates. Gson finds fields by name through +# reflection, and it needs the generic Signature attribute to rebuild typed +# collections. Thus, if R8 renames a field or removes Signature, it corrupts +# the persisted state silently: # -# * Upload.accept is a List. Without Signature, Gson decodes the -# elements as bare maps. Then no rule ever matches, and a configured accept -# status (for example 409) is reported as an http error, not as a completed -# upload. ChunkedManifest.parts has the same shape and the same failure. -# * A journal Entry from an older build fails to parse if field names changed. -# The library then drops the Entry as malformed. This loses the terminal -# outcomes that the journal exists to keep. A ChunkedManifest is the resume -# record for a chunked upload, and it fails in the same way. +# * Descriptor.accept is a List and Descriptor.parts a +# List. Without Signature, Gson decodes the elements as bare maps. +# Then no accept rule matches, and every chunked part reads as unsent. +# * A record from an older build fails to parse if field names changed. The +# library drops it as malformed. That loses the outcomes the journal +# exists to keep, and the entries the queue exists to run. +# * EntryState is an enum persisted by its @SerializedName wire string. # -# Debug builds are not minified and round-trip correctly. Thus neither failure +# Debug builds are not minified and round-trip correctly, so neither failure # is reproducible without R8. Keep these rules. -keepattributes Signature -keepattributes *Annotation* --keep class ai.openspace.backgroundupload.Upload { *; } --keep class ai.openspace.backgroundupload.Upload$* { *; } --keep class ai.openspace.backgroundupload.NotificationConfig { *; } --keep class ai.openspace.backgroundupload.EventJournal$Entry { *; } --keep class ai.openspace.backgroundupload.ChunkedManifest { *; } --keep class ai.openspace.backgroundupload.ChunkedManifest$* { *; } +# v10 queue +-keep class ai.openspace.backgroundupload.QueueEntry { *; } +-keep class ai.openspace.backgroundupload.EntryState { *; } +-keep class ai.openspace.backgroundupload.Descriptor { *; } +-keep class ai.openspace.backgroundupload.FormPart { *; } +-keep class ai.openspace.backgroundupload.RetryOverride { *; } +-keep class ai.openspace.backgroundupload.StagedBody { *; } +-keep class ai.openspace.backgroundupload.Part { *; } +-keep class ai.openspace.backgroundupload.QueueSettings { *; } +-keep class ai.openspace.backgroundupload.RetryDefaults { *; } +-keep class ai.openspace.backgroundupload.EventJournal$SettledRecord { *; } +-keep class ai.openspace.backgroundupload.EventJournal$Response { *; } -keep class ai.openspace.backgroundupload.UploadOutcome$AcceptRule { *; } +-keep class ai.openspace.backgroundupload.NotificationConfig { *; } + +# v9 files read once at the first v10 launch +-keep class ai.openspace.backgroundupload.LegacyImport$V9Entry { *; } +-keep class ai.openspace.backgroundupload.LegacyManifest { *; } diff --git a/android/src/main/java/ai/openspace/backgroundupload/AtomicFiles.kt b/android/src/main/java/ai/openspace/backgroundupload/AtomicFiles.kt new file mode 100644 index 00000000..bf291b53 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/AtomicFiles.kt @@ -0,0 +1,53 @@ +package ai.openspace.backgroundupload + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.file.StandardOpenOption + +/** + * Write-ahead file writes. Every durable file in the library goes through + * [writeAtomically]: write a tmp sibling, fsync it, rename it over the + * target. A crash at any point leaves either the old target or the new one, + * never a partial file. + */ +internal object AtomicFiles { + const val TMP_SUFFIX = ".tmp" + + fun tmpFor(target: File) = File(target.parentFile, target.name + TMP_SUFFIX) + + /** Throws IOException when the target could not be replaced. The old target is then intact. */ + fun writeAtomically(target: File, write: (FileOutputStream) -> Unit) { + val parent = target.parentFile ?: throw IOException("no parent directory for ${target.path}") + if (!parent.isDirectory && !parent.mkdirs()) { + throw IOException("could not create ${parent.path}") + } + val tmp = tmpFor(target) + try { + FileOutputStream(tmp).use { out -> + write(out) + out.flush() + out.fd.sync() + } + if (!tmp.renameTo(target)) throw IOException("could not rename ${tmp.path} to ${target.name}") + } catch (error: Throwable) { + tmp.delete() + throw error + } + syncDirectory(parent) + } + + fun writeText(target: File, text: String) = + writeAtomically(target) { it.write(text.toByteArray(Charsets.UTF_8)) } + + /** + * Makes a rename durable. This works on Linux (Android). Some file systems + * do not allow it, so a failure is ignored: the rename itself is still atomic. + */ + fun syncDirectory(dir: File) { + runCatching { + FileChannel.open(dir.toPath(), StandardOpenOption.READ).use { it.force(true) } + } + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/AttemptEvent.kt b/android/src/main/java/ai/openspace/backgroundupload/AttemptEvent.kt new file mode 100644 index 00000000..37a65f0a --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/AttemptEvent.kt @@ -0,0 +1,91 @@ +package ai.openspace.backgroundupload + +import com.facebook.react.bridge.WritableMap + +/** + * One HTTP attempt, before the library interprets it. Live only: never + * journaled. [outcome] is `completed` when the response is accepted and + * `error` otherwise, so a 401 is `error` with httpCode 401 even though the + * entry parks. + */ +data class AttemptEvent( + val id: String, + val key: String, + val requestId: String, + val attempt: Int, + val url: String, + val method: String, + val partIndex: Int?, + val outcome: String, + val httpCode: Int?, + val responseBody: String?, + val responseBodyTruncated: Boolean?, + val responseHeaders: Map?, + val errorKind: String?, + val errorMessage: String?, + val cancelReason: String?, + val at: Long, +) { + companion object { + const val MAX_BODY_CHARS = 4 * 1024 + + fun ofResponse( + entry: QueueEntry, + requestId: String, + url: String, + partIndex: Int?, + response: UploadResponse, + accepted: Boolean, + at: Long, + ): AttemptEvent { + val (body, truncated) = EventJournal.capBody(response.body, MAX_BODY_CHARS) + return AttemptEvent( + id = entry.id, key = entry.key, requestId = requestId, attempt = entry.attempts, + url = url, method = entry.descriptor?.method ?: "POST", partIndex = partIndex, + outcome = if (accepted) "completed" else "error", + httpCode = response.code, responseBody = body, responseBodyTruncated = truncated, + responseHeaders = response.headers, + errorKind = if (accepted) null else "http", + errorMessage = if (accepted) null else "HTTP ${response.code}", + cancelReason = null, at = at, + ) + } + + fun ofFailure( + entry: QueueEntry, + requestId: String, + url: String, + partIndex: Int?, + errorKind: String, + message: String, + at: Long, + ) = AttemptEvent( + id = entry.id, key = entry.key, requestId = requestId, attempt = entry.attempts, + url = url, method = entry.descriptor?.method ?: "POST", partIndex = partIndex, + outcome = "error", httpCode = null, responseBody = null, responseBodyTruncated = null, + responseHeaders = null, errorKind = errorKind, errorMessage = message, + cancelReason = null, at = at, + ) + } + + fun toMap(): Map = LinkedHashMap().apply { + put("id", id) + put("key", key) + put("requestId", requestId) + put("attempt", attempt.toDouble()) + put("url", url) + put("method", method) + partIndex?.let { put("partIndex", it.toDouble()) } + put("outcome", outcome) + httpCode?.let { put("httpCode", it.toDouble()) } + responseBody?.let { put("responseBody", it) } + responseBodyTruncated?.let { put("responseBodyTruncated", it) } + responseHeaders?.let { put("responseHeaders", it) } + errorKind?.let { put("errorKind", it) } + errorMessage?.let { put("errorMessage", it) } + cancelReason?.let { put("cancelReason", it) } + put("at", at.toDouble()) + } + + fun toWritableMap(): WritableMap = JsonBridge.toWritableMap(toMap()) +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/BodyStaging.kt b/android/src/main/java/ai/openspace/backgroundupload/BodyStaging.kt new file mode 100644 index 00000000..e65d4647 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/BodyStaging.kt @@ -0,0 +1,212 @@ +package ai.openspace.backgroundupload + +import java.io.BufferedOutputStream +import java.io.File +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.UUID + +/** + * Writes a request body into the entry directory before the entry is saved. + * After enqueue resolves, every byte the request needs is in the library + * directory, so the caller may delete its source. + * + * | Descriptor | Staged file | Content-Type | + * | none | none | none | + * | data | body-.json | application/json unless the caller set one | + * | form | body-.multipart| multipart/form-data; boundary=… (replaces the caller's) | + * | file | file- (copy) | the caller's own | + * | file + parts | blob / blob- (move) | the caller's own | + * + * Each staged file has a name per generation, so a replace writes a new file + * next to the old one. The old body is deleted only after the new entry is + * saved. Every source is checked before anything is written. A chunked blob + * of generation 1 keeps the v9 name `blob`, so v9 blobs are found in place. + */ +object BodyStaging { + const val JSON_CONTENT_TYPE = "application/json" + private const val BUFFER = 64 * 1024 + private val CRLF = "\r\n".toByteArray() + + data class Staged(val body: StagedBody, val headers: Map) + + /** + * @param ownedBlob the chunked blob the entry (or a v9 manifest) already + * owns. The parts run over it when the caller's file is gone (v9 recreate). + * @param keepOwned run over [ownedBlob] even when the caller's file is + * present. Only for the same parts, whose accepted flags carry over. + */ + fun stage( + d: Descriptor, + dir: File, + generation: Int, + ownedBlob: File? = null, + keepOwned: Boolean = false, + ): Staged { + checkSources(d, dir, generation, ownedBlob, keepOwned) + val body = when (d.bodyKind) { + StagedBody.NONE -> StagedBody(StagedBody.NONE, null, null, 0) + StagedBody.JSON -> { + val name = "body-$generation.json" + val target = File(dir, name) + writeJson(d.dataJson!!, target) + StagedBody(StagedBody.JSON, name, null, target.length()) + } + StagedBody.MULTIPART -> { + val name = "body-$generation.multipart" + val target = File(dir, name) + val boundary = newBoundary() + writeMultipart(d.form!!, boundary, target) + StagedBody(StagedBody.MULTIPART, name, boundary, target.length()) + } + StagedBody.FILE -> { + val name = "file-$generation" + val target = File(dir, name) + copyFile(File(d.file!!), target) + StagedBody(StagedBody.FILE, name, null, target.length()) + } + else -> { + val parts = d.parts!! + val source = File(d.file!!) + val runOver = chunkedInput(d, dir, generation, ownedBlob, keepOwned) + // Checked before the move, so a rejected plan moves nothing. + if (!ChunkedParts.tilesExactly(parts, runOver.length())) { + throw QueueException( + QueueException.E_INVALID, + "parts must tile the file exactly: [0, ${runOver.length()})", + ) + } + val blob = if (runOver.path == source.path) { + File(dir, blobName(generation)).also { takeOwnership(source, it) } + } else runOver + StagedBody(StagedBody.CHUNKED, blob.name, null, ChunkedParts.totalBytes(parts)) + } + } + return Staged(body, headersFor(d.headers, body)) + } + + /** The Content-Type rule of the table above. */ + fun headersFor(headers: Map, body: StagedBody): Map = + when (body.kind) { + StagedBody.JSON -> + if (HeaderMap.contains(headers, "Content-Type")) headers + else headers + ("Content-Type" to JSON_CONTENT_TYPE) + StagedBody.MULTIPART -> + HeaderMap.without(headers, "Content-Type") + + ("Content-Type" to "multipart/form-data; boundary=${body.boundary}") + else -> headers + } + + /** The chunked blob name of [generation]. Generation 1 keeps the v9 name. */ + fun blobName(generation: Int) = + if (generation <= 1) QueueStore.BLOB_FILE else "${QueueStore.BLOB_FILE}-$generation" + + /** + * The file a chunked plan runs over, before anything moves: + * 1. [ownedBlob] when [keepOwned] and it exists; + * 2. the caller's file when present (a new file wins over an old blob); + * 3. this generation's blob, left by a crash after the move; + * 4. [ownedBlob], when the caller's file was moved away at an earlier enqueue. + */ + internal fun chunkedInput(d: Descriptor, dir: File, generation: Int, ownedBlob: File?, keepOwned: Boolean): File { + val source = File(d.file!!) + val leftover = File(dir, blobName(generation)) + return when { + keepOwned && ownedBlob != null && ownedBlob.exists() -> ownedBlob + source.isFile -> source + leftover.exists() -> leftover + ownedBlob != null && ownedBlob.exists() -> ownedBlob + else -> throw QueueException(QueueException.E_FILE_MISSING, "file does not exist: ${source.path}") + } + } + + /** Every source must exist and be readable before any write. */ + internal fun checkSources(d: Descriptor, dir: File, generation: Int, ownedBlob: File?, keepOwned: Boolean) { + d.form?.forEach { part -> + part.path?.let { requireReadable(File(it), "form part '${part.name}'") } + } + when (d.bodyKind) { + StagedBody.FILE -> requireReadable(File(d.file!!), "file") + StagedBody.CHUNKED -> chunkedInput(d, dir, generation, ownedBlob, keepOwned) + } + } + + private fun requireReadable(file: File, what: String) { + if (!file.isFile || !file.canRead()) { + throw QueueException(QueueException.E_FILE_MISSING, "$what does not exist or can not be read: ${file.path}") + } + } + + internal fun writeJson(dataJson: String, target: File) = + AtomicFiles.writeText(target, dataJson) + + /** + * RFC 7578. Per part: the boundary line, Content-Disposition with the name + * (and a filename for a file part), Content-Type, a blank line, the bytes, + * CRLF. Then the closing delimiter. File parts stream from disk. + */ + internal fun writeMultipart(form: List, boundary: String, target: File) { + AtomicFiles.writeAtomically(target) { raw -> + val out = BufferedOutputStream(raw, BUFFER) + for (part in form) { + out.ascii("--$boundary") + out.write(CRLF) + val disposition = StringBuilder("Content-Disposition: form-data; name=\"") + .append(escapeQuoted(part.name)).append('"') + if (part.path != null) { + val fileName = part.fileName ?: File(part.path).name + disposition.append("; filename=\"").append(escapeQuoted(fileName)).append('"') + } + out.write(disposition.toString().toByteArray(Charsets.UTF_8)) + out.write(CRLF) + out.write("Content-Type: ${part.contentType}".toByteArray(Charsets.UTF_8)) + out.write(CRLF) + out.write(CRLF) + if (part.path != null) { + File(part.path).inputStream().use { it.copyTo(out, BUFFER) } + } else { + out.write((part.string ?: "").toByteArray(Charsets.UTF_8)) + } + out.write(CRLF) + } + out.ascii("--$boundary--") + out.write(CRLF) + out.flush() + } + } + + /** The WHATWG form encoding of a quoted name: `"`, CR and LF are percent-encoded. */ + internal fun escapeQuoted(value: String): String = + value.replace("\"", "%22").replace("\r", "%0D").replace("\n", "%0A") + + internal fun copyFile(source: File, target: File) { + source.inputStream().use { input -> + AtomicFiles.writeAtomically(target) { out -> input.copyTo(out, BUFFER) } + } + } + + /** + * Moves the caller's file to [blob] (v9 verbatim). A crash between the move + * and the entry save leaves the bytes at the blob path with no entry; a + * retry whose source is gone adopts them ([chunkedInput] step 3). + */ + internal fun takeOwnership(source: File, blob: File) { + if (source.absoluteFile == blob.absoluteFile) return + if (!source.exists()) { + if (blob.exists()) return + throw QueueException(QueueException.E_FILE_MISSING, "file does not exist: ${source.path}") + } + blob.parentFile?.mkdirs() + if (blob.exists()) blob.delete() + if (!source.renameTo(blob)) { + // renameTo can not cross file systems. Files.move falls back to copy + delete. + Files.move(source.toPath(), blob.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + blob.parentFile?.let { AtomicFiles.syncDirectory(it) } + } + + internal fun newBoundary(): String = "----RNBGU" + UUID.randomUUID().toString().replace("-", "") + + private fun OutputStream.ascii(text: String) = write(text.toByteArray(Charsets.US_ASCII)) +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/ChunkedEngine.kt b/android/src/main/java/ai/openspace/backgroundupload/ChunkedEngine.kt index e398c750..bbbee474 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/ChunkedEngine.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/ChunkedEngine.kt @@ -6,108 +6,22 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit /** - * The pure scheduling half of chunked execution: the window, the retry - * policy, and the backoff. It is kept free of Android and OkHttp types. Thus - * the highest-consequence invariants (at most WINDOW parts in flight, and - * never two requests for one part index) are unit-testable on a plain JVM. - * [ChunkedUploadWorker] supplies the part executor. + * The pure scheduling half of chunked execution: the window. It has no + * Android or OkHttp types, so its two invariants (at most WINDOW parts in + * flight, never two requests for one part index) are unit-testable on a + * plain JVM. The retry decisions moved to [RetryClassifier] in v10. */ object ChunkedEngine { - // The number of parts of one upload in flight at one time. This is a library - // constant, not an option. If soak data shows that a different value is - // better, this constant changes, not the API. + // Parts of one upload in flight at one time. A library constant, not an option. const val WINDOW = 3 - // The library retries a non-accepted, non-transient HTTP response this many - // times per part. Then the response becomes a terminal error and stalls the - // upload. The budget is small on purpose. A response that the server repeats - // (401, 400) does not change without a new startUpload. Only transient - // failures retry without a limit. - const val PART_HTTP_RETRIES = 3 - - // The poll interval while the network is unusable (offline, or waiting for - // wifi). The interval is constant, not exponential. We wait for conditions - // here; we do not back off a server. And expiresAt bounds the total wait. - const val CONNECTIVITY_POLL_MS = 10_000L - - private const val BACKOFF_BASE_MS = 1_000L - private const val BACKOFF_CAP_MS = 60_000L - - // A 5xx means that the server failed, not that the request is wrong. Thus it - // retries like a transport failure: without a limit, until expiresAt. - fun isTransientHttp(code: Int) = code in 500..599 - - /** What a starting worker must do for the manifest that it finds (or does not find). */ - enum class StartAction { - /** - * No manifest exists. The upload was completed and acknowledged, or it was - * explicitly removed, while this run sat in the queue. Both are legitimate - * ends, already reported (or deliberately not reported). Exit with success - * and in silence. A journaled terminal here would be a spurious 'file' - * error for an upload that nobody owns any more. - */ - NO_MANIFEST, - - /** - * Every part is already accepted: this is a trailing resume run. Re-report - * the journaled completion (never mint a second terminal event) and stop. - * Start no foreground service and no transfers. - */ - ALREADY_COMPLETE, - - /** Pending parts remain. Run the engine. */ - RUN, - } - - fun startAction(manifest: ChunkedManifest?): StartAction = when { - manifest == null -> StartAction.NO_MANIFEST - manifest.allAccepted -> StartAction.ALREADY_COMPLETE - else -> StartAction.RUN - } - - /** How a run that found (or produced) an all-accepted manifest reports the completion. */ - sealed class CompletionReport { - /** An unacknowledged 'completed' entry exists. Re-emit it. Never mint a second entry. */ - data class ReEmit(val entry: EventJournal.Entry) : CompletionReport() - - /** A fresh completion with no journal entry yet. Journal and emit a new entry. */ - object Mint : CompletionReport() - - /** - * A trailing run with nothing unacknowledged: the completion was journaled - * AND acknowledged. Nobody is owed an event. This occurs when the trailing - * run races ackEvents, which deletes the journal entry just before the - * manifest. An event minted here would be a duplicate 'completed' for an - * upload that the consumer already settled. - */ - object None : CompletionReport() - } - - fun completionReport( - unacked: List, - uploadId: String, - freshCompletion: Boolean, - ): CompletionReport { - val existing = unacked.firstOrNull { it.uploadId == uploadId && it.type == "completed" } - return when { - existing != null -> CompletionReport.ReEmit(existing) - freshCompletion -> CompletionReport.Mint - else -> CompletionReport.None - } - } - - /** Exponential backoff for transient failures: 1s, 2s, 4s, and more, capped at 60s. */ - fun backoffMs(attempt: Int): Long = - (BACKOFF_BASE_MS shl (attempt - 1).coerceIn(0, 6)).coerceAtMost(BACKOFF_CAP_MS) - /** - * Runs [executePart] exactly one time per index, with at most [window] parts - * at one time. One coroutine per part index is what guarantees that no two - * requests for the same part are in flight (concurrent PUTs of one partNum - * are verified unsafe on the server side). An executor that throws cancels - * the remaining parts, and the error propagates. Terminal classification is - * the caller's job. + * Runs [executePart] exactly one time per index, with at most [window] + * parts at one time. One coroutine per index is what guarantees no two + * requests for one part are in flight (concurrent PUTs of one partNum are + * unsafe on the server). An executor that throws cancels the other parts, + * and the error propagates. */ suspend fun run( partIndexes: List, diff --git a/android/src/main/java/ai/openspace/backgroundupload/ChunkedManifest.kt b/android/src/main/java/ai/openspace/backgroundupload/ChunkedManifest.kt deleted file mode 100644 index 6bbe523b..00000000 --- a/android/src/main/java/ai/openspace/backgroundupload/ChunkedManifest.kt +++ /dev/null @@ -1,298 +0,0 @@ -package ai.openspace.backgroundupload - -import android.content.Context -import com.facebook.react.bridge.ReadableMap -import com.google.gson.Gson -import java.io.File -import java.util.Base64 - -/** - * The durable record of one chunked upload: the moved source file, the parts - * that the consumer authored, and which of them the server has accepted. - * [ChunkedManifestStore] persists it as JSON at startUpload, BEFORE the work - * is enqueued. Thus a worker rescheduled after process death (or a startUpload - * after a crash, a stop, or a reauth) resumes from it without a call into JS. - * This manifest IS the resume mechanism. - * - * The data shape is kept free of Android and React types (Gson round-trips - * it, and JVM tests construct it directly). The ReadableMap parsing lives in - * the companion, like [Upload]'s. - */ -data class ChunkedManifest( - val id: String, - /** The library-owned copy of the bytes (the consumer's file, renamed in). */ - val sourcePath: String, - val parts: List, - val accept: List, - /** Epoch ms. After this time, the upload stops with errorKind 'expired'. */ - val expiresAt: Long, - val wifiOnly: Boolean, - val noNotification: Boolean, - val createdAt: Long, -) { - /** - * One part, exactly as the consumer authored it. The library sends the file - * bytes [start, end) as the body of a PUT to [url], with [headers] - * unchanged. It never derives or edits a protocol field. - */ - data class Part( - val url: String, - val headers: Map, - val start: Long, - val end: Long, // exclusive - val accepted: Boolean = false, - ) { - val size get() = end - start - } - - val showsNotification get() = !noNotification - val totalBytes get() = parts.sumOf { it.size } - val acceptedBytes get() = parts.filter { it.accepted }.sumOf { it.size } - - /** The server's auto-publish condition. It is the only thing that 'completed' may mean. */ - val allAccepted get() = parts.all { it.accepted } - - fun isExpired(now: Long) = now >= expiresAt - - fun pendingIndexes() = parts.indices.filter { !parts[it].accepted } - - fun withPartAccepted(index: Int) = copy( - parts = parts.mapIndexed { i, part -> if (i == index) part.copy(accepted = true) else part }, - ) - - class ReconcileException(message: String) : IllegalArgumentException(message) - - /** - * A startUpload re-call with an existing id is one of two things: - * - * **Resume** — the incoming parts are the SAME array (identical count, - * ranges, and urls). The headers, the accept rules, expiresAt, and the flags - * come from the new call. This is how fresh auth reaches stalled parts, and - * how a salvage extends the deadline. The accepted part statuses, the moved - * source, and createdAt survive from this manifest. A resume is permitted at - * any time, running or not. A running worker re-reads the stored copy before - * every attempt. - * - * **Recreate** — a DIFFERENT parts array. The consumer re-authored the - * upload under a fresh server uploadId after the old one died (it expired - * past the server's 31-day window, or it is otherwise unrecoverable). The - * owned bytes are kept. The parts are replaced as a whole, and every part - * status resets to unsent. The headers, the accept rules, and expiresAt come - * from the new call. The new ranges must tile exactly [0, blobSize). A - * partial or overlapping cover would silently upload wrong bytes. A recreate - * is accepted only while the upload is NOT running (stalled on a terminal - * error, expired, or cancelled). A different parts array while a worker - * executes is a consumer bug, not a recreate, because the in-flight requests - * belong to the old parts. - */ - fun reconcile(incoming: ChunkedManifest, running: Boolean, blobSize: Long): ChunkedManifest { - if (samePartsAs(incoming)) { - // Accepted flags follow the RANGE, not the array index. samePartsAs is - // order-independent, so the same tile can sit at a different index. - val acceptedStarts = parts.filter { it.accepted }.map { it.start }.toSet() - return incoming.copy( - sourcePath = sourcePath, - createdAt = createdAt, - parts = incoming.parts.map { it.copy(accepted = it.start in acceptedStarts) }, - ) - } - if (running) throw ReconcileException( - "chunked upload '$id' is running; a different parts array is only accepted once it stops", - ) - if (!tilesExactly(incoming.parts, blobSize)) throw ReconcileException( - "chunked upload '$id' recreate parts must tile exactly [0, $blobSize)", - ) - return incoming.copy(sourcePath = sourcePath, createdAt = createdAt) - } - - // Order-independent, like tilesExactly. The same tiles, authored in a - // different order, are the SAME upload (a resume), never a recreate. - private fun samePartsAs(incoming: ChunkedManifest): Boolean { - if (incoming.parts.size != parts.size) return false - val stored = parts.sortedBy { it.start } - val fresh = incoming.parts.sortedBy { it.start } - return stored.indices.all { i -> - fresh[i].url == stored[i].url && - fresh[i].start == stored[i].start && - fresh[i].end == stored[i].end - } - } - - companion object { - /** - * Whether [parts] cover [0, size) exactly: no gap, no overlap, and nothing - * past the end. Order-independent, like everything else about parts. - */ - fun tilesExactly(parts: List, size: Long): Boolean { - if (parts.isEmpty()) return false - val sorted = parts.sortedBy { it.start } - var cursor = 0L - for (part in sorted) { - if (part.start != cursor || part.end <= part.start) return false - cursor = part.end - } - return cursor == size - } - - /** - * Validates a first-call (create) manifest against the just-owned bytes. - * Like a recreate, the parts must tile exactly [0, blobSize). A partial or - * overlapping cover would silently upload wrong bytes. It throws BEFORE - * the manifest is saved. Thus the moved blob stays adoptable by a - * corrected retry (see UploaderModule.takeOwnership's orphan branch). - */ - fun validatedForCreate(incoming: ChunkedManifest, blobSize: Long): ChunkedManifest { - if (!tilesExactly(incoming.parts, blobSize)) throw ReconcileException( - "chunked upload '${incoming.id}' parts must tile exactly [0, $blobSize)", - ) - return incoming - } - - /** @param sourcePath the library-owned destination, not the consumer's path. */ - fun fromReadableMap(map: ReadableMap, sourcePath: String, createdAt: Long): ChunkedManifest { - val partsArr = map.getArray("parts") ?: throw Upload.MissingOptionException("parts") - if (partsArr.size() == 0) throw IllegalArgumentException("parts must be a non-empty array") - if (!map.hasKey("expiresAt")) throw Upload.MissingOptionException("expiresAt") - return ChunkedManifest( - id = map.getString("id") ?: throw Upload.MissingOptionException("id"), - sourcePath = sourcePath, - parts = (0 until partsArr.size()).map { i -> - val part = partsArr.getMap(i) ?: throw Upload.MissingOptionException("parts[$i]") - val range = part.getMap("range") ?: throw Upload.MissingOptionException("parts[$i].range") - Part( - url = part.getString("url") ?: throw Upload.MissingOptionException("parts[$i].url"), - headers = parseHeaderMap(part.getMap("headers")), - start = range.getDouble("start").toLong(), - end = range.getDouble("end").toLong(), - ) - }, - accept = parseAcceptRules(map.getArray("accept")), - expiresAt = map.getDouble("expiresAt").toLong(), - wifiOnly = if (map.hasKey("wifiOnly")) map.getBoolean("wifiOnly") else false, - noNotification = if (map.hasKey("noNotification")) map.getBoolean("noNotification") else false, - createdAt = createdAt, - ) - } - } -} - -/** - * A file-backed store: one directory per upload id, which holds - * `manifest.json` and `blob` (the moved source bytes). It has the same - * durability pattern as [EventJournal]: tmp+rename writes, and corrupt files - * read as absent. It is reachable from a bare Context, because the worker can - * run in a process where React never initialized. - */ -class ChunkedManifestStore(private val dir: File) { - - companion object { - private val gson = Gson() - - @Volatile - private var instance: ChunkedManifestStore? = null - - fun get(context: Context): ChunkedManifestStore = - instance ?: synchronized(this) { - instance ?: ChunkedManifestStore(File(context.filesDir, "rnbgupload-chunked")) - .also { instance = it } - } - } - - init { - dir.mkdirs() - } - - // Upload ids come from the consumer, and they can contain path separators or - // other filesystem-hostile characters. Thus the directory name is an encoding - // of the id, never the id itself. The id is read back from the manifest, not - // decoded from the name. - private fun uploadDir(id: String) = - File(dir, Base64.getUrlEncoder().withoutPadding().encodeToString(id.toByteArray())) - - private fun manifestFile(id: String) = File(uploadDir(id), "manifest.json") - - /** Where startUpload moves the source file for this id. */ - fun blobFile(id: String) = File(uploadDir(id), "blob") - - @Synchronized - fun load(id: String): ChunkedManifest? { - val file = manifestFile(id) - if (!file.exists()) return null - val parsed = runCatching { gson.fromJson(file.readText(), ChunkedManifest::class.java) } - .getOrNull() - return validated(parsed) - } - - /** Throws on a write failure. A manifest that did not persist must fail the startUpload call. */ - @Synchronized - fun save(manifest: ChunkedManifest) { - val dir = uploadDir(manifest.id) - dir.mkdirs() - val tmp = File(dir, "manifest.tmp") - tmp.writeText(gson.toJson(manifest)) - if (!tmp.renameTo(manifestFile(manifest.id))) { - throw java.io.IOException("failed to persist chunked manifest for '${manifest.id}'") - } - } - - /** - * An atomic read-modify-write. Thus a worker that marks a part accepted can - * never clobber a concurrent startUpload's fresh headers (or another part's - * flag). Returns null, without a throw, when the manifest is gone or the - * write failed. A caller that can continue from memory does that. - */ - @Synchronized - fun update(id: String, transform: (ChunkedManifest) -> ChunkedManifest): ChunkedManifest? = - runCatching { - val manifest = load(id) ?: return null - val next = transform(manifest) - save(next) - next - }.getOrNull() - - /** - * An atomic create-or-transform. The store lock spans load, [transform], and - * save. Thus nothing — a running worker's markAccepted included — can write - * between them and be erased. startUpload's load, reconcile, and save must - * go through here, not as three separate calls. [transform] receives null - * when no manifest exists. Unlike [update], a transform that throws (a - * reconcile rejection) or a failed write propagates, because startUpload - * must fail loudly, not continue from memory. - */ - @Synchronized - fun compute(id: String, transform: (ChunkedManifest?) -> ChunkedManifest): ChunkedManifest { - val next = transform(load(id)) - save(next) - return next - } - - /** Whether a manifest is stored for this id (without parsing it). */ - @Synchronized - fun contains(id: String): Boolean = manifestFile(id).exists() - - /** Deletes the manifest AND the moved bytes. Does nothing for an unknown id (a simple upload). */ - @Synchronized - fun remove(id: String) { - uploadDir(id).deleteRecursively() - } - - @Synchronized - fun all(): List = - (dir.listFiles { f -> f.isDirectory } ?: emptyArray()) - .mapNotNull { d -> - val file = File(d, "manifest.json") - if (!file.exists()) return@mapNotNull null - validated(runCatching { gson.fromJson(file.readText(), ChunkedManifest::class.java) }.getOrNull()) - } - - // Gson does not use the constructor. Thus a corrupt or field-renamed file can - // make non-null Kotlin fields null. Reject a file that lacks a field that the - // engine relies on. Normalize an absent accept list; do not reject it. - @Suppress("SENSELESS_COMPARISON") - private fun validated(m: ChunkedManifest?): ChunkedManifest? { - if (m == null || m.id == null || m.sourcePath == null || m.parts == null) return null - if (m.parts.isEmpty()) return null - if (m.parts.any { it == null || it.url == null || it.headers == null }) return null - return if (m.accept == null) m.copy(accept = listOf()) else m - } -} diff --git a/android/src/main/java/ai/openspace/backgroundupload/ChunkedParts.kt b/android/src/main/java/ai/openspace/backgroundupload/ChunkedParts.kt new file mode 100644 index 00000000..5d8ac2e0 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/ChunkedParts.kt @@ -0,0 +1,60 @@ +package ai.openspace.backgroundupload + +/** + * One part of a chunked upload, as the caller wrote it. The library sends the + * file bytes [start, end) to [url]. [accepted] is set when the server + * accepted the part. The field names match the v9 manifest, so a v9 + * manifest.json reads into this class unchanged. + */ +data class Part( + val url: String, + val headers: Map, + val start: Long, + val end: Long, // exclusive + val accepted: Boolean = false, +) { + val size get() = end - start +} + +/** Pure rules over a parts list. Moved from the v9 ChunkedManifest with the same meaning. */ +object ChunkedParts { + + /** Whether [parts] cover [0, size) exactly: no gap, no overlap, nothing past the end. Order does not matter. */ + fun tilesExactly(parts: List, size: Long): Boolean { + if (parts.isEmpty()) return false + var cursor = 0L + for (part in parts.sortedBy { it.start }) { + if (part.start != cursor || part.end <= part.start) return false + cursor = part.end + } + return cursor == size + } + + /** + * The same upload: the same count, ranges, and urls, in any order. Headers + * are not compared, because a resume sends fresh headers. + */ + fun sameParts(a: List, b: List): Boolean { + if (a.size != b.size) return false + val x = a.sortedBy { it.start } + val y = b.sortedBy { it.start } + return x.indices.all { i -> + x[i].url == y[i].url && x[i].start == y[i].start && x[i].end == y[i].end + } + } + + /** The [incoming] parts with the accepted flags of [stored]. A flag follows the range start, not the index. */ + fun carryAccepted(stored: List, incoming: List): List { + val acceptedStarts = stored.filter { it.accepted }.map { it.start }.toSet() + return incoming.map { it.copy(accepted = it.start in acceptedStarts) } + } + + fun totalBytes(parts: List): Long = parts.sumOf { it.size } + + fun acceptedBytes(parts: List): Long = parts.filter { it.accepted }.sumOf { it.size } + + fun pendingIndexes(parts: List): List = parts.indices.filter { !parts[it].accepted } + + fun withAccepted(parts: List, index: Int): List = + parts.mapIndexed { i, part -> if (i == index) part.copy(accepted = true) else part } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/ChunkedUploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/ChunkedUploadWorker.kt index 33750073..1b1fed34 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/ChunkedUploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/ChunkedUploadWorker.kt @@ -1,410 +1,178 @@ package ai.openspace.backgroundupload -import android.app.NotificationManager import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo -import androidx.work.ListenableWorker import androidx.work.WorkerParameters import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext import java.io.File -import java.io.IOException import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong +/** The WorkManager class for a chunked entry. The run is [EntryWorker]'s. */ +class ChunkedUploadWorker(context: Context, params: WorkerParameters) : EntryWorker(context, params) + /** - * Executes one chunked upload from its durable [ChunkedManifest]. The input - * data carries only the upload id. The manifest is the record: startUpload - * persists it before this work is enqueued. Thus a worker rescheduled after - * process death resumes from disk, with no JS involved. + * The parts of one chunked entry, at most [ChunkedEngine.WINDOW] at a time, + * each part through the shared 4-request semaphore. One logical entry has + * one progress stream (byte-weighted) and one outcome: completed only when + * every part is accepted. * - * One logical upload has one event stream: byte-weighted aggregate progress, - * and one terminal event. 'completed' is journaled only when every part is - * accepted. Every other terminal keeps the manifest and the bytes, so a later - * startUpload can resume. The bytes are deleted only when a 'completed' event - * is ACKED (see UploaderModule.ackEvents). + * Per part: accepted → persist the flag; auth → the whole entry parks (the + * sibling parts stop); transient → a short backoff waits in the part while + * the siblings go on, a long one releases the whole worker (accepted parts + * are kept); terminal → the entry fails with that part's index. */ -class ChunkedUploadWorker(private val context: Context, params: WorkerParameters) : - CoroutineWorker(context, params) { - - companion object { - /** - * The key for the upload id in the worker's input data. It is a string - * literal for the same reason as [UploadWorker.PARAMS_KEY]: WorkManager's - * database persists it across builds, and it must survive R8 renames and - * refactors. - */ - const val ID_KEY = "chunkedUploadId" - - /** How often a starting worker re-checks [ChunkedWorkerGate] for its id. */ - private const val GATE_POLL_MS = 100L - } - - private lateinit var uploadId: String - private val store by lazy { ChunkedManifestStore.get(context) } - private val config by lazy { NotificationConfig.load(context) } - private val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager +internal class ChunkedTransfer(private val host: EntryWorker) { - // The latest known manifest. Part executors re-read the stored copy before - // every attempt (see latest()). Thus a reconciling startUpload's fresh - // headers, and an extended expiresAt, reach a worker that already runs. - @Volatile - private var manifest: ChunkedManifest? = null - - @Volatile - private var connectivity = Connectivity.Ok + /** A terminal part failure. Not a CancellationException, so it stops the sibling parts. */ + private class PartFailed(val settlement: Settlement.Failed) : Exception(settlement.message) - // In-flight bytes per part index, for byte-weighted aggregate progress. private val partSent = ConcurrentHashMap() + private val acceptedHere = ConcurrentHashMap.newKeySet() private val acceptedBytes = AtomicLong(0) + private var total = 0L - private class ExpiredException : Exception("upload expired") - - private class SourceMissingException(path: String) : - IOException("chunked source file missing: $path") - - private class PartRejectedException(val partIndex: Int, val response: UploadResponse) : - Exception("part $partIndex rejected with HTTP ${response.code}") - - private class PartBeyondEofException(val partIndex: Int, message: String) : Exception(message) - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - uploadId = inputData.getString(ID_KEY) ?: throw Throwable("No upload id") - - // Acquire the per-id execution gate BEFORE the first manifest read. A - // cancel-then-start can start this worker while the cancelled one still - // winds down, and two PUTs of one partNum are unsafe. Also, the manifest - // read occurs only after the gate is held. That is what makes the module's - // recreate check race-free (see ChunkedWorkerGate and - // ChunkedManifestStore.compute). - try { - while (!ChunkedWorkerGate.tryAcquire(uploadId, this@ChunkedUploadWorker)) { - delay(GATE_POLL_MS) - } - } catch (error: CancellationException) { - // Cancelled while waiting. A user cancel still owes its terminal event. - checkAndHandleCancellation() - throw error - } - try { - runUpload() - } finally { - ChunkedWorkerGate.release(uploadId, this@ChunkedUploadWorker) - } - } - - private suspend fun runUpload(): Result { - val initial = store.load(uploadId) - when (ChunkedEngine.startAction(initial)) { - // The upload was completed-and-acknowledged, or it was removed, while - // this run sat in the queue. Both are legitimate and already settled. - // Exit in silence. A terminal journaled here would be a spurious error - // for an upload that nobody owns. - ChunkedEngine.StartAction.NO_MANIFEST -> return Result.success() - // A trailing resume of a finished-but-unacknowledged upload. Re-report - // the journaled completion. Skip the foreground service and the engine. - ChunkedEngine.StartAction.ALREADY_COMPLETE -> { - manifest = initial - journalCompleted(freshCompletion = false) - return Result.success() - } - ChunkedEngine.StartAction.RUN -> Unit + @Volatile + private var lastAcceptedUrl: String? = null + + suspend fun run(start: QueueEntry): Settlement { + val d0 = start.descriptor!! + val parts = d0.parts!! + lastAcceptedUrl = parts.lastOrNull { it.accepted }?.url + val pending = ChunkedParts.pendingIndexes(parts) + // A run over an all-accepted entry that has not settled yet. + if (pending.isEmpty()) return Settlement.Completed(null, lastAcceptedUrl ?: d0.reportUrl, d0.method) + val blob = host.bodyFile(start) + if (blob == null || !blob.exists()) { + return Settlement.Failed("file", "the chunked file is missing", null, null, d0.reportUrl, d0.method) } - checkNotNull(initial) // RUN implies a manifest - manifest = initial - acceptedBytes.set(initial.acceptedBytes) - UploadProgress.add(uploadId, initial.totalBytes) - UploadProgress.set(uploadId, initial.acceptedBytes) + total = ChunkedParts.totalBytes(parts) + acceptedBytes.set(ChunkedParts.acceptedBytes(parts)) + UploadProgress.add(host.entryId, total) + UploadProgress.set(host.entryId, acceptedBytes.get()) - // Initialization. A failure here is terminal: journaled, never retried. - // The EXCEPTION is a refused foreground start, which the transfer - // survives. try { - if (initial.showsNotification) { - ensureNotificationChannel(notificationManager, config) - setForeground(getForegroundInfo()) - } - } catch (error: Throwable) { - if (!isForegroundStartDenied(error)) { - if (!checkAndHandleCancellation()) { - UploadProgress.remove(uploadId) - handleFailure(error) - } - return terminalErrorResult() - } - // The app is in the background, and API 31+ refused the foreground - // start. This is the usual state for a WorkManager relaunch (a reboot, - // or a quota resume). The upload runs correctly without foreground - // priority. A failure here would brick every headless resume. - } - - return try { - ChunkedEngine.run(initial.pendingIndexes()) { index -> executePart(index) } - // Every executor returned. An executor returns only when its part was - // accepted. That is exactly the server's auto-publish condition. - UploadProgress.complete(uploadId) - journalCompleted(freshCompletion = true) - Result.success() - } catch (error: Throwable) { - if (checkAndHandleCancellation()) throw error - UploadProgress.remove(uploadId) - handleFailure(error) - terminalErrorResult() + ChunkedEngine.run(pending) { index -> executePart(index, blob, start.backoffStreak) } + } catch (failed: PartFailed) { + return failed.settlement } + // Every executor returned, and an executor returns only when its part was + // accepted: the server's auto-publish condition. + return Settlement.Completed(null, lastAcceptedUrl ?: d0.reportUrl, d0.method) } - /** - * Uploads one part until it is accepted, or throws. Terminal conditions - * (expiry, a missing source, or a non-accepted response out of retries) - * propagate and cancel the sibling parts. Everything transient retries here, - * bounded only by expiresAt. - */ - private suspend fun executePart(index: Int) { - var rejections = 0 - var transientAttempts = 0 + private suspend fun executePart(index: Int, blob: File, initialStreak: Int) { + var streak = initialStreak while (true) { - val current = latest() - val part = current.parts[index] - if (part.accepted) return - if (current.isExpired(System.currentTimeMillis())) throw ExpiredException() - - // A range past the blob's EOF can never transmit. The read would fail - // on every attempt until expiry. Thus it is a terminal 'file' error - // immediately (iOS classifies it the same way). length() is 0 for a - // missing file. That case falls through to the transfer, which - // classifies it as source-missing. The failed-probe-reads-as-network - // default stays intact. - val blobLength = runCatching { File(current.sourcePath).length() }.getOrDefault(0L) - if (blobLength > 0L && part.end > blobLength) throw PartBeyondEofException( - index, - "part $index range [${part.start}, ${part.end}) exceeds source size $blobLength", - ) - - if (!validateAndReportConnectivity(current.wifiOnly)) { - delay(ChunkedEngine.CONNECTIVITY_POLL_MS) - continue + if (index in acceptedHere) return + val latest = host.ops.latest(host.entryId, host.generation) + val stored = latest.descriptor!!.parts!![index] + if (stored.accepted) return + if (RetryClassifier.isExpired(host.now(), latest.expiresAt)) throw EntryWorker.ExpiredException() + + // A range past EOF can never be sent. length() is 0 for a missing file; + // that case falls through to the transfer, which classifies it as file. + val blobLength = runCatching { blob.length() }.getOrDefault(0L) + if (blobLength > 0L && stored.end > blobLength) { + throw PartFailed( + Settlement.Failed( + "file", + "part $index range [${stored.start}, ${stored.end}) exceeds the file size $blobLength", + null, index, stored.url, latest.descriptor.method, + ), + ) } + host.waitForNetwork() + + val requestId = UUID.randomUUID().toString() + val entry = host.ops.recordAttempt(host.entryId, host.generation, requestId) + // The generation of the headers this attempt sends: both come from the same entry. + val headerGeneration = entry.headerGeneration + val d = entry.descriptor!! + val part = d.parts!![index] + val policy = host.policy(entry) val response = try { transferSemaphore.withPermit { - okhttpUploadPart(uploadHttpClient, part, File(current.sourcePath)) { sent -> - onPartProgress(index, sent) - } + okhttpSend( + uploadHttpClient, + TransferRequest( + part.url, d.method, host.headersFor(d, part, requestId), + rangeRequestBody(blob, part.start, part.end), + ), + ) { sent -> onPartProgress(index, sent) } } } catch (error: CancellationException) { throw error - } catch (error: IOException) { + } catch (error: Throwable) { onPartProgress(index, 0L) - // The default is fileExists=true. Thus a failed probe reads as - // network, not file. - val fileExists = runCatching { File(current.sourcePath).exists() }.getOrDefault(true) - if (!fileExists) throw SourceMissingException(current.sourcePath) - transientAttempts++ - delay(ChunkedEngine.backoffMs(transientAttempts)) - continue + val fileExists = runCatching { blob.exists() }.getOrDefault(true) + val message = error.message ?: error.javaClass.simpleName + EventReporter.attempt( + AttemptEvent.ofFailure( + entry, requestId, part.url, index, RetryClassifier.failureKind(error, fileExists), message, host.now(), + ), + ) + when (val verdict = RetryClassifier.classifyFailure(error, fileExists)) { + is RetryClassifier.Verdict.Terminal -> throw PartFailed( + Settlement.Failed(verdict.errorKind, verdict.message, null, index, part.url, d.method), + ) + else -> { + streak++ + host.backoffOrRelease(policy, streak, entry.expiresAt) + continue + } + } } - if (UploadOutcome.isAccepted(response.code, response.body, current.accept)) { - markAccepted(index) - return - } - onPartProgress(index, 0L) - if (ChunkedEngine.isTransientHttp(response.code)) { - transientAttempts++ - delay(ChunkedEngine.backoffMs(transientAttempts)) - continue + val verdict = RetryClassifier.classifyResponse(response.code, response.body, d.accept, policy.exempt) + EventReporter.attempt( + AttemptEvent.ofResponse( + entry, requestId, part.url, index, response, verdict == RetryClassifier.Verdict.Accepted, host.now(), + ), + ) + when (verdict) { + RetryClassifier.Verdict.Accepted -> { + markAccepted(index, part) + return + } + RetryClassifier.Verdict.Auth -> { + if (host.ops.hasNewerHeaders(host.entryId, host.generation, headerGeneration)) { + streak = 0 + continue + } + throw EntryWorker.ParkException(headerGeneration) + } + RetryClassifier.Verdict.Transient -> { + onPartProgress(index, 0L) + streak++ + host.backoffOrRelease(policy, streak, entry.expiresAt) + } + is RetryClassifier.Verdict.Terminal -> throw PartFailed( + Settlement.Failed("http", "HTTP ${response.code} on part $index", response, index, part.url, d.method), + ) } - rejections++ - if (rejections > ChunkedEngine.PART_HTTP_RETRIES) throw PartRejectedException(index, response) - delay(ChunkedEngine.backoffMs(rejections)) } } - // The stored copy is the truth: a reconcile can have replaced the headers - // or expiresAt. Fall back to the in-memory copy only when the read fails. - private fun latest(): ChunkedManifest = - store.load(uploadId)?.also { manifest = it } ?: manifest!! - - private fun markAccepted(index: Int) { - // Persist the flag first, atomically against concurrent flips and - // reconciles. This is best-effort. A lost flag only re-sends this part on - // a later resume, and the consumer's accept rules absorb that ('already - // completed'). That is better than a failure of an upload that the server - // accepted. - manifest = store.update(uploadId) { it.withPartAccepted(index) } - ?: manifest?.withPartAccepted(index) - manifest?.parts?.get(index)?.let { acceptedBytes.addAndGet(it.size) } + private fun markAccepted(index: Int, part: Part) { + // Remembered here too, so a lost flag write does not re-send the part in this run. + acceptedHere += index + host.ops.markAccepted(host.entryId, host.generation, index) + acceptedBytes.addAndGet(part.size) + lastAcceptedUrl = part.url partSent.remove(index) - reportProgress() + report() } private fun onPartProgress(index: Int, sent: Long) { if (sent == 0L) partSent.remove(index) else partSent[index] = sent - reportProgress() + report() } - private fun reportProgress() { - val total = manifest?.totalBytes ?: return + private fun report() { val sent = (acceptedBytes.get() + partSent.values.sum()).coerceAtMost(total) - UploadProgress.set(uploadId, sent) - EventReporter.progress(uploadId, sent, total) - updateNotification() + host.reportProgress(sent, total) } - - // A resume of a finished-but-unacknowledged upload (all parts accepted, - // 'completed' journaled, and the consumer re-called startUpload before the - // ack) must not mint a second terminal event. Re-emit the journaled one. - // Then a live listener still hears it, with the eventId that the consumer - // will acknowledge. And a trailing run whose completion was already ACKED - // reports nothing at all. See ChunkedEngine.CompletionReport. - private fun journalCompleted(freshCompletion: Boolean) { - val report = ChunkedEngine.completionReport( - EventJournal.get(context).unacknowledged(), - uploadId, - freshCompletion, - ) - when (report) { - is ChunkedEngine.CompletionReport.ReEmit -> EventReporter.emit(report.entry) - // No response fields, because no single response represents N accepted - // parts. - ChunkedEngine.CompletionReport.Mint -> journalAndEmit( - EventJournal.Entry( - eventId = UUID.randomUUID().toString(), - uploadId = uploadId, - type = "completed", - timestamp = System.currentTimeMillis(), - ), - ) - ChunkedEngine.CompletionReport.None -> Unit - } - } - - private fun handleFailure(error: Throwable) { - val entry = when (error) { - is ExpiredException -> errorEntry( - error = "upload expired before every part was accepted", - errorKind = "expired", - ) - is PartRejectedException -> { - val (body, truncated) = EventJournal.capBody(error.response.body) - errorEntry( - error = "HTTP ${error.response.code} on part ${error.partIndex}", - errorKind = "http", - partIndex = error.partIndex, - responseCode = error.response.code, - responseBody = body, - responseBodyTruncated = truncated, - responseHeaders = error.response.headers, - ) - } - is SourceMissingException -> errorEntry(error = error.message!!, errorKind = "file") - is PartBeyondEofException -> errorEntry( - error = error.message!!, - errorKind = "file", - partIndex = error.partIndex, - ) - else -> { - val fileExists = manifest?.let { m -> - runCatching { File(m.sourcePath).exists() }.getOrDefault(true) - } ?: true - errorEntry( - error = error.message ?: "Unknown exception", - errorKind = UploadOutcome.errorKind(error, fileExists), - ) - } - } - journalAndEmit(entry) - } - - private fun errorEntry( - error: String, - errorKind: String, - partIndex: Int? = null, - responseCode: Int? = null, - responseBody: String? = null, - responseBodyTruncated: Boolean = false, - responseHeaders: Map? = null, - ) = EventJournal.Entry( - eventId = UUID.randomUUID().toString(), - uploadId = uploadId, - type = "error", - timestamp = System.currentTimeMillis(), - error = error, - errorKind = errorKind, - partIndex = partIndex, - responseCode = responseCode, - responseBody = responseBody, - responseBodyTruncated = responseBodyTruncated, - responseHeaders = responseHeaders, - ) - - // The semantics are the same as UploadWorker's. Only a user cancel is - // terminal (journaled, cancelReason 'user'). A system stop emits nothing, - // because WorkManager will re-run this upload, and the manifest resumes it. - // The manifest and the bytes are kept in both cases. stopUpload's contract - // is that the next startUpload resumes. - private fun checkAndHandleCancellation(): Boolean { - if (!isStopped) return false - - UploadProgress.remove(uploadId) - - if (!UserCancellations.consume(uploadId)) return true - - journalAndEmit( - EventJournal.Entry( - eventId = UUID.randomUUID().toString(), - uploadId = uploadId, - type = "cancelled", - timestamp = System.currentTimeMillis(), - cancelReason = "user", - ), - ) - return true - } - - private fun journalAndEmit(entry: EventJournal.Entry) { - // A terminal event for an id whose manifest is gone would report an - // upload that nobody owns any more. Either removeUpload deleted it mid-run - // (its work cancel races the in-flight PUT's IOException), or a completed - // ack released it. Suppress the event; iOS's removedIds has the same idea. - // A user cancel keeps its manifest, so real 'cancelled' events pass - // through. - if (!store.contains(uploadId)) return - EventReporter.journalAndEmit(context, entry) - } - - private fun validateAndReportConnectivity(wifiOnly: Boolean): Boolean { - connectivity = validateConnectivity(context, wifiOnly) - updateNotification() - return connectivity == Connectivity.Ok - } - - private fun updateNotification() { - if (manifest?.showsNotification != true) return - notificationManager.notify( - config.systemNotificationId, - buildUploadNotification(context, config, connectivity), - ) - } - - override suspend fun getForegroundInfo(): ForegroundInfo = - uploadForegroundInfo(config, buildUploadNotification(context, config, connectivity)) } - -/** - * The Result that a chunked run returns after it journals a terminal error: - * SUCCESS, deliberately. The journal and the manifest are the upload's outcome - * record, never the WorkManager row state. A row that finishes FAILED destroys - * every appended dependent: WorkManager marks the dependents of a failed - * prerequisite FAILED without a run. Thus a resume enqueued during the failing - * run's teardown window would silently never run (see the APPEND_OR_REPLACE - * note in UploaderModule.enqueueChunkedUpload). getAllUploads derives a - * chunked upload's state from its manifest (allAccepted), not from row states. - */ -internal fun terminalErrorResult(): ListenableWorker.Result = ListenableWorker.Result.success() diff --git a/android/src/main/java/ai/openspace/backgroundupload/ChunkedWorkerGate.kt b/android/src/main/java/ai/openspace/backgroundupload/ChunkedWorkerGate.kt deleted file mode 100644 index 424c627b..00000000 --- a/android/src/main/java/ai/openspace/backgroundupload/ChunkedWorkerGate.kt +++ /dev/null @@ -1,40 +0,0 @@ -package ai.openspace.backgroundupload - -import java.util.concurrent.ConcurrentHashMap - -/** - * At most one [ChunkedUploadWorker] EXECUTES per upload id, process-wide. - * - * The unique-work chain almost guarantees this, but not across a cancel. - * cancelUniqueWork marks the row CANCELLED immediately, while the cancelled - * worker's coroutine still winds down. Thus a startUpload that arrives right - * after a cancelUpload can enqueue (and start) a replacement worker while the - * old worker still has a part PUT in flight. Two concurrent PUTs of one - * partNum are verified unsafe on the server side. A starting worker acquires - * its id here, and a successor waits for the release. - * - * This is also the truthful "is this upload running" for the recreate rule. - * A worker registers before its first manifest read, and it releases in a - * finally block. WorkManager's row state stays RUNNING for a moment after - * doWork returns. This gate does not: it never reports a finished run as - * running. - * - * The gate is same-process only, like [UserCancellations]. A worker in a dead - * process holds nothing, and WorkManager runs our workers in the app process. - */ -object ChunkedWorkerGate { - private val holders = ConcurrentHashMap() - - /** True when [token] now holds the id, or already held it. False while another token holds it. */ - fun tryAcquire(id: String, token: Any): Boolean { - val current = holders.putIfAbsent(id, token) - return current == null || current === token - } - - /** Releases only when [token] is the holder. Thus a stale release cannot evict a successor. */ - fun release(id: String, token: Any) { - holders.remove(id, token) - } - - fun isRunning(id: String): Boolean = holders.containsKey(id) -} diff --git a/android/src/main/java/ai/openspace/backgroundupload/Diag.kt b/android/src/main/java/ai/openspace/backgroundupload/Diag.kt new file mode 100644 index 00000000..14382887 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/Diag.kt @@ -0,0 +1,19 @@ +package ai.openspace.backgroundupload + +import android.util.Log + +/** + * Logging that is safe in the JVM unit tests. There, android.util.Log is a + * stub that throws, so every call is wrapped. + */ +internal object Diag { + const val TAG = "RNFileUploader" + + fun warn(message: String, error: Throwable? = null) { + runCatching { Log.w(TAG, message, error) } + } + + fun error(message: String, error: Throwable? = null) { + runCatching { Log.e(TAG, message, error) } + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/EnqueueRules.kt b/android/src/main/java/ai/openspace/backgroundupload/EnqueueRules.kt new file mode 100644 index 00000000..5b4fdf64 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/EnqueueRules.kt @@ -0,0 +1,156 @@ +package ai.openspace.backgroundupload + +/** + * The same-id rules of enqueue() (plan 5.4), as pure functions. [decide] + * picks the action; the builders make the next entry from the staged body. + * + * | Stored entry | Action | + * | none, no v9 manifest | Create | + * | none, v9 manifest | AdoptV9: keep the blob and accepted parts | + * | legacy row | Replace (generation + 1) | + * | same body, completed, record present | ReEmit: deliveries + 1, no re-run | + * | same body, completed, record gone | Replace (it was acked) | + * | same body, any other state | Resume (a settled one reopens: gen + 1) | + * | different body, running | RejectRunning (E_RUNNING) | + * | different body, otherwise | Replace (generation + 1, attempts 0) | + */ +object EnqueueRules { + + sealed class Action { + object Create : Action() + data class AdoptV9(val manifest: LegacyManifest) : Action() + data class ReEmit(val eventId: String) : Action() + object Resume : Action() + object Replace : Action() + object RejectRunning : Action() + } + + fun decide( + existing: QueueEntry?, + v9: LegacyManifest?, + incoming: Descriptor, + hasRecord: (eventId: String) -> Boolean, + ): Action { + if (existing == null) return if (v9 != null) Action.AdoptV9(v9) else Action.Create + if (existing.legacy) return Action.Replace + if (existing.sameBodyAs(incoming)) { + if (existing.state == EntryState.COMPLETED) { + val eventId = existing.settledEventId + return if (eventId != null && hasRecord(eventId)) Action.ReEmit(eventId) else Action.Replace + } + return Action.Resume + } + return if (existing.state == EntryState.RUNNING) Action.RejectRunning else Action.Replace + } + + /** + * The generation to stage a body for before the store lock is taken, or + * null to stage under the lock. Only a copied body (JSON, multipart, + * file), and only when no worker can change the decision meanwhile: a new + * id, or a replace of an entry that a worker can not take (not queued, + * not running). A chunked body is a move, which is fast, over a blob that + * a worker may be reading, so it always stages under the lock. + */ + fun preStageGeneration(existing: QueueEntry?, action: Action, incoming: Descriptor): Int? { + val copied = incoming.bodyKind.let { + it == StagedBody.JSON || it == StagedBody.MULTIPART || it == StagedBody.FILE + } + if (!copied) return null + return when (action) { + Action.Create -> 1 + Action.Replace -> existing + ?.takeIf { it.state != EntryState.QUEUED && it.state != EntryState.RUNNING } + ?.let { it.generation + 1 } + else -> null + } + } + + /** The parts an adopted v9 manifest runs with: its accepted flags when the parts are the same. */ + fun adoptedParts(v9: LegacyManifest, incoming: List): List = + if (ChunkedParts.sameParts(v9.parts, incoming)) ChunkedParts.carryAccepted(v9.parts, incoming) + else incoming + + private fun initialState(paused: Boolean) = if (paused) EntryState.PAUSED else EntryState.QUEUED + + /** A new entry (Create, AdoptV9). [parts] carries adopted accepted flags. */ + fun created( + p: EntryParsing.Parsed, + staged: BodyStaging.Staged, + parts: List?, + paused: Boolean, + headerGeneration: Int, + now: Long, + ): QueueEntry { + val runParts = parts ?: p.descriptor.parts + return QueueEntry( + id = p.id, + key = p.key, + varsJson = p.varsJson, + descriptor = p.descriptor.copy(headers = staged.headers, parts = runParts), + body = staged.body, + state = initialState(paused), + attempts = 0, + bytesSent = runParts?.let { ChunkedParts.acceptedBytes(it) } ?: 0L, + totalBytes = staged.body.totalBytes, + expiresAt = p.expiresAt, + createdAt = now, + updatedAt = now, + headerGeneration = headerGeneration, + generation = 1, + ) + } + + /** + * Same body. New headers, expiresAt, vars, accept, retry, and notification + * flag replace the stored ones; the body and accepted parts stay. A settled + * entry reopens with a fresh generation. A running one stays running (the + * worker reads the new headers before its next attempt). + */ + fun resumed( + existing: QueueEntry, + p: EntryParsing.Parsed, + paused: Boolean, + headerGeneration: Int, + now: Long, + ): QueueEntry { + val stored = existing.descriptor!! + val body = existing.body!! + val parts = stored.parts?.let { ChunkedParts.carryAccepted(it, p.descriptor.parts!!) } + val running = existing.state == EntryState.RUNNING + val reopen = existing.isSettled + return existing.copy( + key = p.key, + varsJson = p.varsJson, + descriptor = stored.copy( + headers = BodyStaging.headersFor(p.descriptor.headers, body), + parts = parts, + accept = p.descriptor.accept, + retry = p.descriptor.retry, + noNotification = p.descriptor.noNotification, + ), + state = if (running) EntryState.RUNNING else initialState(paused), + bytesSent = parts?.let { ChunkedParts.acceptedBytes(it) } ?: if (running) existing.bytesSent else 0L, + expiresAt = p.expiresAt, + updatedAt = now, + nextAttemptAt = null, + backoffStreak = 0, + headerGeneration = headerGeneration, + parkedGeneration = null, + generation = if (reopen) existing.generation + 1 else existing.generation, + settledEventId = if (reopen) null else existing.settledEventId, + ) + } + + /** Different body (or over a legacy or acked row). A new life over the same id. */ + fun replaced( + existing: QueueEntry, + p: EntryParsing.Parsed, + staged: BodyStaging.Staged, + paused: Boolean, + headerGeneration: Int, + now: Long, + ): QueueEntry = created(p, staged, null, paused, headerGeneration, now).copy( + createdAt = existing.createdAt, + generation = existing.generation + 1, + ) +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/EntryParsing.kt b/android/src/main/java/ai/openspace/backgroundupload/EntryParsing.kt new file mode 100644 index 00000000..81d06fb9 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/EntryParsing.kt @@ -0,0 +1,184 @@ +package ai.openspace.backgroundupload + +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.net.URI + +/** + * Turns the EnqueueEntry `{ id, key, vars, descriptor }` into Kotlin values. + * JS has already validated the descriptor. Native checks only what it needs + * to run, and rejects anything else with E_INVALID. + * + * A null value is read as absent. The bridge turns a JS `undefined` into + * null, so native can not tell `data: null` from `data: undefined`. + */ +object EntryParsing { + class InvalidEntryException(message: String) : IllegalArgumentException(message) + + data class Parsed( + val id: String, + val key: String, + val varsJson: String, + val descriptor: Descriptor, + val expiresAt: Long, + ) + + private val METHODS = setOf("POST", "PUT", "PATCH", "DELETE", "GET") + + fun parse(entry: ReadableMap): Parsed { + val id = entry.string("id")?.takeIf { it.isNotEmpty() } ?: invalid("id is required") + val key = entry.string("key")?.takeIf { it.isNotEmpty() } ?: invalid("key is required") + val varsJson = JsonBridge.toJson(JsonBridge.valueOf(entry, "vars")) + val d = entry.map("descriptor") ?: invalid("descriptor is required") + val expiresAt = d.number("expiresAt")?.toLong() ?: invalid("descriptor.expiresAt is required") + return Parsed(id, key, varsJson, descriptor(d), expiresAt) + } + + fun descriptor(d: ReadableMap): Descriptor { + val method = (d.string("method") ?: "POST").uppercase() + if (method !in METHODS) invalid("method $method is not supported") + + val parts = d.array("parts")?.let { parseParts(it) } + val url = d.string("url") + if (url == null && parts == null) invalid("url is required unless parts is set") + url?.let { requireHttpUrl(it, "url") } + + val dataJson = if (d.isSet("data")) JsonBridge.toJson(JsonBridge.valueOf(d, "data")) else null + val form = d.array("form")?.let { parseForm(it) } + val file = d.string("file")?.let { stripFileScheme(it) } + val kinds = listOfNotNull(dataJson?.let { "data" }, form?.let { "form" }, file?.let { "file" }) + if (kinds.size > 1) invalid("at most one of data, form, file; got ${kinds.joinToString()}") + if (parts != null && file == null) invalid("parts requires file") + if (method == "GET" && kinds.isNotEmpty()) invalid("a GET request can not carry a body") + + val headers = parseHeaderMap(d.map("headers")) + requireValidHeaders(headers, "headers") + + return Descriptor( + url = url, + method = method, + headers = headers, + dataJson = dataJson, + form = form, + file = file, + parts = parts, + accept = parseAcceptRules(d.array("accept")), + retry = d.map("retry")?.let { parseRetry(it) }, + noNotification = d.map("android")?.bool("noNotification") ?: false, + ) + } + + /** updateHeaders(patch): the header map, checked the same way as a descriptor's. */ + fun headerPatch(patch: ReadableMap): Map = + parseHeaderMap(patch).also { requireValidHeaders(it, "updateHeaders") } + + /** `file:///a%20b` → `/a b`. A plain path is returned as it is. */ + fun stripFileScheme(path: String): String { + if (!path.startsWith("file://")) return path + return runCatching { URI(path).path }.getOrNull() ?: path.removePrefix("file://") + } + + private fun parseParts(arr: ReadableArray): List { + if (arr.size() == 0) invalid("parts must be a non-empty array") + return (0 until arr.size()).map { i -> + val p = arr.getMap(i) ?: invalid("parts[$i] must be an object") + val url = p.string("url") ?: invalid("parts[$i].url is required") + requireHttpUrl(url, "parts[$i].url") + val range = p.map("range") ?: invalid("parts[$i].range is required") + val start = range.number("start")?.toLong() ?: invalid("parts[$i].range.start is required") + val end = range.number("end")?.toLong() ?: invalid("parts[$i].range.end is required") + if (start < 0 || end <= start) invalid("parts[$i].range must satisfy 0 <= start < end") + val headers = parseHeaderMap(p.map("headers")) + requireValidHeaders(headers, "parts[$i].headers") + Part(url = url, headers = headers, start = start, end = end) + } + } + + private fun parseForm(arr: ReadableArray): List { + if (arr.size() == 0) invalid("form must be a non-empty array") + return (0 until arr.size()).map { i -> + val p = arr.getMap(i) ?: invalid("form[$i] must be an object") + val name = p.string("name") ?: invalid("form[$i].name is required") + val contentType = p.string("contentType") ?: invalid("form[$i].contentType is required") + val string = p.string("string") + val path = p.string("path")?.let { stripFileScheme(it) } + if ((string == null) == (path == null)) invalid("form[$i] must set exactly one of string, path") + FormPart(name, contentType, string, path, p.string("fileName")) + } + } + + private fun parseRetry(r: ReadableMap): RetryOverride { + val backoff = r.map("backoff") + val exempt = r.map("terminalHttp")?.array("exempt")?.let { arr -> + (0 until arr.size()).mapNotNull { i -> + if (arr.getType(i) == ReadableType.Number) arr.getDouble(i).toInt() else null + } + } + return RetryOverride( + baseMs = backoff?.number("baseMs")?.toLong(), + maxMs = backoff?.number("maxMs")?.toLong(), + jitter = backoff?.number("jitter"), + exempt = exempt, + ) + } + + internal fun parseAcceptRules(arr: ReadableArray?): List { + if (arr == null) return listOf() + return (0 until arr.size()).mapNotNull { i -> + val rule = arr.getMap(i) ?: return@mapNotNull null + val status = rule.number("status") ?: return@mapNotNull null + UploadOutcome.AcceptRule(status.toInt(), rule.string("bodyIncludes")) + } + } + + /** Header values keep their text. A number is written as JSON would write it. */ + internal fun parseHeaderMap(map: ReadableMap?): Map { + if (map == null) return mapOf() + val out = LinkedHashMap() + JsonBridge.fromReadable(map).forEach { (k, v) -> + when (v) { + null -> Unit + is Double -> out[k] = JsonBridge.numberText(v) + else -> out[k] = v.toString() + } + } + return out + } + + private fun requireHttpUrl(url: String, where: String) { + if (url.toHttpUrlOrNull() == null) invalid("$where is not an http(s) url: $url") + } + + // OkHttp throws on a header name or value it can not send. Check it here, + // so the error is an enqueue rejection and not a failure at attempt time. + private fun requireValidHeaders(headers: Map, where: String) { + try { + val builder = Headers.Builder() + headers.forEach { (k, v) -> builder.add(k, v) } + } catch (e: IllegalArgumentException) { + invalid("$where: ${e.message}") + } + } + + private fun invalid(message: String): Nothing = throw InvalidEntryException(message) + + private fun ReadableMap.isSet(key: String) = hasKey(key) && getType(key) != ReadableType.Null + + private fun ReadableMap.string(key: String): String? = + if (hasKey(key) && getType(key) == ReadableType.String) getString(key) else null + + private fun ReadableMap.number(key: String): Double? = + if (hasKey(key) && getType(key) == ReadableType.Number) getDouble(key) else null + + private fun ReadableMap.bool(key: String): Boolean? = + if (hasKey(key) && getType(key) == ReadableType.Boolean) getBoolean(key) else null + + private fun ReadableMap.map(key: String): ReadableMap? = + if (hasKey(key) && getType(key) == ReadableType.Map) getMap(key) else null + + private fun ReadableMap.array(key: String): ReadableArray? = + if (hasKey(key) && getType(key) == ReadableType.Array) getArray(key) else null +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/EntryTransitions.kt b/android/src/main/java/ai/openspace/backgroundupload/EntryTransitions.kt new file mode 100644 index 00000000..94a297fa --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/EntryTransitions.kt @@ -0,0 +1,86 @@ +package ai.openspace.backgroundupload + +/** + * The state changes of one entry, as pure functions. [QueueController] and + * [WorkerOps] apply them inside `QueueStore.compute`, so each one is atomic + * against the other side. + */ +object EntryTransitions { + + /** A worker takes a queued entry. */ + fun toRunning(e: QueueEntry, now: Long) = + e.copy(state = EntryState.RUNNING, nextAttemptAt = null, updatedAt = now) + + /** A 401/403 under the current header generation. No backoff. */ + fun toParked(e: QueueEntry, headerGeneration: Int, now: Long) = e.copy( + state = EntryState.AWAITING_AUTH, + parkedGeneration = headerGeneration, + backoffStreak = 0, + updatedAt = now, + ) + + /** + * A short backoff that the worker waits out itself. The row stays running + * and shows when the next attempt is due. + */ + fun toBackingOff(e: QueueEntry, nextAttemptAt: Long, now: Long) = + e.copy(nextAttemptAt = nextAttemptAt, updatedAt = now) + + /** One attempt starts: attempts + 1, its X-Request-Id, and no pending backoff. */ + fun toAttempt(e: QueueEntry, requestId: String, now: Long) = e.copy( + attempts = e.attempts + 1, + lastRequestId = requestId, + nextAttemptAt = null, + updatedAt = now, + ) + + /** A backoff too long to wait inside the worker. The streak is kept so the next wait keeps growing. */ + fun toReleased(e: QueueEntry, nextAttemptAt: Long, streak: Int, now: Long) = e.copy( + state = EntryState.QUEUED, + nextAttemptAt = nextAttemptAt, + backoffStreak = streak, + updatedAt = now, + ) + + fun toSettled(e: QueueEntry, state: EntryState, eventId: String, bytesSent: Long, now: Long) = e.copy( + state = state, + settledEventId = eventId, + bytesSent = bytesSent, + nextAttemptAt = null, + parkedGeneration = null, + updatedAt = now, + ) + + /** + * pause(). parkedGeneration is kept so resume() can return the entry to + * awaiting-auth. nextAttemptAt is cleared: resume() retries at once. + */ + fun toPaused(e: QueueEntry, now: Long) = + e.copy(state = EntryState.PAUSED, nextAttemptAt = null, updatedAt = now) + + /** resume(): back to awaiting-auth only when no updateHeaders() came in between. */ + fun toResumed(e: QueueEntry, headerGeneration: Int, now: Long): QueueEntry = + if (e.parkedGeneration != null && e.parkedGeneration == headerGeneration) { + e.copy(state = EntryState.AWAITING_AUTH, updatedAt = now) + } else { + e.copy(state = EntryState.QUEUED, parkedGeneration = null, updatedAt = now) + } + + /** A system stop of a running worker. WorkManager runs the row again. No outcome. */ + fun toStopped(e: QueueEntry, now: Long) = e.copy(state = EntryState.QUEUED, updatedAt = now) + + /** updateHeaders() on a parked entry. */ + fun toUnparked(e: QueueEntry, paused: Boolean, now: Long) = e.copy( + state = if (paused) EntryState.PAUSED else EntryState.QUEUED, + parkedGeneration = null, + updatedAt = now, + ) + + /** Whether a worker of [generation] may still settle [e]. Not after a cancel, a replace, or a settle. */ + fun canSettle(e: QueueEntry?, generation: Int) = + e != null && e.generation == generation && e.isLive + + /** Whether a worker of [generation] still owns the running entry. */ + fun isOwnedRun(e: QueueEntry?, generation: Int) = + e != null && e.generation == generation && e.state == EntryState.RUNNING +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/EntryWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/EntryWorker.kt new file mode 100644 index 00000000..35f67957 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/EntryWorker.kt @@ -0,0 +1,297 @@ +package ai.openspace.backgroundupload + +import android.app.NotificationManager +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import kotlin.math.max +import kotlin.math.min + +/** + * Runs one queue entry. The input data holds only the entry id; the worker + * reads the entry from the store at start and again before every attempt, + * so fresh headers and a new expiresAt reach a running worker with no + * restart. + * + * The run: acquire the per-id gate, take the entry (queued → running), run + * the transfer, then settle, park, or release. The body kind picks the + * transfer: [SimpleTransfer] or [ChunkedTransfer]. [UploadWorker] and + * [ChunkedUploadWorker] are the two class names WorkManager knows; both run + * this same code, so a kind change under a queued run is safe. + * + * Every run returns success (see [WorkManagerScheduler] for why). A v9 row, + * which has no entry id, exits at once in silence. + */ +open class EntryWorker(protected val context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + + companion object { + private const val GATE_POLL_MS = 100L + /** The poll while the network is unusable (offline, or waiting for wifi). */ + const val CONNECTIVITY_POLL_MS = 10_000L + /** The poll while a short backoff remainder runs out before the run starts. */ + private const val WAIT_POLL_MS = 10_000L + } + + /** A 401/403 under the headers of [headerGeneration] (the entry's value the attempt sent). */ + class ParkException(val headerGeneration: Int) : Exception("awaiting auth") + + /** A backoff too long to wait here. */ + class BackoffException(val nextAttemptAt: Long, val streak: Int) : Exception("released for backoff") + + class ExpiredException : Exception("expired before completion") + + /** The queue was paused between the module's pause and the work cancel reaching us. */ + class PausedException : Exception("queue paused") + + internal lateinit var entryId: String + internal var generation = 0 + internal val store by lazy { QueueStore.get(context) } + internal val ops by lazy { + WorkerOps( + store, + EventJournal.get(context), + QueueSettingsStore.get(context), + EventReporter, + WorkManagerScheduler(context), + ) + } + private val config by lazy { NotificationConfig.load(context) } + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + @Volatile + private var connectivity = Connectivity.Ok + + @Volatile + private var showsNotification = false + + final override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val id = inputData.getString(WorkManagerScheduler.ENTRY_ID_KEY) ?: return@withContext Result.success() + entryId = id + // Acquire before the first store read: a cancel-then-enqueue can start + // this run while the old one still winds down. + while (!WorkerGate.tryAcquire(id, this@EntryWorker)) delay(GATE_POLL_MS) + try { + runEntry() + Result.success() + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + // A store failure (disk full at start or during an attempt). Nothing + // was settled; try the run again later. + Diag.error("run of '$id' failed before it could settle; retrying", error) + Result.retry() + } finally { + WorkerGate.release(id, this@EntryWorker) + } + } + + private suspend fun runEntry() { + val initial = store.load(entryId) ?: return // forgotten while queued + if (initial.legacy) return + if (initial.state == EntryState.AWAITING_AUTH) { + // The expiry wake of a parked entry. + if (RetryClassifier.isExpired(now(), initial.expiresAt)) { + ops.settle(entryId, initial.generation, expired(initial)) + } + return + } + if (initial.state != EntryState.QUEUED && initial.state != EntryState.RUNNING) return + if (ops.settings().paused) return + if (!waitUntilDue(initial)) return + val entry = ops.begin(entryId) ?: return + generation = entry.generation + showsNotification = entry.descriptor?.noNotification == false + + var current = entry + var first = true + while (true) { + try { + if (!first) current = ops.latest(entryId, generation) + first = false + startForeground() + val settlement = transfer(current) + endProgress(completed = settlement is Settlement.Completed) + ops.settle(entryId, generation, settlement) + return + } catch (park: ParkException) { + endProgress(completed = false) + // REISSUE: updateHeaders() landed while this attempt was in flight. + if (ops.park(entryId, generation, park.headerGeneration) != WorkerOps.ParkResult.REISSUE) return + } catch (backoff: BackoffException) { + endProgress(completed = false) + ops.release(entryId, generation, backoff.nextAttemptAt, backoff.streak) + return + } catch (error: ExpiredException) { + endProgress(completed = false) + ops.settle(entryId, generation, expired(current)) + return + } catch (error: NotOwnedException) { + endProgress(completed = false) + return + } catch (error: PausedException) { + endProgress(completed = false) + return + } catch (error: CancellationException) { + // A system stop moves a running entry back to queued. A pause or a + // cancel already moved it; then this does nothing. + endProgress(completed = false) + ops.stopped(entryId, generation) + throw error + } catch (error: IOException) { + // A store write failed (disk full, directory briefly unwritable). + // The transfers classify every network IOException themselves, so + // one that lands here is storage: transient, no outcome. Back to + // queued; doWork returns retry. + endProgress(completed = false) + ops.stopped(entryId, generation) + throw error + } catch (error: Throwable) { + endProgress(completed = false) + val d = current.descriptor + ops.settle( + entryId, + generation, + Settlement.Failed( + errorKind = "unknown", + message = error.message ?: error.javaClass.simpleName, + response = null, + partIndex = null, + url = d?.reportUrl ?: "", + method = d?.method ?: "POST", + ), + ) + return + } + } + } + + private suspend fun transfer(entry: QueueEntry): Settlement = + if (entry.body?.kind == StagedBody.CHUNKED) ChunkedTransfer(this).run(entry) + else SimpleTransfer(this).run(entry) + + private fun expired(entry: QueueEntry) = Settlement.Failed( + errorKind = "expired", + message = "expired before completion", + response = null, + partIndex = null, + url = entry.descriptor?.reportUrl ?: "", + method = entry.descriptor?.method ?: "POST", + ) + + /** + * Sleeps out a short backoff remainder. False when the wait is long (the + * wake run comes back for it) or the entry is no longer queued. + */ + private suspend fun waitUntilDue(initial: QueueEntry): Boolean { + var e = initial + while (true) { + val at = e.nextAttemptAt ?: return true + val remaining = at - now() + if (remaining <= 0) return true + if (remaining > RetryClassifier.IN_WORKER_BACKOFF_MAX_MS) return false + delay(min(remaining, WAIT_POLL_MS)) + e = store.load(entryId) ?: return false + if (e.state != EntryState.QUEUED && e.state != EntryState.RUNNING) return false + } + } + + // MARK: - helpers for the transfers + + internal fun now() = System.currentTimeMillis() + + /** + * Waits until the network fits the queue's wifi-only setting. Re-reads + * the settings and the entry at every poll. + */ + internal suspend fun waitForNetwork() { + while (true) { + val s = ops.settings() + if (s.paused) throw PausedException() + val entry = ops.latest(entryId, generation) + if (RetryClassifier.isExpired(now(), entry.expiresAt)) throw ExpiredException() + connectivity = validateConnectivity(context, s.wifiOnly) + updateNotification() + if (connectivity == Connectivity.Ok) return + delay(CONNECTIVITY_POLL_MS) + } + } + + /** The descriptor's headers, the part's over them, and X-Request-Id over all. */ + internal fun headersFor(d: Descriptor, part: Part?, requestId: String): Map { + val withPart = if (part == null) d.headers else HeaderMap.merge(d.headers, part.headers) + return HeaderMap.merge(withPart, mapOf("X-Request-Id" to requestId)) + } + + internal fun policy(entry: QueueEntry) = + RetryClassifier.policy(ops.settings().retry, entry.descriptor?.retry) + + /** + * A short backoff waits here, with the row still running and showing + * nextAttemptAt; a long one throws [BackoffException] to release the worker. + */ + internal suspend fun backoffOrRelease(policy: RetryClassifier.Policy, streak: Int, expiresAt: Long) { + val backoff = RetryClassifier.backoffMs(policy, streak) + val now = now() + if (backoff <= RetryClassifier.IN_WORKER_BACKOFF_MAX_MS) { + val wait = min(backoff, max(0L, expiresAt - now)) + ops.backingOff(entryId, generation, now + wait) + delay(wait) + return + } + throw BackoffException(RetryClassifier.nextAttemptAt(now, backoff, expiresAt), streak) + } + + internal fun reportProgress(sent: Long, total: Long) { + UploadProgress.set(entryId, sent) + EventReporter.progress(entryId, sent, total) + updateNotification() + } + + internal fun bodyFile(entry: QueueEntry): File? = store.bodyFile(entry) + + private fun endProgress(completed: Boolean) { + if (completed) UploadProgress.complete(entryId) else UploadProgress.remove(entryId) + EventReporter.flushProgress(entryId) + EventReporter.dropProgress(entryId) + } + + // MARK: - notification + + // v9 rules. A suppressed notification means no foreground mode. A denied + // foreground start (API 31+, app in the background: the usual case for a + // WorkManager relaunch) is not a failure; the transfer runs without + // foreground priority. Any other failure is logged and the run goes on. + private suspend fun startForeground() { + if (!showsNotification) return + try { + ensureNotificationChannel(notificationManager, config) + setForeground(getForegroundInfo()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + if (!isForegroundStartDenied(error)) Diag.warn("foreground start failed; running without it", error) + } + } + + private fun updateNotification() { + if (!showsNotification) return + runCatching { + notificationManager.notify( + config.systemNotificationId, + buildUploadNotification(context, config, connectivity), + ) + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo = + uploadForegroundInfo(config, buildUploadNotification(context, config, connectivity)) +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt index 2dd718b3..03535d68 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt @@ -1,82 +1,139 @@ package ai.openspace.backgroundupload import android.content.Context +import com.facebook.react.bridge.WritableMap import com.google.gson.Gson import java.io.File -// Durable record of terminal upload events (completed / error / cancelled). -// Written BEFORE the event is emitted to JS; deleted only when JS acknowledges. -// One JSON file per event named .json — tmp+rename keeps each write -// self-contained so a crash mid-append can never corrupt other entries. -// -// `maxEntries` is a runaway guard: the design assumes JS drains the journal via -// ack() on every boot, but if that loop breaks (or a consumer hasn't adopted it -// yet) the directory would grow without bound. When exceeded we drop the OLDEST -// entries. Set high enough that legitimate heavy offline use won't hit it — this -// only fires in the pathological "nothing ever acks" case. -class EventJournal( - private val dir: File, - private val maxEntries: Int = MAX_ENTRIES, -) { - - data class Entry( +/** + * The durable record of settled outcomes (completed, error, cancelled). A + * record is written BEFORE the outcome is emitted to JS and deleted only when + * JS acknowledges it. One JSON file per record, `.json`, written + * with tmp + fsync + rename, so a crash mid-write can not corrupt another + * record. + * + * [maxEntries] is a runaway guard: if nothing ever acknowledges, the oldest + * records are dropped. It only fires in that broken case. + */ +class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTRIES) { + + /** RawResponse. [status] is null for a chunked completion. */ + data class Response( + val status: Int?, + val headers: Map?, + val body: String?, + val bodyTruncated: Boolean, + ) { + fun toMap(): Map = LinkedHashMap().apply { + status?.let { put("status", it.toDouble()) } + headers?.let { put("headers", it) } + body?.let { put("body", it) } + put("bodyTruncated", bodyTruncated) + } + + companion object { + fun of(response: UploadResponse): Response { + val (body, truncated) = capBody(response.body) + return Response(response.code, response.headers, body, truncated) + } + + /** A chunked completion: N parts, no one response. */ + val NONE = Response(null, null, null, false) + } + } + + /** One settled outcome, in the SettledEvent shape plus [generation]. */ + data class SettledRecord( val eventId: String, - val uploadId: String, - val type: String, // completed | error | cancelled - val timestamp: Long, - val responseCode: Int? = null, - val responseBody: String? = null, - val responseBodyTruncated: Boolean = false, - val responseHeaders: Map? = null, - val error: String? = null, - val errorKind: String? = null, // http | network | file | expired | unknown - val cancelReason: String? = null, // user | system - // Chunked uploads: the index of the failing part, when one part's response - // caused the error. - val partIndex: Int? = null, + val id: String, + val key: String, + val varsJson: String, + val at: Long, + val attempts: Int, + val requestId: String?, + /** + * How many times this outcome reached JS: 1 after a live emit, 0 when it + * was journaled with JS dead; +1 per later delivery (replay, re-emit). + */ + val deliveries: Int, + /** The entry state this outcome puts it in. */ + val state: String, + val bytesSent: Long, + val totalBytes: Long, + val url: String, + val method: String, + val partIndex: Int?, + /** completed | error | cancelled */ + val kind: String, + /** completed; also error with errorKind http. */ + val response: Response?, + val errorKind: String?, + val message: String?, + val cancelReason: String?, + /** The entry life this belongs to (same-id rule 6). */ + val generation: Int, ) { - fun toWritableMap(): com.facebook.react.bridge.WritableMap = - com.facebook.react.bridge.Arguments.createMap().apply { - putString("eventId", eventId) - putString("id", uploadId) - putString("type", type) - putDouble("timestamp", timestamp.toDouble()) - responseCode?.let { putInt("responseCode", it) } - responseBody?.let { putString("responseBody", it) } - if (responseBodyTruncated) putBoolean("responseBodyTruncated", true) - responseHeaders?.let { - putMap("responseHeaders", com.facebook.react.bridge.Arguments.makeNativeMap(it)) - } - error?.let { putString("error", it) } - errorKind?.let { putString("errorKind", it) } - cancelReason?.let { putString("cancelReason", it) } - partIndex?.let { putInt("partIndex", it) } + fun toMap(): Map = LinkedHashMap().apply { + put("eventId", eventId) + put("id", id) + put("key", key) + put("vars", runCatching { JsonBridge.parse(varsJson) }.getOrNull()) + put("at", at.toDouble()) + put("attempts", attempts.toDouble()) + requestId?.let { put("requestId", it) } + put("deliveries", deliveries.toDouble()) + put("state", state) + put("bytesSent", bytesSent.toDouble()) + put("totalBytes", totalBytes.toDouble()) + put("url", url) + put("method", method) + partIndex?.let { put("partIndex", it.toDouble()) } + put("kind", kind) + when (kind) { + KIND_COMPLETED -> put("response", (response ?: Response.NONE).toMap()) + KIND_ERROR -> put("error", LinkedHashMap().apply { + put("errorKind", errorKind ?: "unknown") + put("message", message ?: "") + response?.let { put("response", it.toMap()) } + partIndex?.let { put("partIndex", it.toDouble()) } + }) + KIND_CANCELLED -> put("cancelReason", cancelReason ?: "user") } + } + + fun toWritableMap(): WritableMap = JsonBridge.toWritableMap(toMap()) } companion object { - const val MAX_BODY_CHARS = 64 * 1024 + const val KIND_COMPLETED = "completed" + const val KIND_ERROR = "error" + const val KIND_CANCELLED = "cancelled" + + /** 1 MB, the RawResponse cap. */ + const val MAX_BODY_CHARS = 1_048_576 const val MAX_ENTRIES = 1000 private val gson = Gson() - // Char-count cap (not byte-accurate: splitting on a byte boundary risks - // cutting a surrogate pair; a slightly loose cap is fine as a safety limit). - // Returns the (possibly truncated) body and whether truncation occurred. - // Single source of truth so the journaled copy and the live-emitted copy match. - fun capBody(body: String?): Pair = - if (body != null && body.length > MAX_BODY_CHARS) - body.substring(0, MAX_BODY_CHARS) to true - else body to false + // Event ids are UUIDs that native mints. ackEvents takes ids from JS, and + // an id is a file name here, so anything else is ignored. + private val EVENT_ID = Regex("^[A-Za-z0-9-]{1,64}$") + + fun isValidEventId(id: String) = EVENT_ID.matches(id) + + /** + * A char-count cap. It is not byte-exact: a cut on a byte boundary could + * split a surrogate pair. Returns the body and whether it was cut. + */ + fun capBody(body: String?, max: Int = MAX_BODY_CHARS): Pair = + if (body != null && body.length > max) body.substring(0, max) to true else body to false @Volatile private var instance: EventJournal? = null - // The worker may run in a process where React never initialized, so the - // journal must be reachable from a bare Context, not the module. + /** v10 records live in `rnbgupload-settled`. The v9 `rnbgupload-events` is read once by [LegacyImport]. */ fun get(context: Context): EventJournal = instance ?: synchronized(this) { - instance - ?: EventJournal(File(context.filesDir, "rnbgupload-events")).also { instance = it } + instance ?: EventJournal(File(context.filesDir, "rnbgupload-settled")).also { instance = it } } } @@ -84,61 +141,92 @@ class EventJournal( dir.mkdirs() } + private fun fileFor(eventId: String) = File(dir, "$eventId.json") + + /** + * Never throws. The worker calls this right after the server accepted the + * request. A thrown IOException would look like a transient failure and + * re-send the request. Losing one record is the lesser harm, so a failure + * returns false and the caller goes on. + */ @Synchronized - fun append(entry: Entry) { - // Defensive cap in case a caller didn't pre-cap; idempotent when it did. - val (body, truncated) = capBody(entry.responseBody) - val bounded = - if (truncated) entry.copy(responseBody = body, responseBodyTruncated = true) else entry - // A journal write must NEVER throw into the caller. The worker calls this - // right after a successful upload; a propagated IOException (e.g. disk full) - // would be classified as a retryable error and re-run the upload, sending - // duplicate data to the server. Losing one journal entry is the lesser evil. - try { - val tmp = File(dir, "${entry.eventId}.tmp") - tmp.writeText(gson.toJson(bounded)) - tmp.renameTo(File(dir, "${entry.eventId}.json")) + fun append(record: SettledRecord): Boolean { + val bounded = record.response?.let { r -> + val (body, truncated) = capBody(r.body) + if (truncated) record.copy(response = r.copy(body = body, bodyTruncated = true)) else record + } ?: record + val written = try { + AtomicFiles.writeText(fileFor(record.eventId), gson.toJson(bounded)) + true } catch (t: Throwable) { - t.printStackTrace() - return + Diag.error("journal append failed for ${record.eventId}", t) + false } pruneToMax() + return written } - // Keep the directory bounded. Prune by file modification time (no parsing) - // rather than the entry's own timestamp — cheaper, and close enough since a - // file's mtime is when it was journaled. Guarded: a prune failure must not - // propagate for the same reason append() must not. + // Prunes by file time (no parsing). Guarded for the same reason as append. private fun pruneToMax() { try { - // Sweep orphaned .tmp files (writeText succeeded but rename failed). - dir.listFiles { f -> f.extension == "tmp" }?.forEach { it.delete() } + dir.listFiles { f -> f.name.endsWith(AtomicFiles.TMP_SUFFIX) }?.forEach { it.delete() } val files = dir.listFiles { f -> f.extension == "json" } ?: return if (files.size <= maxEntries) return - files.sortedBy { it.lastModified() } - .take(files.size - maxEntries) - .forEach { it.delete() } + files.sortedBy { it.lastModified() }.take(files.size - maxEntries).forEach { it.delete() } } catch (t: Throwable) { - t.printStackTrace() + Diag.error("journal prune failed", t) } } + /** Every record, oldest first. Corrupt files are skipped. */ @Synchronized - @Suppress("SENSELESS_COMPARISON") // Gson can inject null into a non-null field - fun unacknowledged(): List = + fun unacknowledged(): List = (dir.listFiles { f -> f.extension == "json" } ?: emptyArray()) - .mapNotNull { f -> - runCatching { gson.fromJson(f.readText(), Entry::class.java) }.getOrNull() - } - // Gson bypasses the constructor, so a file missing a field yields null - // despite the non-null Kotlin type. Check every field JS relies on being - // present, not just eventId — an entry reaching JS with a null `type` - // would fall silently through a `switch (event.type)`. - .filter { it.eventId != null && it.uploadId != null && it.type != null } - .sortedBy { it.timestamp } + .mapNotNull { read(it) } + .sortedBy { it.at } + + @Synchronized + fun find(eventId: String): SettledRecord? = + if (isValidEventId(eventId)) read(fileFor(eventId)) else null + + @Synchronized + fun forEntry(id: String): List = unacknowledged().filter { it.id == id } + + /** + * One more delivery of [eventId]: rewrites the record with deliveries + 1 + * and returns it. Null when the record is gone. When the rewrite fails the + * incremented record is still returned, so the delivery goes ahead. + */ + @Synchronized + fun incrementDeliveries(eventId: String): SettledRecord? { + val record = find(eventId) ?: return null + val next = record.copy(deliveries = record.deliveries + 1) + try { + AtomicFiles.writeText(fileFor(eventId), gson.toJson(next)) + } catch (t: Throwable) { + Diag.error("journal deliveries update failed for $eventId", t) + } + return next + } + /** Idempotent. Unknown and malformed ids are ignored. */ @Synchronized fun ack(eventIds: List) { - eventIds.forEach { File(dir, "$it.json").delete() } + eventIds.filter { isValidEventId(it) }.forEach { fileFor(it).delete() } + } + + @Suppress("SENSELESS_COMPARISON", "USELESS_ELVIS") + private fun read(file: File): SettledRecord? { + if (!file.exists()) return null + val r = runCatching { gson.fromJson(file.readText(), SettledRecord::class.java) }.getOrNull() + ?: return null + // Gson does not run constructors. Check every field JS relies on. + if (r.eventId == null || r.id == null || r.key == null || r.kind == null || r.state == null) return null + return r.copy( + varsJson = r.varsJson ?: "null", + url = r.url ?: "", + method = r.method ?: "POST", + deliveries = r.deliveries.coerceAtLeast(0), + ) } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt index 46460e1e..68121854 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt @@ -1,48 +1,72 @@ package ai.openspace.backgroundupload -import android.content.Context import com.facebook.react.bridge.Arguments -// Sends live events to JS through the module's codegen event emitters. Terminal -// outcomes are journaled before they reach here, so when JS is absent (headless -// worker, mid-reload) dropping the live event costs nothing — the consumer picks -// it up from getUnacknowledgedEvents instead. -object EventReporter { - - // Journal first, then emit. The journal is the durable record; it survives - // when JS is dead. The live emit is best-effort. The two carry the identical - // payload. Thus a consumer can acknowledge a live event by its eventId. - fun journalAndEmit(context: Context, entry: EventJournal.Entry) { - EventJournal.get(context).append(entry) - emit(entry) +/** The events that queue transitions produce. [EventReporter] sends them to JS; tests record them. */ +interface QueueEvents { + fun state(row: RequestRow) + + /** The caller journaled [record] first. */ + fun settled(record: EventJournal.SettledRecord) + + /** + * Whether a live emit can reach a JS listener now. A record journaled + * while no listener is there (JS dead, or alive but not yet subscribed) + * starts at 0 deliveries, so its first real delivery (the replay) counts + * as 1. + */ + fun canDeliver(): Boolean +} + +/** + * Sends live events to JS through the module's codegen emitters. With no + * module (a headless worker, a reload) an event is dropped. Settled outcomes + * are journaled before they reach here, so a dropped one is replayed from + * the journal. + */ +object EventReporter : QueueEvents { + + private val throttle = ProgressThrottle { id, sent, total -> + val module = UploaderModule.instance ?: return@ProgressThrottle + module.emitProgress(Arguments.createMap().apply { + putString("id", id) + putDouble("bytesSent", sent.toDouble()) + putDouble("totalBytes", total.toDouble()) + }) } - // Emit a terminal event from its journal entry, so the live event carries the - // exact same payload (incl. eventId) as the journaled copy — letting a consumer - // ackEvents([eventId]) right after handling a live event, and keeping iOS/Android - // event shapes identical. - fun emit(entry: EventJournal.Entry) { + override fun state(row: RequestRow) { val module = UploaderModule.instance ?: return - val params = entry.toWritableMap() - when (entry.type) { - "completed" -> module.emitCompletedEvent(params) - "cancelled" -> module.emitCancelledEvent(params) - else -> module.emitErrorEvent(params) - } + module.emitState(JsonBridge.toWritableMap(row.toMap())) } - fun progress(uploadId: String, bytesSentTotal: Long, contentLength: Long) { + override fun settled(record: EventJournal.SettledRecord) { val module = UploaderModule.instance ?: return - module.emitProgressEvent(Arguments.createMap().apply { - putString("id", uploadId) - // Guard against a zero-byte file (contentLength == 0) producing NaN. - val pct = if (contentLength <= 0) 0.0 else bytesSentTotal.toDouble() * 100 / contentLength - putDouble("progress", pct) // 0-100 - }) + module.emitSettled(record.toWritableMap()) + } + + override fun canDeliver(): Boolean = UploaderModule.instance?.listening == true + + /** Moves the row's bytesSent in memory and emits through the throttle. */ + fun progress(id: String, sent: Long, total: Long) { + RequestIndex.shared.setBytes(id, sent) + throttle.offer(id, sent, total, isForeground()) + } + + fun flushProgress(id: String) = throttle.flush(id) + + fun dropProgress(id: String) = throttle.drop(id) + + fun attempt(event: AttemptEvent) { + val module = UploaderModule.instance ?: return + module.emitAttempt(event.toWritableMap()) } fun notification() { val module = UploaderModule.instance ?: return - module.emitNotificationEvent(Arguments.createMap()) + module.emitNotification(Arguments.createMap()) } + + private fun isForeground(): Boolean = + runCatching { UploaderModule.instance?.isForeground() == true }.getOrDefault(false) } diff --git a/android/src/main/java/ai/openspace/backgroundupload/JsonBridge.kt b/android/src/main/java/ai/openspace/backgroundupload/JsonBridge.kt new file mode 100644 index 00000000..a84b8661 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/JsonBridge.kt @@ -0,0 +1,176 @@ +package ai.openspace.backgroundupload + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive +import kotlin.math.abs +import kotlin.math.floor + +/** + * Moves values between three forms: the bridge (ReadableMap, WritableMap), + * plain Kotlin values (Map, List, String, Double, Boolean, null), and JSON + * text. `vars` and `data` are stored as JSON text. + * + * Numbers: RN gives every JS number to Kotlin as a Double. A Double with no + * fraction is written as an integer, so `{ n: 1 }` becomes `{"n":1}`, as + * JSON.stringify writes it, not `{"n":1.0}`. + * + * Key order: the bridge does not keep the JS key order. Object keys are + * written sorted, so the same object always gives the same text. The + * same-body check compares that text. + */ +object JsonBridge { + private val gson = GsonBuilder().serializeNulls().disableHtmlEscaping().create() + + // 2^53. Above this a Double can not hold every integer, so it keeps the + // Double form. + private const val MAX_SAFE_INTEGER = 9_007_199_254_740_992.0 + + fun fromReadable(map: ReadableMap): Map { + val out = LinkedHashMap() + val keys = map.keySetIterator() + while (keys.hasNextKey()) { + val key = keys.nextKey() + out[key] = valueOf(map, key) + } + return out + } + + fun fromReadableArray(array: ReadableArray): List = + (0 until array.size()).map { i -> + when (array.getType(i)) { + ReadableType.Null -> null + ReadableType.Boolean -> array.getBoolean(i) + ReadableType.Number -> array.getDouble(i) + ReadableType.String -> array.getString(i) + ReadableType.Map -> array.getMap(i)?.let { fromReadable(it) } + ReadableType.Array -> array.getArray(i)?.let { fromReadableArray(it) } + } + } + + /** The plain value at [key]. Null for an absent key. */ + fun valueOf(map: ReadableMap, key: String): Any? { + if (!map.hasKey(key)) return null + return when (map.getType(key)) { + ReadableType.Null -> null + ReadableType.Boolean -> map.getBoolean(key) + ReadableType.Number -> map.getDouble(key) + ReadableType.String -> map.getString(key) + ReadableType.Map -> map.getMap(key)?.let { fromReadable(it) } + ReadableType.Array -> map.getArray(key)?.let { fromReadableArray(it) } + } + } + + /** Plain values to JSON text. */ + fun toJson(value: Any?): String = gson.toJson(toElement(value)) + + /** JSON text to plain values. Throws on malformed text. Numbers come back as Double. */ + fun parse(json: String): Any? = fromElement(JsonParser.parseString(json)) + + /** A number as JSON would print it: an integer when it has no fraction. */ + fun numberText(d: Double): String = gson.toJson(number(d)) + + private fun number(d: Double): JsonPrimitive = + if (d.isFinite() && d == floor(d) && abs(d) < MAX_SAFE_INTEGER) JsonPrimitive(d.toLong()) + else JsonPrimitive(d) + + private fun toElement(value: Any?): JsonElement = when (value) { + null -> JsonNull.INSTANCE + is Boolean -> JsonPrimitive(value) + is Double -> number(value) + is Float -> number(value.toDouble()) + is Int, is Long, is Short, is Byte -> JsonPrimitive((value as Number).toLong()) + is Number -> JsonPrimitive(value) + is String -> JsonPrimitive(value) + is Map<*, *> -> JsonObject().apply { + value.entries + .sortedBy { it.key.toString() } + .forEach { (k, v) -> add(k.toString(), toElement(v)) } + } + is Iterable<*> -> JsonArray().apply { value.forEach { add(toElement(it)) } } + is Array<*> -> JsonArray().apply { value.forEach { add(toElement(it)) } } + else -> JsonPrimitive(value.toString()) + } + + private fun fromElement(element: JsonElement): Any? = when { + element.isJsonNull -> null + element.isJsonObject -> LinkedHashMap().apply { + element.asJsonObject.entrySet().forEach { (k, v) -> put(k, fromElement(v)) } + } + element.isJsonArray -> element.asJsonArray.map { fromElement(it) } + else -> { + val p = element.asJsonPrimitive + when { + p.isBoolean -> p.asBoolean + p.isNumber -> p.asDouble + else -> p.asString + } + } + } + + /** + * Plain values to the bridge. The factories default to the native ones. The + * JVM tests pass JavaOnlyMap and JavaOnlyArray, because the native ones need + * the React Native C++ library. + */ + fun toWritableMap( + map: Map, + newMap: () -> WritableMap = Arguments::createMap, + newArray: () -> WritableArray = Arguments::createArray, + ): WritableMap { + val out = newMap() + map.forEach { (k, v) -> putValue(out, k, v, newMap, newArray) } + return out + } + + fun toWritableArray( + list: List, + newMap: () -> WritableMap = Arguments::createMap, + newArray: () -> WritableArray = Arguments::createArray, + ): WritableArray { + val out = newArray() + list.forEach { v -> + when (v) { + null -> out.pushNull() + is Boolean -> out.pushBoolean(v) + is Number -> out.pushDouble(v.toDouble()) + is String -> out.pushString(v) + is Map<*, *> -> out.pushMap(toWritableMap(stringKeys(v), newMap, newArray)) + is List<*> -> out.pushArray(toWritableArray(v, newMap, newArray)) + else -> out.pushString(v.toString()) + } + } + return out + } + + fun putValue( + map: WritableMap, + key: String, + value: Any?, + newMap: () -> WritableMap = Arguments::createMap, + newArray: () -> WritableArray = Arguments::createArray, + ) { + when (value) { + null -> map.putNull(key) + is Boolean -> map.putBoolean(key, value) + is Number -> map.putDouble(key, value.toDouble()) + is String -> map.putString(key, value) + is Map<*, *> -> map.putMap(key, toWritableMap(stringKeys(value), newMap, newArray)) + is List<*> -> map.putArray(key, toWritableArray(value, newMap, newArray)) + else -> map.putString(key, value.toString()) + } + } + + private fun stringKeys(map: Map<*, *>): Map = + map.entries.associate { (k, v) -> k.toString() to v } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/LegacyImport.kt b/android/src/main/java/ai/openspace/backgroundupload/LegacyImport.kt new file mode 100644 index 00000000..d00e97c5 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/LegacyImport.kt @@ -0,0 +1,101 @@ +package ai.openspace.backgroundupload + +import android.content.Context +import com.google.gson.Gson +import java.io.File + +/** + * First v10 launch. Each v9 journal entry becomes a read-only settled row + * with key `legacy` and the v9 upload id. Nothing is delivered; the app + * reads the rows with getRequests() and cancels them. v9 chunked manifests + * are left in place: a same-id enqueue adopts them. + * + * The caller cancels the v9 WorkManager rows after this returns true. The + * order is safe: under v10 code a v9 row exits at once (it has no entry + * id), so nothing can write a v9 journal file after the import. + * + * Runs once, guarded by a marker file. A failed row save leaves the marker + * unwritten, so the next launch tries again. + */ +object LegacyImport { + const val MARKER = "v9-imported" + const val KEY = "legacy" + const val V9_JOURNAL_DIR = "rnbgupload-events" + + /** The v9 journal Entry, every field nullable: Gson reads whatever is there. */ + data class V9Entry( + val eventId: String?, + val uploadId: String?, + val type: String?, + val timestamp: Long?, + ) + + private val gson = Gson() + + /** Returns true when the import ran at this launch (no marker yet). */ + fun runOnce(context: Context, store: QueueStore): Boolean { + val marker = File(QueueStore.rootDir(context), MARKER) + if (marker.exists()) return false + val complete = import(File(context.filesDir, V9_JOURNAL_DIR), store) + if (complete) { + runCatching { AtomicFiles.writeText(marker, "1") } + .onFailure { Diag.error("could not write the v9 import marker", it) } + } + return true + } + + /** + * Imports every v9 journal entry in [v9Dir]. The newest entry per upload + * id wins. Returns false when a row could not be saved (its file is kept). + */ + internal fun import(v9Dir: File, store: QueueStore): Boolean { + val files = v9Dir.listFiles { f -> f.extension == "json" } ?: return true + val read = files.mapNotNull { f -> + val entry = runCatching { gson.fromJson(f.readText(), V9Entry::class.java) }.getOrNull() + if (entry == null) { + Diag.warn("v9 journal file unreadable, skipped: ${f.name}") + f.delete() + null + } else f to entry + } + var complete = true + read.groupBy { it.second.uploadId }.forEach { (uploadId, group) -> + val newest = group.maxByOrNull { it.second.timestamp ?: 0L }!!.second + val row = if (uploadId == null) null else legacyRow(newest) + val saved = when { + row == null -> true + store.load(row.id) != null -> true // a v10 entry already owns the id + else -> runCatching { store.save(row) }.isSuccess + } + if (saved) group.forEach { it.first.delete() } else complete = false + } + return complete + } + + internal fun legacyRow(entry: V9Entry): QueueEntry? { + val id = entry.uploadId ?: return null + val state = when (entry.type) { + "completed" -> EntryState.COMPLETED + "error" -> EntryState.ERROR + "cancelled" -> EntryState.CANCELLED + else -> return null + } + val at = entry.timestamp ?: 0L + return QueueEntry( + id = id, + key = KEY, + varsJson = "null", + descriptor = null, + body = null, + state = state, + attempts = 0, + bytesSent = 0, + totalBytes = 0, + expiresAt = at, + createdAt = at, + updatedAt = at, + generation = 1, + legacy = true, + ) + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/ProgressThrottle.kt b/android/src/main/java/ai/openspace/backgroundupload/ProgressThrottle.kt new file mode 100644 index 00000000..f4032d20 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/ProgressThrottle.kt @@ -0,0 +1,55 @@ +package ai.openspace.backgroundupload + +/** + * Limits progress events per id: at most one per second while the app is in + * the foreground, one per 10 minutes in the background. A value held back is + * kept as pending; [flush] sends it (the trailing edge on settle, park, + * release, and stop). + */ +class ProgressThrottle( + private val clock: () -> Long = System::currentTimeMillis, + private val emit: (id: String, sent: Long, total: Long) -> Unit, +) { + companion object { + const val FOREGROUND_MS = 1_000L + const val BACKGROUND_MS = 600_000L + } + + private class Slot(var lastEmitAt: Long?, var pending: Pair?) + + private val slots = HashMap() + + fun offer(id: String, sent: Long, total: Long, foreground: Boolean) { + val interval = if (foreground) FOREGROUND_MS else BACKGROUND_MS + val now = clock() + val send = synchronized(slots) { + val slot = slots.getOrPut(id) { Slot(null, null) } + val last = slot.lastEmitAt + if (last == null || now - last >= interval) { + slot.lastEmitAt = now + slot.pending = null + true + } else { + slot.pending = sent to total + false + } + } + if (send) emit(id, sent, total) + } + + /** Sends the held-back value once, if there is one. */ + fun flush(id: String) { + val pending = synchronized(slots) { + val slot = slots[id] ?: return + val p = slot.pending ?: return + slot.pending = null + slot.lastEmitAt = clock() + p + } + emit(id, pending.first, pending.second) + } + + fun drop(id: String) { + synchronized(slots) { slots.remove(id) } + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt b/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt new file mode 100644 index 00000000..810895d7 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt @@ -0,0 +1,422 @@ +package ai.openspace.backgroundupload + +import java.io.IOException +import java.util.UUID + +/** + * Every transition that JS causes: enqueue, pause, resume, cancel, + * setWifiOnly, updateHeaders, ack, and the boot sweep. [UploaderModule] calls + * it from one single-thread executor, so these calls never overlap each other. + * Workers change entries at the same time; every change here is inside the + * store lock, so each one is atomic against them. + * + * Order of a change: journal (when there is an outcome), store, work + * schedule, then events. Events go out after the store lock is released. + */ +class QueueController( + private val store: QueueStore, + private val journal: EventJournal, + private val settings: QueueSettingsStore, + private val events: QueueEvents, + private val scheduler: WorkScheduler, + private val isWorkerRunning: (id: String) -> Boolean = WorkerGate::isRunning, + private val clock: () -> Long = System::currentTimeMillis, +) { + + // MARK: - enqueue + + private class Enqueued(val entry: QueueEntry?, val reEmit: EventJournal.SettledRecord?) + + /** A body staged before the store lock was taken, and the generation it was staged for. */ + private class PreStaged(val generation: Int, val staged: BodyStaging.Staged) + + /** Test seam: runs between the staging outside the lock and the commit under it. */ + internal var afterPreStage: () -> Unit = {} + + /** + * Persists the entry and every staged byte, then schedules it. Returns the + * id. Throws [QueueException] with E_RUNNING, E_FILE_MISSING, E_STORAGE, or + * E_INVALID. + * + * A copied body is staged before the store lock when the decision can not + * change meanwhile (see [EnqueueRules.preStageGeneration]), so a large + * copy does not block every worker transition. Under the lock the + * decision is made again; a staged body that no longer fits is deleted + * and the body is staged again under the lock. + */ + fun enqueue(p: EntryParsing.Parsed): String { + val pre = preStage(p) + var preUsed = false + fun preFor(generation: Int): BodyStaging.Staged? = + pre?.takeIf { it.generation == generation }?.staged?.also { preUsed = true } + val result = try { + store.locked { decideAndCommit(p, ::preFor) } + } finally { + if (pre != null && !preUsed) discard(p.id, pre) + } + result.reEmit?.let { events.settled(it) } + result.entry?.let { entry -> + scheduleRun(entry) + events.state(entry.toRow()) + } + return p.id + } + + private fun preStage(p: EntryParsing.Parsed): PreStaged? { + val generation = store.locked { + val existing = store.load(p.id) + val v9 = if (existing == null) store.legacyManifest(p.id) else null + val action = EnqueueRules.decide(existing, v9, p.descriptor) { journal.find(it) != null } + EnqueueRules.preStageGeneration(existing, action, p.descriptor) + } ?: return null + val staged = stageOrThrow(p.descriptor, store.entryDir(p.id), generation) + afterPreStage() + return PreStaged(generation, staged) + } + + /** Deletes a pre-staged body that the commit did not use, unless the stored entry points at it. */ + private fun discard(id: String, pre: PreStaged) { + val file = pre.staged.body.fileName?.let { java.io.File(store.entryDir(id), it) } ?: return + val current = store.load(id) + if (current == null || store.bodyFile(current) != file) file.delete() + } + + /** Runs under the store lock. [preStaged] returns the body staged outside the lock for a generation, if any. */ + private fun decideAndCommit(p: EntryParsing.Parsed, preStaged: (generation: Int) -> BodyStaging.Staged?): Enqueued { + val existing = store.load(p.id) + val v9 = if (existing == null) store.legacyManifest(p.id) else null + val s = settings.load() + val now = clock() + val dir = store.entryDir(p.id) + return when (val action = EnqueueRules.decide(existing, v9, p.descriptor) { journal.find(it) != null }) { + EnqueueRules.Action.RejectRunning -> throw QueueException( + QueueException.E_RUNNING, + "entry '${p.id}' is running; a different body is accepted once it stops", + ) + is EnqueueRules.Action.ReEmit -> Enqueued(null, journal.incrementDeliveries(action.eventId)) + EnqueueRules.Action.Resume -> { + val next = EnqueueRules.resumed(existing!!, p, s.paused, s.headerGeneration, now) + saveOrThrow(next) + Enqueued(next, null) + } + EnqueueRules.Action.Create -> { + val staged = preStaged(1) ?: stageOrThrow(p.descriptor, dir, 1) + commit(EnqueueRules.created(p, staged, null, s.paused, s.headerGeneration, now)) + } + is EnqueueRules.Action.AdoptV9 -> { + val incoming = p.descriptor.parts + val parts = incoming?.let { EnqueueRules.adoptedParts(action.manifest, it) } + // The same parts resume over the v9 blob, as a same-body enqueue does. + val keepOwned = incoming != null && ChunkedParts.sameParts(action.manifest.parts, incoming) + val staged = stageOrThrow(p.descriptor.copy(parts = parts), dir, 1, store.blobFile(p.id), keepOwned) + commit(EnqueueRules.created(p, staged, parts, s.paused, s.headerGeneration, now)) + } + EnqueueRules.Action.Replace -> { + val old = existing!! + // Different parts: a present caller file wins; the old blob is the fallback. + val ownedBlob = if (old.body?.kind == StagedBody.CHUNKED) store.bodyFile(old) else null + val staged = preStaged(old.generation + 1) + ?: stageOrThrow(p.descriptor, dir, old.generation + 1, ownedBlob) + commit(EnqueueRules.replaced(old, p, staged, s.paused, s.headerGeneration, now)) + } + } + } + + private fun stageOrThrow( + d: Descriptor, + dir: java.io.File, + generation: Int, + ownedBlob: java.io.File? = null, + keepOwned: Boolean = false, + ): BodyStaging.Staged = + try { + BodyStaging.stage(d, dir, generation, ownedBlob, keepOwned) + } catch (e: QueueException) { + throw e + } catch (e: IOException) { + throw QueueException(QueueException.E_STORAGE, "could not stage the body: ${e.message}") + } + + /** Saves a newly staged entry. On failure the new body file is removed; the old entry stays as it was. */ + private fun commit(next: QueueEntry): Enqueued { + try { + store.save(next) + } catch (e: IOException) { + if (next.body?.kind != StagedBody.CHUNKED) store.bodyFile(next)?.delete() + throw QueueException(QueueException.E_STORAGE, "could not save the entry: ${e.message}") + } + store.pruneUnreferenced(next) + return Enqueued(next, null) + } + + private fun saveOrThrow(next: QueueEntry) { + try { + store.save(next) + } catch (e: IOException) { + throw QueueException(QueueException.E_STORAGE, "could not save the entry: ${e.message}") + } + } + + // MARK: - queue control + + /** Whole-queue pause. Live rows move to paused and their work stops. No outcome. */ + fun pause() { + settings.update { it.copy(paused = true) } + val now = clock() + val paused = transformAll { e -> + if (e.isLive && e.state != EntryState.PAUSED) EntryTransitions.toPaused(e, now) else e + } + paused.forEach { scheduler.cancel(it.id) } + paused.forEach { events.state(it.toRow()) } + } + + fun resume() { + val s = settings.update { it.copy(paused = false) } + val now = clock() + val resumed = transformAll { e -> + if (e.state == EntryState.PAUSED) EntryTransitions.toResumed(e, s.headerGeneration, now) else e + } + resumed.forEach { events.state(it.toRow()) } + // Every queued entry, not only the resumed ones: a run is idempotent. + store.all().forEach { scheduleRun(it) } + } + + /** + * Live: journal a 'cancelled' (user) outcome, then forget after its ack. + * Settled: forget now, row and bytes. Unknown: no-op. Unacked records of a + * forgotten entry are kept, so a handler that has not run yet still runs. + */ + fun cancel(id: String) { + val settled = store.locked { + val e = store.load(id) ?: return@locked null + if (!e.isLive || e.legacy) { + store.remove(id) + return@locked null + } + val now = clock() + val record = cancelledRecord(e, now) + journal.append(record) + val next = EntryTransitions.toSettled(e, EntryState.CANCELLED, record.eventId, e.bytesSent, now) + saveOrThrow(next) + next to record + } + scheduler.cancel(id) + settled?.let { (entry, record) -> + if (record.deliveries > 0) events.settled(record) + events.state(entry.toRow()) + } + } + + private fun cancelledRecord(e: QueueEntry, now: Long) = EventJournal.SettledRecord( + eventId = UUID.randomUUID().toString(), + id = e.id, + key = e.key, + varsJson = e.varsJson, + at = now, + attempts = e.attempts, + requestId = e.lastRequestId, + deliveries = if (events.canDeliver()) 1 else 0, + state = EntryState.CANCELLED.wire, + bytesSent = e.bytesSent, + totalBytes = e.totalBytes, + url = e.descriptor?.reportUrl ?: "", + method = e.descriptor?.method ?: "POST", + partIndex = null, + kind = EventJournal.KIND_CANCELLED, + response = null, + errorKind = null, + message = null, + cancelReason = "user", + generation = e.generation, + ) + + fun setWifiOnly(enabled: Boolean) { + settings.update { it.copy(wifiOnly = enabled) } + } + + fun configureRetry(defaults: RetryDefaults) { + settings.update { it.copy(retry = defaults) } + } + + /** + * Bumps the header generation, merges [patch] into every entry not yet + * forgotten, and requeues the parked ones. Workers compare each entry's + * own headerGeneration, which changes here under the store lock together + * with its headers. So a worker that gets a 401 either sees the patched + * entry and re-issues, or parks first and is requeued here. + */ + fun updateHeaders(patch: Map) { + val s = settings.update { it.copy(headerGeneration = it.headerGeneration + 1) } + val now = clock() + val unparkedIds = mutableSetOf() + val changed = transformAll { e -> + val patched = e.withHeadersPatched(patch, s.headerGeneration) + if (patched.state != EntryState.AWAITING_AUTH) return@transformAll patched + unparkedIds += e.id + EntryTransitions.toUnparked(patched, s.paused, now) + } + changed.filter { it.id in unparkedIds }.forEach { entry -> + scheduleRun(entry) + events.state(entry.toRow()) + } + } + + // MARK: - journal + + /** Every unacknowledged outcome, each counted as one more delivery. */ + fun unacknowledged(): List = + journal.unacknowledged().mapNotNull { journal.incrementDeliveries(it.eventId) } + + /** + * Removes the records. An acked completed or cancelled outcome of the + * entry's current life forgets the entry: row and bytes. An error keeps + * the row until cancel() or a same-id enqueue. Unknown ids are ignored. + * + * A record of the entry's current life on a live entry means the settle + * journaled and emitted, but its store write failed. The record is applied + * first, as the boot sweep would; without that, the sweep finds no record + * after this ack and runs the finished request again. When that save fails + * too, the record stays unacked so the sweep can apply it later. + */ + fun ack(eventIds: List) { + val forgotten = mutableListOf() + val repaired = mutableListOf() + store.locked { + eventIds.forEach { eventId -> + val record = journal.find(eventId) ?: return@forEach + var e = store.load(record.id) + if (e != null && e.isLive && !e.legacy && e.generation == record.generation) { + val next = EntryTransitions.toSettled(e, stateOf(record), record.eventId, record.bytesSent, clock()) + if (!trySave(next)) return@forEach + repaired += next + e = next + } + journal.ack(listOf(eventId)) + if (record.kind == EventJournal.KIND_ERROR || e == null) return@forEach + if (e.generation == record.generation && e.settledEventId == record.eventId) { + store.remove(record.id) + forgotten += record.id + } + } + } + forgotten.forEach { scheduler.cancel(it) } + repaired.filter { it.id !in forgotten }.forEach { events.state(it.toRow()) } + } + + private fun stateOf(record: EventJournal.SettledRecord): EntryState = + EntryState.values().firstOrNull { it.wire == record.state } ?: EntryState.ERROR + + // MARK: - boot sweep + + /** + * Repairs what a process death can leave, then schedules queued work. An + * entry whose worker runs in this process is skipped: that worker finishes + * its own transition. Run at module init, after [LegacyImport]. + * + * 1. A live entry with a journal record of its own generation: the process + * died between the journal append and the store transition. Apply it. + * 2. A running entry with no worker: a process death mid-run. Queue it. + * A live row that disagrees with the queue's paused setting (a death + * partway through pause() or resume()): make it agree. + * 3. A settled entry with records of its generation other than its own: + * orphans from a cancel race. Ack them. + * 4. A completed or cancelled entry whose own record is gone: the ack + * landed but the forget did not. Forget it. + * 5. Schedule every queued entry, and the expiry wake of every parked one. + */ + fun sweep() { + val now = clock() + val changed = mutableListOf() + val toForget = mutableListOf() + val toSchedule = mutableListOf() + val paused = settings.load().paused + store.locked { + val records = journal.unacknowledged().groupBy { it.id } + for (e in store.all()) { + if (e.legacy || isWorkerRunning(e.id)) continue + val own = records[e.id].orEmpty().filter { it.generation == e.generation } + if (e.isLive) { + val latest = own.maxByOrNull { it.at } + if (latest != null) { + val next = EntryTransitions.toSettled(e, stateOf(latest), latest.eventId, latest.bytesSent, now) + if (trySave(next)) { + journal.ack(own.filter { it !== latest }.map { it.eventId }) + changed += next + } + continue + } + var cur = e + if (e.state == EntryState.RUNNING) { + cur = EntryTransitions.toStopped(e, now) + } + // A process death partway through pause() or resume() leaves rows + // that disagree with the queue setting. + if (paused && cur.state != EntryState.PAUSED) { + cur = EntryTransitions.toPaused(cur, now) + } else if (!paused && cur.state == EntryState.PAUSED) { + cur = EntryTransitions.toResumed(cur, settings.load().headerGeneration, now) + } + if (cur !== e) { + if (trySave(cur)) changed += cur else continue + } + if (!paused || cur.state == EntryState.AWAITING_AUTH) toSchedule += cur + } else { + val orphans = own.filter { it.eventId != e.settledEventId } + if (orphans.isNotEmpty()) journal.ack(orphans.map { it.eventId }) + val forgettable = e.state == EntryState.COMPLETED || e.state == EntryState.CANCELLED + if (forgettable && own.none { it.eventId == e.settledEventId }) { + store.remove(e.id) + toForget += e.id + } + } + } + } + toForget.forEach { scheduler.cancel(it) } + toSchedule.forEach { scheduleRun(it, keepWake = true) } + changed.forEach { events.state(it.toRow()) } + } + + private fun trySave(entry: QueueEntry): Boolean = try { + store.save(entry) + true + } catch (e: IOException) { + Diag.error("could not save '${entry.id}'", e) + false + } + + // MARK: - helpers + + /** + * Runs [entry] when it is queued. A backoff longer than a worker waits goes + * to the wake; a parked entry gets a wake at its expiry, so it settles + * 'expired' on time. + */ + private fun scheduleRun(entry: QueueEntry, keepWake: Boolean = false) { + val now = clock() + when (entry.state) { + EntryState.QUEUED -> { + val at = entry.nextAttemptAt + if (at != null && at - now > RetryClassifier.IN_WORKER_BACKOFF_MAX_MS) { + scheduler.scheduleWake(entry, at, replace = !keepWake) + } else { + scheduler.schedule(entry) + } + } + EntryState.AWAITING_AUTH -> scheduler.scheduleWake(entry, entry.expiresAt, replace = !keepWake) + else -> Unit + } + } + + /** Applies [transform] to every non-legacy entry under the store lock. Returns the ones it changed. */ + private fun transformAll(transform: (QueueEntry) -> QueueEntry): List { + val changed = mutableListOf() + store.locked { + store.all().filter { !it.legacy }.forEach { e -> + store.compute(e.id) { cur -> + if (cur == null) null else transform(cur).also { if (it !== cur) changed += it } + } + } + } + return changed + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/QueueEntry.kt b/android/src/main/java/ai/openspace/backgroundupload/QueueEntry.kt new file mode 100644 index 00000000..7a5e7f2f --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/QueueEntry.kt @@ -0,0 +1,228 @@ +package ai.openspace.backgroundupload + +import com.google.gson.annotations.SerializedName + +/** The row states. [wire] is the RequestState string JS sees. */ +enum class EntryState(val wire: String) { + @SerializedName("queued") QUEUED("queued"), + @SerializedName("running") RUNNING("running"), + @SerializedName("awaiting-auth") AWAITING_AUTH("awaiting-auth"), + @SerializedName("paused") PAUSED("paused"), + @SerializedName("completed") COMPLETED("completed"), + @SerializedName("error") ERROR("error"), + @SerializedName("cancelled") CANCELLED("cancelled"); + + val isLive get() = this == QUEUED || this == RUNNING || this == AWAITING_AUTH || this == PAUSED +} + +/** One multipart field. [path] is the caller's file, read only at staging. */ +data class FormPart( + val name: String, + val contentType: String, + val string: String?, + val path: String?, + val fileName: String?, +) + +/** A request's `retry`, as a partial override of the configure() defaults. */ +data class RetryOverride( + val baseMs: Long?, + val maxMs: Long?, + val jitter: Double?, + val exempt: List?, +) + +/** What request(vars) returned, as native needs it to run. */ +data class Descriptor( + /** Null only with parts. */ + val url: String?, + val method: String, + /** The merged headers, plus the Content-Type that staging sets. */ + val headers: Map, + /** JSON text of `data`. It is also the bytes of the staged body. */ + val dataJson: String?, + val form: List?, + /** The caller's path, with any file:// prefix removed. */ + val file: String?, + /** The accepted flags live here. */ + val parts: List?, + val accept: List, + val retry: RetryOverride?, + val noNotification: Boolean, +) { + val bodyKind: String + get() = when { + parts != null -> StagedBody.CHUNKED + file != null -> StagedBody.FILE + form != null -> StagedBody.MULTIPART + dataJson != null -> StagedBody.JSON + else -> StagedBody.NONE + } + + /** The url to report for this request: the descriptor's, or the last part's. */ + val reportUrl: String get() = url ?: parts?.lastOrNull()?.url ?: "" +} + +/** + * Where the request body is on disk. [fileName] is relative to the entry + * directory, so a moved app data directory does not break it. + */ +data class StagedBody( + val kind: String, + val fileName: String?, + val boundary: String?, + val totalBytes: Long, +) { + companion object { + const val NONE = "none" + const val JSON = "json" + const val MULTIPART = "multipart" + const val FILE = "file" + const val CHUNKED = "chunked" + } +} + +/** One queue entry. [QueueStore] persists it as `entry.json` with Gson. */ +data class QueueEntry( + val id: String, + val key: String, + /** "null" for null vars. */ + val varsJson: String, + /** Null only for a legacy row. */ + val descriptor: Descriptor?, + /** Null only for a legacy row. */ + val body: StagedBody?, + val state: EntryState, + /** Attempts in this life. Chunked: across every part. */ + val attempts: Int, + val bytesSent: Long, + val totalBytes: Long, + val expiresAt: Long, + val createdAt: Long, + val updatedAt: Long, + /** Set while the entry waits out a backoff: queued for a long one, running for a short one. */ + val nextAttemptAt: Long? = null, + /** The consecutive transient failures before the last release. The next backoff continues from it. */ + val backoffStreak: Int = 0, + /** The settings header generation the headers were last merged at. */ + val headerGeneration: Int = 0, + /** Set while awaiting-auth (and kept under pause): the header generation it parked under. */ + val parkedGeneration: Int? = null, + /** +1 each time a settled entry reopens, and on a different-body replace. Journal records carry it. */ + val generation: Int = 1, + /** The journal record of this life's outcome. */ + val settledEventId: String? = null, + /** The X-Request-Id of the last attempt. */ + val lastRequestId: String? = null, + val legacy: Boolean = false, +) { + val isLive get() = state.isLive + val isSettled get() = !state.isLive + + fun toRow() = RequestRow( + id = id, + key = key, + varsJson = varsJson, + state = state.wire, + bytesSent = bytesSent, + totalBytes = totalBytes, + attempts = attempts, + updatedAt = updatedAt, + nextAttemptAt = nextAttemptAt, + createdAt = createdAt, + ) + + /** + * Whether [incoming] carries the same body. A different body kind, a + * different url or method, or different content is a different body. + * Chunked compares the parts (the path is ignored: the owned blob is the + * truth, as in v9). + */ + fun sameBodyAs(incoming: Descriptor): Boolean { + val stored = descriptor ?: return false + if (stored.bodyKind != incoming.bodyKind) return false + if (stored.method != incoming.method) return false + return when (stored.bodyKind) { + StagedBody.CHUNKED -> ChunkedParts.sameParts(stored.parts!!, incoming.parts!!) + StagedBody.FILE -> stored.url == incoming.url && stored.file == incoming.file + StagedBody.MULTIPART -> stored.url == incoming.url && stored.form == incoming.form + StagedBody.JSON -> stored.url == incoming.url && stored.dataJson == incoming.dataJson + else -> stored.url == incoming.url + } + } + + /** + * updateHeaders(): the patch replaces same-named headers (any case) and adds + * the rest. A part that carries its own copy of a patched header gets the + * new value too, because a stale per-part Authorization would shadow the + * fresh one. + */ + fun withHeadersPatched(patch: Map, generation: Int): QueueEntry { + val d = descriptor ?: return copy(headerGeneration = generation) + return copy( + descriptor = d.copy( + headers = HeaderMap.merge(d.headers, patch), + parts = d.parts?.map { part -> + val shared = patch.filterKeys { name -> HeaderMap.contains(part.headers, name) } + if (shared.isEmpty()) part else part.copy(headers = HeaderMap.merge(part.headers, shared)) + }, + ), + headerGeneration = generation, + ) + } +} + +/** One row of getRequests() and of a state event. */ +class RequestRow( + val id: String, + val key: String, + val varsJson: String, + val state: String, + val bytesSent: Long, + val totalBytes: Long, + val attempts: Int, + val updatedAt: Long, + val nextAttemptAt: Long?, + /** Sort order only; not sent to JS. */ + val createdAt: Long, +) { + /** vars parsed back to an object, once. A malformed text reads as null. */ + val vars: Any? by lazy { runCatching { JsonBridge.parse(varsJson) }.getOrNull() } + + fun withBytes(sent: Long) = RequestRow( + id, key, varsJson, state, sent, totalBytes, attempts, updatedAt, nextAttemptAt, createdAt, + ) + + /** The RequestRow shape. nextAttemptAt only when set. */ + fun toMap(): Map = LinkedHashMap().apply { + put("id", id) + put("key", key) + put("vars", vars) + put("state", state) + put("bytesSent", bytesSent.toDouble()) + put("totalBytes", totalBytes.toDouble()) + put("attempts", attempts.toDouble()) + put("updatedAt", updatedAt.toDouble()) + nextAttemptAt?.let { put("nextAttemptAt", it.toDouble()) } + } +} + +/** Header maps whose names match without regard to case. */ +object HeaderMap { + fun contains(headers: Map, name: String) = + headers.keys.any { it.equals(name, ignoreCase = true) } + + fun get(headers: Map, name: String): String? = + headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value + + /** [over] replaces same-named entries of [base] (any case); its spelling is kept. */ + fun merge(base: Map, over: Map): Map { + val out = LinkedHashMap() + base.forEach { (k, v) -> if (!contains(over, k)) out[k] = v } + out.putAll(over) + return out + } + + fun without(headers: Map, name: String): Map = + headers.filterKeys { !it.equals(name, ignoreCase = true) } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/QueueSettings.kt b/android/src/main/java/ai/openspace/backgroundupload/QueueSettings.kt new file mode 100644 index 00000000..1a364526 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/QueueSettings.kt @@ -0,0 +1,97 @@ +package ai.openspace.backgroundupload + +import android.content.Context +import com.google.gson.Gson +import java.io.File + +/** The configure() retry defaults. A request's `retry` overrides them field by field. */ +data class RetryDefaults( + val baseMs: Long = 1_000, + val maxMs: Long = 7_200_000, + val jitter: Double = 0.2, + val exempt: List = listOf(404), +) + +/** Queue-wide settings. They live next to the entries, in `settings.json`. */ +data class QueueSettings( + val wifiOnly: Boolean = false, + val paused: Boolean = false, + /** +1 per updateHeaders(). A 401 from an attempt sent under an older value re-issues at once. */ + val headerGeneration: Int = 0, + val retry: RetryDefaults = RetryDefaults(), +) + +/** + * Reads and writes [QueueSettings]. The value is cached after the first read. + * Workers read it before every attempt, so the cache matters. A corrupt file + * reads as the defaults. + */ +class QueueSettingsStore(private val file: File) { + + companion object { + private val gson = Gson() + + @Volatile + private var instance: QueueSettingsStore? = null + + fun get(context: Context): QueueSettingsStore = + instance ?: synchronized(this) { + instance ?: QueueSettingsStore(File(QueueStore.rootDir(context), "settings.json")) + .also { instance = it } + } + + /** configure().retry → defaults. Absent fields keep the library defaults. */ + fun retryDefaults(retry: Map?): RetryDefaults { + val d = RetryDefaults() + if (retry == null) return d + val backoff = retry["backoff"] as? Map<*, *> + val terminal = retry["terminalHttp"] as? Map<*, *> + return RetryDefaults( + baseMs = (backoff?.get("baseMs") as? Number)?.toLong() ?: d.baseMs, + maxMs = (backoff?.get("maxMs") as? Number)?.toLong() ?: d.maxMs, + jitter = (backoff?.get("jitter") as? Number)?.toDouble() ?: d.jitter, + exempt = (terminal?.get("exempt") as? List<*>)?.mapNotNull { (it as? Number)?.toInt() } + ?: d.exempt, + ) + } + } + + private var cached: QueueSettings? = null + + @Synchronized + fun load(): QueueSettings { + cached?.let { return it } + val read = if (file.exists()) { + runCatching { gson.fromJson(file.readText(), QueueSettings::class.java) }.getOrNull() + } else null + return validated(read).also { cached = it } + } + + /** Throws IOException when the write fails. The cache then keeps the old value. */ + @Synchronized + fun update(transform: (QueueSettings) -> QueueSettings): QueueSettings { + val next = transform(load()) + AtomicFiles.writeText(file, gson.toJson(next)) + cached = next + return next + } + + // Gson does not run constructors, so absent fields read as null or 0. + @Suppress("SENSELESS_COMPARISON", "USELESS_ELVIS") + private fun validated(s: QueueSettings?): QueueSettings { + if (s == null) return QueueSettings() + val d = RetryDefaults() + val r = s.retry + return QueueSettings( + wifiOnly = s.wifiOnly, + paused = s.paused, + headerGeneration = s.headerGeneration, + retry = if (r == null) d else RetryDefaults( + baseMs = if (r.baseMs > 0) r.baseMs else d.baseMs, + maxMs = if (r.maxMs > 0) r.maxMs else d.maxMs, + jitter = r.jitter, + exempt = r.exempt ?: d.exempt, + ), + ) + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/QueueStore.kt b/android/src/main/java/ai/openspace/backgroundupload/QueueStore.kt new file mode 100644 index 00000000..1beb0870 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/QueueStore.kt @@ -0,0 +1,215 @@ +package ai.openspace.backgroundupload + +import android.content.Context +import com.google.gson.Gson +import java.io.File +import java.io.IOException +import java.util.Base64 + +/** + * The durable queue: one directory per entry id. This is the v9 chunked + * manifest store, generalized in place. The root directory keeps its v9 name + * (`rnbgupload-chunked`) so v9 blobs and manifests are found without a move. + * + * A v10 directory holds `entry.json` and at most one staged body file (see + * [StagedBody.fileName]). A v9 directory holds `manifest.json` and `blob` + * until a same-id enqueue adopts it. + * + * Every write is tmp + fsync + rename ([AtomicFiles]). Bodies are staged + * before `entry.json` is saved, so an `entry.json` on disk means its body is + * durable. The store keeps [RequestIndex] current on every save and remove. + * It is reachable from a bare Context, because a worker can run in a process + * where React never started. + */ +class QueueStore(private val dir: File, private val index: RequestIndex = RequestIndex()) { + + companion object { + const val ENTRY_FILE = "entry.json" + const val V9_MANIFEST_FILE = "manifest.json" + const val BLOB_FILE = "blob" + + private val gson = Gson() + + @Volatile + private var instance: QueueStore? = null + + fun rootDir(context: Context) = File(context.filesDir, "rnbgupload-chunked") + + /** The process-wide store. The first call loads every row into [RequestIndex.shared]. */ + fun get(context: Context): QueueStore = + instance ?: synchronized(this) { + instance ?: QueueStore(rootDir(context), RequestIndex.shared) + .also { it.loadIndex() } + .also { instance = it } + } + } + + init { + dir.mkdirs() + } + + /** Rebuilds the index from disk. */ + @Synchronized + fun loadIndex() { + index.replaceAll(all().map { it.toRow() }) + } + + // Ids come from the caller and can hold path separators, so the directory + // name is an encoding of the id. The id is read back from the file. + fun entryDir(id: String) = + File(dir, Base64.getUrlEncoder().withoutPadding().encodeToString(id.toByteArray())) + + fun blobFile(id: String) = File(entryDir(id), BLOB_FILE) + + /** The staged body file of [entry], or null for a bodiless request. */ + fun bodyFile(entry: QueueEntry): File? = + entry.body?.fileName?.let { File(entryDir(entry.id), it) } + + private fun entryFile(id: String) = File(entryDir(id), ENTRY_FILE) + + /** Runs [block] under the store lock. For work that spans several store calls. */ + fun locked(block: () -> T): T = synchronized(this) { block() } + + @Synchronized + fun load(id: String): QueueEntry? = read(entryFile(id)) + + /** Throws IOException when the entry did not persist. */ + @Synchronized + fun save(entry: QueueEntry) { + AtomicFiles.writeText(entryFile(entry.id), gson.toJson(entry)) + index.put(entry.toRow()) + } + + /** + * An atomic read-modify-write. The lock spans load, [transform], and save, + * so nothing can write between them and be erased. A result that is the + * same object as the input writes nothing. A null result writes nothing: + * forgetting an entry is always an explicit [remove]. A throwing + * transform or a failed write propagates. + */ + @Synchronized + fun compute(id: String, transform: (QueueEntry?) -> QueueEntry?): QueueEntry? { + val current = load(id) + val next = transform(current) + if (next != null && next !== current) save(next) + return next + } + + /** Best effort, for a worker that can go on from memory: null when the entry is gone or the write failed. */ + @Synchronized + fun update(id: String, transform: (QueueEntry) -> QueueEntry): QueueEntry? = + runCatching { + val current = load(id) ?: return null + val next = transform(current) + if (next !== current) save(next) + next + }.getOrNull() + + /** + * Forgets the entry: row and bytes. `entry.json` goes first, so a partial + * delete never leaves a row that points at missing bytes. + */ + @Synchronized + fun remove(id: String) { + entryFile(id).delete() + entryDir(id).deleteRecursively() + index.remove(id) + } + + /** Every v10 entry. A directory with only a v9 manifest is not a row. */ + @Synchronized + fun all(): List = + (dir.listFiles { f -> f.isDirectory } ?: emptyArray()) + .mapNotNull { d -> File(d, ENTRY_FILE).takeIf { it.exists() }?.let { read(it) } } + + /** The v9 chunked manifest for [id], when the directory has no v10 entry. */ + @Synchronized + fun legacyManifest(id: String): LegacyManifest? { + if (entryFile(id).exists()) return null + val file = File(entryDir(id), V9_MANIFEST_FILE) + if (!file.exists()) return null + val parsed = runCatching { gson.fromJson(file.readText(), LegacyManifest::class.java) }.getOrNull() + return LegacyManifest.validated(parsed) + } + + /** + * Deletes every file in the entry directory that [entry] does not use: + * an older body, a v9 manifest it adopted, tmp files from a crash. + */ + @Synchronized + fun pruneUnreferenced(entry: QueueEntry) { + val keep = setOfNotNull(ENTRY_FILE, entry.body?.fileName) + entryDir(entry.id).listFiles()?.forEach { f -> + if (f.name !in keep) f.deleteRecursively() + } + } + + private fun read(file: File): QueueEntry? { + if (!file.exists()) return null + val parsed = try { + gson.fromJson(file.readText(), QueueEntry::class.java) + } catch (error: Throwable) { + Diag.warn("queue entry unreadable, skipped: ${file.parentFile?.name}", error) + return null + } + return validated(parsed).also { + if (it == null) Diag.warn("queue entry incomplete, skipped: ${file.parentFile?.name}") + } + } + + // Gson does not run constructors. A corrupt file, or one from an older + // build, can hold null in a non-null field. Reject what the engine relies + // on; normalize what has a safe default. + @Suppress("SENSELESS_COMPARISON", "USELESS_ELVIS") + private fun validated(e: QueueEntry?): QueueEntry? { + if (e == null || e.id == null || e.key == null || e.state == null) return null + if (!e.legacy && (e.descriptor == null || e.body == null || e.body.kind == null)) return null + val d = e.descriptor?.let { d -> + if (d.parts != null && d.parts.any { it == null || it.url == null }) return null + d.copy( + method = d.method ?: "POST", + headers = d.headers ?: emptyMap(), + accept = d.accept ?: emptyList(), + parts = d.parts?.map { if (it.headers == null) it.copy(headers = emptyMap()) else it }, + ) + } + return e.copy( + varsJson = e.varsJson ?: "null", + descriptor = d, + generation = if (e.generation <= 0) 1 else e.generation, + ) + } +} + +/** A v9 chunked manifest. The field names are the v9 ones. */ +data class LegacyManifest( + val id: String, + val sourcePath: String, + val parts: List, + val accept: List, + val expiresAt: Long, + val noNotification: Boolean, + val createdAt: Long, +) { + companion object { + @Suppress("SENSELESS_COMPARISON") + fun validated(m: LegacyManifest?): LegacyManifest? { + if (m == null || m.id == null || m.parts == null || m.parts.isEmpty()) return null + if (m.parts.any { it == null || it.url == null }) return null + return m.copy( + parts = m.parts.map { if (it.headers == null) it.copy(headers = emptyMap()) else it }, + accept = m.accept ?: emptyList(), + ) + } + } +} + +/** A rejection that reaches JS as `promise.reject(code, message)`. */ +class QueueException(val code: String, message: String) : IOException(message) { + companion object { + const val E_RUNNING = "E_RUNNING" + const val E_FILE_MISSING = "E_FILE_MISSING" + const val E_STORAGE = "E_STORAGE" + const val E_INVALID = "E_INVALID" + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/RequestIndex.kt b/android/src/main/java/ai/openspace/backgroundupload/RequestIndex.kt new file mode 100644 index 00000000..a59708be --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/RequestIndex.kt @@ -0,0 +1,49 @@ +package ai.openspace.backgroundupload + +import java.util.concurrent.ConcurrentHashMap + +/** + * The in-memory rows behind the synchronous getRequests(). [QueueStore] + * keeps it current: it calls [put] after every save and [remove] after every + * remove. Progress ticks move [RequestRow.bytesSent] here only. + */ +class RequestIndex { + companion object { + val shared = RequestIndex() + } + + private val rows = ConcurrentHashMap() + + fun replaceAll(all: Collection) { + rows.clear() + all.forEach { rows[it.id] = it } + } + + /** + * A save of a running entry keeps the larger bytesSent. The stored value + * lags the in-memory progress, and a save for an attempt must not move the + * row backwards. + */ + fun put(row: RequestRow) { + rows.compute(row.id) { _, old -> + if (old != null && old.state == row.state && row.state == EntryState.RUNNING.wire && + old.bytesSent > row.bytesSent && old.bytesSent <= row.totalBytes + ) row.withBytes(old.bytesSent) else row + } + } + + fun remove(id: String) { + rows.remove(id) + } + + fun get(id: String): RequestRow? = rows[id] + + /** Oldest first, then by id. */ + fun snapshot(): List = + rows.values.sortedWith(compareBy { it.createdAt }.thenBy { it.id }) + + /** A progress tick. A missing id is ignored. */ + fun setBytes(id: String, bytesSent: Long) { + rows.computeIfPresent(id) { _, row -> row.withBytes(bytesSent) } + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/RetryClassifier.kt b/android/src/main/java/ai/openspace/backgroundupload/RetryClassifier.kt new file mode 100644 index 00000000..cfd01781 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/RetryClassifier.kt @@ -0,0 +1,83 @@ +package ai.openspace.backgroundupload + +import java.io.IOException +import kotlin.math.min +import kotlin.random.Random + +/** + * The retry table (plan section 6.1), as pure functions. + * + * | Response or failure | Verdict | + * | 2xx, or an accept rule matches | Accepted | + * | 401, 403 | Auth (park) | + * | 408, 429, 5xx | Transient | + * | other 4xx in `exempt` (default [404]) | Transient | + * | other 4xx | Terminal http | + * | any other status (1xx, a final 3xx) | Terminal http | + * | IOException, payload file missing | Terminal file | + * | IOException | Transient | + * | anything else | Terminal unknown | + */ +object RetryClassifier { + + sealed class Verdict { + object Accepted : Verdict() + object Transient : Verdict() + object Auth : Verdict() + data class Terminal(val errorKind: String, val message: String) : Verdict() + } + + data class Policy(val baseMs: Long, val maxMs: Long, val jitter: Double, val exempt: List) + + /** A wait up to this long happens inside the worker. A longer one releases the worker. */ + const val IN_WORKER_BACKOFF_MAX_MS = 30_000L + + fun policy(defaults: RetryDefaults, override: RetryOverride?): Policy = Policy( + baseMs = override?.baseMs ?: defaults.baseMs, + maxMs = override?.maxMs ?: defaults.maxMs, + jitter = override?.jitter ?: defaults.jitter, + exempt = override?.exempt ?: defaults.exempt, + ) + + fun classifyResponse( + code: Int, + body: String?, + accept: List, + exempt: List, + ): Verdict = when { + UploadOutcome.isAccepted(code, body, accept) -> Verdict.Accepted + code == 401 || code == 403 -> Verdict.Auth + code == 408 || code == 429 || code in 500..599 -> Verdict.Transient + code in 400..499 && code in exempt -> Verdict.Transient + else -> Verdict.Terminal("http", "HTTP $code") + } + + /** A CancellationException is never classified; the caller rethrows it first. */ + fun classifyFailure(error: Throwable, fileExists: Boolean): Verdict = when { + error is IOException && !fileExists -> + Verdict.Terminal("file", "request body file is missing: ${error.message ?: error.javaClass.simpleName}") + error is IOException -> Verdict.Transient + else -> Verdict.Terminal("unknown", error.message ?: error.javaClass.simpleName) + } + + /** The live attempt's errorKind for a failure: network, file, or unknown. */ + fun failureKind(error: Throwable, fileExists: Boolean): String = + UploadOutcome.errorKind(error, fileExists) + + fun isExpired(now: Long, expiresAt: Long) = now >= expiresAt + + /** + * base * 2^(streak-1), capped at maxMs, then spread by ± jitter and capped + * again. streak 1 is baseMs. + */ + fun backoffMs(policy: Policy, streak: Int, random: Random = Random.Default): Long { + val exponent = (streak.coerceAtLeast(1) - 1).coerceAtMost(40) + val raw = min(policy.baseMs.toDouble() * Math.pow(2.0, exponent.toDouble()), policy.maxMs.toDouble()) + val jitter = policy.jitter.coerceIn(0.0, 1.0) + val spread = raw * (1.0 + jitter * (2.0 * random.nextDouble() - 1.0)) + return min(spread, policy.maxMs.toDouble()).toLong().coerceAtLeast(0L) + } + + /** The wake time, never later than expiresAt, so an entry expires on time. */ + fun nextAttemptAt(now: Long, backoffMs: Long, expiresAt: Long): Long = min(now + backoffMs, expiresAt) +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/Scheduler.kt b/android/src/main/java/ai/openspace/backgroundupload/Scheduler.kt new file mode 100644 index 00000000..872dd2a3 --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/Scheduler.kt @@ -0,0 +1,111 @@ +package ai.openspace.backgroundupload + +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.workDataOf +import java.util.concurrent.TimeUnit + +/** + * Starts and stops worker runs for entries. A run only carries the entry id; + * the worker reads everything else from the store. So an extra run is always + * harmless: it finds nothing to do and exits. + */ +interface WorkScheduler { + /** A run as soon as possible, on the entry's main chain. */ + fun schedule(entry: QueueEntry) + + /** + * A run at [at] under a second unique name. Used for long backoffs and for + * the expiry of a parked entry. [replace] false keeps a wake that already + * exists (the boot sweep). + */ + fun scheduleWake(entry: QueueEntry, at: Long, replace: Boolean) + + /** Cancels the main chain and the wake. */ + fun cancel(id: String) +} + +/** + * WorkManager unique work per entry id, APPEND_OR_REPLACE, as v9. + * + * Why APPEND_OR_REPLACE: a worker settles before doWork returns, so a same-id + * enqueue can arrive while the row is still RUNNING. KEEP would drop it and + * REPLACE would kill the running worker. APPEND runs it after; OR_REPLACE + * starts a fresh chain after a CANCELLED or FAILED one. Workers always return + * success, because WorkManager fails the dependents of a FAILED row without a + * run. + * + * Long waits do not go on the main chain. A delayed row there would hold + * back every later "run now" appended behind it. They use the wake name + * (`#wake`) with an initial delay instead. + * + * No WorkManager Constraints: connectivity and wifi-only are checked inside + * the worker, as v9, because the constraint path was unreliable. + */ +class WorkManagerScheduler(context: Context) : WorkScheduler { + companion object { + /** v9 rows carry the tag "RNFileUploader"; this one is new so the v9 cancel does not touch v10 work. */ + const val WORK_TAG = "RNFileUploader.v10" + const val ID_TAG_PREFIX = "RNFileUploaderId:" + /** A string literal, because WorkManager persists it across builds. */ + const val ENTRY_ID_KEY = "entryId" + const val V9_WORK_TAG = "RNFileUploader" + + fun wakeName(id: String) = "$id#wake" + + /** Milliseconds from [now] until [at]; never negative. */ + fun initialDelayMs(at: Long?, now: Long): Long = if (at == null) 0L else (at - now).coerceAtLeast(0L) + + /** An unfinished row that is not RUNNING: a queued run that did not start yet. */ + fun hasQueuedSuccessor(states: List): Boolean = + states.any { !it.isFinished && it != WorkInfo.State.RUNNING } + } + + private val workManager = WorkManager.getInstance(context) + + override fun schedule(entry: QueueEntry) { + // A queued successor already guarantees a run after the current one. + val states = workManager.getWorkInfosForUniqueWork(entry.id).get().map { it.state } + if (hasQueuedSuccessor(states)) return + workManager + .beginUniqueWork(entry.id, ExistingWorkPolicy.APPEND_OR_REPLACE, request(entry, 0L)) + .enqueue() + } + + override fun scheduleWake(entry: QueueEntry, at: Long, replace: Boolean) { + val delay = initialDelayMs(at, System.currentTimeMillis()) + workManager.enqueueUniqueWork( + wakeName(entry.id), + if (replace) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP, + request(entry, delay), + ) + } + + override fun cancel(id: String) { + workManager.cancelUniqueWork(id) + workManager.cancelUniqueWork(wakeName(id)) + } + + /** First v10 launch: the v9 rows. Their workers are gone. */ + fun cancelV9Work() { + workManager.cancelAllWorkByTag(V9_WORK_TAG) + } + + private fun request(entry: QueueEntry, delayMs: Long): OneTimeWorkRequest { + val builder = if (entry.body?.kind == StagedBody.CHUNKED) { + OneTimeWorkRequestBuilder() + } else { + OneTimeWorkRequestBuilder() + } + return builder + .addTag(WORK_TAG) + .addTag(ID_TAG_PREFIX + entry.id) + .setInputData(workDataOf(ENTRY_ID_KEY to entry.id)) + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .build() + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/Upload.kt b/android/src/main/java/ai/openspace/backgroundupload/Upload.kt deleted file mode 100644 index 3df469b0..00000000 --- a/android/src/main/java/ai/openspace/backgroundupload/Upload.kt +++ /dev/null @@ -1,107 +0,0 @@ -package ai.openspace.backgroundupload - -import com.facebook.react.bridge.ReadableArray -import com.facebook.react.bridge.ReadableMap -import java.util.UUID - -// Data model of a single upload -// Can be created from RN's ReadableMap -// Can be used for JSON deserialization -data class Upload( - val id: String, - val url: String, - val path: String, - val method: String, - val wifiOnly: Boolean, - // Non-2xx responses to treat as a successful completion (for example, a 409 - // whose body marks an expected duplicate). Every other non-2xx response is a - // terminal http error. The list is empty by default. - val accept: List, - val headers: Map, - /** - * Suppresses the progress notification for this upload. - * - * The notification is not decoration: posting one is what lets the worker run - * in foreground mode, which is how a long-running worker survives Doze and - * memory pressure. A suppressed upload is an ordinary background worker, so - * the OS may defer it or stop it mid-flight for WorkManager to re-run later. - * Suppress only payloads small enough that a restart costs nothing. - * - * An opt-out rather than an opt-in so that absence means "notify": this model - * is serialized into WorkManager's database, and a job enqueued by a build - * that predates the option can be replayed by a build that has it. - */ - val noNotification: Boolean, -) { - // v8 persisted `acceptStatus: List` where v9 persists `accept`. This is - // not a constructor parameter. It exists only so Gson can surface the legacy - // field to [normalized]. It is null, and thus never serialized, for every - // upload that this build creates. - private val acceptStatus: List? = null - - val showsNotification get() = !noNotification - - /** - * Gson does not use the constructor. Thus a WorkManager job that an older - * build enqueued can give this worker an object whose non-null fields are - * null. A v8 job carries `acceptStatus` and no `accept`. That NPEs the first - * time the worker touches [accept], after the file has fully transmitted, - * and the re-runs then re-send the whole file. This is the same - * normalize-after-fromJson pattern as ChunkedManifestStore.validated(): map - * the legacy statuses to rules, default what is absent, and give the worker - * an object that is safe to use. - */ - @Suppress("SENSELESS_COMPARISON", "USELESS_ELVIS") - fun normalized(): Upload = Upload( - id = id, - url = url, - path = path, - method = method ?: "POST", - wifiOnly = wifiOnly, - accept = accept - ?: acceptStatus?.map { UploadOutcome.AcceptRule(it) } - ?: emptyList(), - headers = headers ?: emptyMap(), - noNotification = noNotification, - ) - - class MissingOptionException(optionName: String) : - IllegalArgumentException("Missing '$optionName'") - - companion object { - fun fromReadableMap(map: ReadableMap) = Upload( - id = map.getString(Upload::id.name) ?: UUID.randomUUID().toString(), - url = map.getString(Upload::url.name) ?: throw MissingOptionException(Upload::url.name), - path = map.getString(Upload::path.name) ?: throw MissingOptionException(Upload::path.name), - method = map.getString(Upload::method.name) ?: "POST", - wifiOnly = if (map.hasKey(Upload::wifiOnly.name)) map.getBoolean(Upload::wifiOnly.name) else false, - accept = parseAcceptRules(map.getArray(Upload::accept.name)), - headers = parseHeaderMap(map.getMap(Upload::headers.name)), - // The notification text and identity are not per-upload options. The - // worker reads them from the NotificationConfig that configure() saved. - noNotification = if (map.hasKey(Upload::noNotification.name)) - map.getBoolean(Upload::noNotification.name) else false, - ) - } -} - -// Upload and ChunkedManifest share this: one accept-rules shape, one parser. -internal fun parseAcceptRules(arr: ReadableArray?): List { - if (arr == null) return listOf() - return (0 until arr.size()).mapNotNull { i -> - val rule = arr.getMap(i) ?: return@mapNotNull null - UploadOutcome.AcceptRule( - status = rule.getInt("status"), - bodyIncludes = if (rule.hasKey("bodyIncludes")) rule.getString("bodyIncludes") else null, - ) - } -} - -internal fun parseHeaderMap(headers: ReadableMap?): Map { - if (headers == null) return mapOf() - val map = mutableMapOf() - for (entry in headers.entryIterator) { - map[entry.key] = entry.value.toString() - } - return map -} diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt index f4fb2763..9c22321a 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt @@ -11,8 +11,7 @@ object UploadOutcome { * A non-2xx response to treat as success. `bodyIncludes` narrows the rule by * a response-body substring. This is necessary when one status has several * meanings, and only the message shows the difference (our backend's 409). - * Gson persists it inside [Upload] and [ChunkedManifest]; see - * consumer-rules.pro. + * Gson persists it inside [Descriptor]; see consumer-rules.pro. */ data class AcceptRule( val status: Int, diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt index 0e48da98..6d84b4f0 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt @@ -9,6 +9,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer import okio.BufferedSink @@ -19,7 +20,8 @@ import java.io.IOException import java.io.RandomAccessFile import kotlin.coroutines.resumeWithException -// Throttling interval of progress reports +// Throttling interval of the raw progress callback. ProgressThrottle limits +// the JS events above this. private const val PROGRESS_INTERVAL = 500 // milliseconds private const val RANGE_COPY_BUFFER = 64 * 1024 @@ -30,38 +32,65 @@ data class UploadResponse( val headers: Map ) -// make an upload request using okhttp -suspend fun okhttpUpload( +/** One request as the worker sends it. [body] is null only for GET and DELETE with no body. */ +data class TransferRequest( + val url: String, + val method: String, + val headers: Map, + val body: RequestBody?, +) + +/** Sends one request and reports bytes written. The headers are sent as they are. */ +suspend fun okhttpSend( client: OkHttpClient, - upload: Upload, - file: File, - onProgress: (Long) -> Unit + request: TransferRequest, + onProgress: (Long) -> Unit, ): UploadResponse { - val request = Request.Builder() - .url(upload.url) - .headers(upload.headers.toHeaders()) - .method(upload.method, withProgressListener(file.asRequestBody(), throttled(onProgress))) + val body = request.body?.let { withProgressListener(it, throttled(onProgress)) } + val built = Request.Builder() + .url(request.url) + .headers(request.headers.toHeaders()) + .method(request.method, body) .build() - return awaitResponse(client, request) + return awaitResponse(client, built) } +// Every body has a null content type, so OkHttp does not invent a +// Content-Type. The header on the request (the caller's, or the one staging +// set) is sent unchanged. + +/** A whole staged file. */ +fun fileBody(file: File): RequestBody = file.asRequestBody(null as MediaType?) + +/** A zero-length body for a POST, PUT, or PATCH with no body. OkHttp requires one. */ +fun emptyBody(): RequestBody = ByteArray(0).toRequestBody(null) + /** - * PUTs one byte range of the source file: a chunked part. It streams straight - * from disk, with no temporary chunk file. The headers are the consumer's, - * unchanged. The library adds nothing, per the design's protocol-as-data rule. + * The file bytes [start, end) as a request body: a chunked part. It streams + * from disk with no temporary chunk file. A RandomAccessFile is opened fresh + * on every writeTo, because OkHttp can replay a body (a connection-level + * retry), and a one-shot stream would then send truncated data. */ -suspend fun okhttpUploadPart( - client: OkHttpClient, - part: ChunkedManifest.Part, - file: File, - onProgress: (Long) -> Unit -): UploadResponse { - val request = Request.Builder() - .url(part.url) - .headers(part.headers.toHeaders()) - .put(withProgressListener(rangeRequestBody(file, part.start, part.end), throttled(onProgress))) - .build() - return awaitResponse(client, request) +fun rangeRequestBody(file: File, start: Long, end: Long): RequestBody = object : RequestBody() { + override fun contentType(): MediaType? = null + + override fun contentLength() = end - start + + override fun writeTo(sink: BufferedSink) { + RandomAccessFile(file, "r").use { raf -> + raf.seek(start) + val buffer = ByteArray(RANGE_COPY_BUFFER) + var remaining = end - start + while (remaining > 0L) { + val read = raf.read(buffer, 0, minOf(remaining, buffer.size.toLong()).toInt()) + if (read < 0) throw IOException( + "source file ended before part range [$start, $end): ${file.path}", + ) + sink.write(buffer, 0, read) + remaining -= read + } + } + } } private suspend fun awaitResponse(client: OkHttpClient, request: Request): UploadResponse = @@ -73,18 +102,21 @@ private suspend fun awaitResponse(client: OkHttpClient, request: Request): Uploa continuation.resumeWithException(e) override fun onResponse(call: Call, response: Response) { - val result = response.use { res -> // close the response asap - UploadResponse( - res.code, - // The body, unchanged: an empty body stays empty. A substituted - // HTTP reason phrase would make accept `bodyIncludes` rules match - // text that the server never sent. iOS also reports the body - // as-is. - res.body?.string().orEmpty(), - res.headers.toMultimap().mapValues { it.value.joinToString(", ") } - ) + val result = try { + response.use { res -> // close the response asap + UploadResponse( + res.code, + // The body unchanged: an empty body stays empty. A substituted + // reason phrase would make accept `bodyIncludes` rules match text + // the server never sent. + res.body?.string().orEmpty(), + res.headers.toMultimap().mapValues { it.value.joinToString(", ") } + ) + } + } catch (e: IOException) { + continuation.resumeWithException(e) + return } - continuation.resumeWith(Result.success(result)) } }) @@ -101,38 +133,7 @@ private fun throttled(onProgress: (Long) -> Unit): (Long) -> Unit { } } -/** - * Streams the file bytes [start, end) as a request body. A RandomAccessFile - * backs it, opened fresh on every writeTo call. OkHttp can replay a body (for - * example, after a connection-level retry), and a one-shot stream would then - * send truncated data silently. - */ -private fun rangeRequestBody(file: File, start: Long, end: Long) = object : RequestBody() { - // Null, so no Content-Type is invented. The consumer's header is already on - // the request, unchanged. - override fun contentType(): MediaType? = null - - override fun contentLength() = end - start - - override fun writeTo(sink: BufferedSink) { - RandomAccessFile(file, "r").use { raf -> - raf.seek(start) - val buffer = ByteArray(RANGE_COPY_BUFFER) - var remaining = end - start - while (remaining > 0L) { - val read = raf.read(buffer, 0, minOf(remaining, buffer.size.toLong()).toInt()) - if (read < 0) throw IOException( - "source file ended before part range [$start, $end): ${file.path}", - ) - sink.write(buffer, 0, read) - remaining -= read - } - } - } -} - -// create a request body that allows us to listen to progress. -// okhttp has no built-in way of reporting progress +// OkHttp has no built-in progress report, so the body counts bytes as it writes. private fun withProgressListener( body: RequestBody, onProgress: (Long) -> Unit diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt index 1f0c63e8..ceb1ee1c 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt @@ -1,286 +1,108 @@ package ai.openspace.backgroundupload -import android.app.NotificationManager import android.content.Context -import androidx.work.CoroutineWorker -import androidx.work.ForegroundInfo import androidx.work.WorkerParameters -import com.google.gson.Gson -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.withPermit +import okhttp3.RequestBody import java.io.File -import java.io.IOException -import java.net.UnknownHostException import java.util.UUID -import java.util.concurrent.TimeUnit -// Retry delay -private val RETRY_DELAY = TimeUnit.SECONDS.toMillis(10L) - -// The retry budget for errors that count (see checkRetry). A connectivity gap -// or flaky-network IO resets the budget. The retry policy is internal to the -// library. It is not an option. -private const val MAX_RETRIES = 5 - -class UploadWorker(private val context: Context, params: WorkerParameters) : - CoroutineWorker(context, params) { - - companion object { - /** - * Key for the serialized [Upload] in the worker's input data. - * - * A string literal on purpose. This key is persisted in WorkManager's - * database, so the build that runs a job may not be the build that enqueued - * it — a key derived from a symbol name (an enum constant, a property) breaks - * the moment R8 renames it or someone refactors, and the failure looks like - * "No Params" on a job that was queued perfectly well by the previous version. - */ - const val PARAMS_KEY = "params" - } - - private lateinit var upload: Upload - // configure() saved this. The worker can read it when WorkManager relaunched - // the worker with no JS. It is lazy, so the SharedPreferences read occurs on - // the worker's IO dispatcher, not at construction. - private val config by lazy { NotificationConfig.load(context) } - private var retries = 0 - private var connectivity = Connectivity.Ok - private val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - // Retrieve the upload. If this throws errors, error reporting won't work. - // However, the only way it has errors is the implementation is incorrect, - // which can be caught in development - val paramsJson = inputData.getString(PARAMS_KEY) ?: throw Throwable("No Params") - // normalized(): an older build can have enqueued this job, and its JSON - // shape can make non-null fields null (Gson does not use the constructor). - // See Upload.normalized. - upload = Gson().fromJson(paramsJson, Upload::class.java).normalized() - - // initialization, errors thrown here won't be retried - try { - // An upload that suppresses its notification cannot enter foreground mode, - // since the notification is the foreground service's own notification. - if (upload.showsNotification) { - // The foreground notification needs a channel to exist first, or posting - // it silently fails and setForeground can crash on newer Android. - ensureNotificationChannel(notificationManager, config) - // `setForeground` is recommended for long-running workers. - // Foreground mode helps prioritize the worker, reducing the risk - // of it being killed during low memory or Doze/App Standby situations. - // ⚠️ This should be called in the foreground - setForeground(getForegroundInfo()) - } - } catch (error: Throwable) { - if (!isForegroundStartDenied(error)) { - if (!checkAndHandleCancellation()) handleError(error) - throw error - } - // The app is in the background on API 31+ (see isForegroundStartDenied). - // Continue the upload without foreground priority. Do not fail an upload - // that can run. +/** The WorkManager class for a single-body entry. The run is [EntryWorker]'s. */ +class UploadWorker(context: Context, params: WorkerParameters) : EntryWorker(context, params) + +/** + * One request with one body: none, JSON, multipart, or a copied file. One + * attempt at a time, until a verdict ends it: + * accepted → Completed; auth → re-issue (newer headers) or park; + * transient → back off (short: here; long: release); terminal → Failed. + */ +internal class SimpleTransfer(private val host: EntryWorker) { + + suspend fun run(start: QueueEntry): Settlement { + val d0 = start.descriptor!! + val file = host.bodyFile(start) + // The payload probe: a staged body that is gone can never be sent. + if (file != null && !file.exists()) { + return Settlement.Failed("file", "the staged request body is missing", null, null, d0.reportUrl, d0.method) } + val total = start.body?.totalBytes ?: 0L + UploadProgress.add(host.entryId, total) + var streak = start.backoffStreak - - // Complex work, errors thrown below here trigger retry. - // We don't let WorkManager manage retries and network constraints as it's very buggy. - // i.e. we'd occasionally get BackgroundServiceStartNotAllowedException, - // or ForegroundServiceStartNotAllowedException, or "isStopped" gets set to "true" - // for no reason - var isRetried = false while (true) { - try { - // - "delay" should be within the "try" block to account for worker cancellation, - // which cancels the delay immediately and throws CancellationException. - // - Linear backoff instead of exponential. One reason for this is we retry on - // invalid connections. Exponential will take too long. - // - We retry only transport failures here (no response). An HTTP - // response, 4xx and 5xx included, is terminal at this layer. - // handleResponse classifies it (a 2xx or an accept rule -> completed, - // else an http error), and the worker returns without a retry. A - // response-code retry policy is the JS queue's job. This matches the - // iOS behavior. - if (isRetried) delay(RETRY_DELAY) - isRetried = true - - val response = upload() ?: continue - handleResponse(response) - return@withContext Result.success() - } catch (error: Throwable) { - if (checkAndHandleCancellation()) throw error - if (checkRetry(error)) continue - handleError(error) + val latest = host.ops.latest(host.entryId, host.generation) + if (RetryClassifier.isExpired(host.now(), latest.expiresAt)) throw EntryWorker.ExpiredException() + host.waitForNetwork() + + val requestId = UUID.randomUUID().toString() + val entry = host.ops.recordAttempt(host.entryId, host.generation, requestId) + // The generation of the headers this attempt sends: both come from the same entry. + val headerGeneration = entry.headerGeneration + val d = entry.descriptor!! + val url = d.url!! + val policy = host.policy(entry) + + val response = try { + transferSemaphore.withPermit { + okhttpSend( + uploadHttpClient, + TransferRequest(url, d.method, host.headersFor(d, null, requestId), requestBody(file, d.method)), + ) { sent -> host.reportProgress(sent, total) } + } + } catch (error: CancellationException) { throw error + } catch (error: Throwable) { + host.reportProgress(0L, total) + val fileExists = file == null || runCatching { file.exists() }.getOrDefault(true) + val message = error.message ?: error.javaClass.simpleName + EventReporter.attempt( + AttemptEvent.ofFailure( + entry, requestId, url, null, RetryClassifier.failureKind(error, fileExists), message, host.now(), + ), + ) + when (val verdict = RetryClassifier.classifyFailure(error, fileExists)) { + is RetryClassifier.Verdict.Terminal -> + return Settlement.Failed(verdict.errorKind, verdict.message, null, null, url, d.method) + else -> { + streak++ + host.backoffOrRelease(policy, streak, entry.expiresAt) + continue + } + } } - } - - // This should never happen. Only here to satisfy the type check - return@withContext Result.failure() - } - - private suspend fun upload(): UploadResponse? { - val file = File(upload.path) - val size = file.length() - - // Register progress asap so the total progress is accurate - // This needs to happen before the semaphore wait - UploadProgress.add(upload.id, size) - - // Don't bother to run on an invalid network - if (!validateAndReportConnectivity()) return null - - // wait for its turn to run - transferSemaphore.acquire() - - try { - return okhttpUpload(uploadHttpClient, upload, file) { progress -> - handleProgress(progress, size) - } - } catch (error: Throwable) { - // reset progress on error - UploadProgress.set(upload.id, 0L) - // pass the error to upper layer for retry decision - throw error - } finally { - transferSemaphore.release() - } - } - - private fun handleProgress(bytesSentTotal: Long, fileSize: Long) { - UploadProgress.set(upload.id, bytesSentTotal) - EventReporter.progress(upload.id, bytesSentTotal, fileSize) - updateNotification() - } - - // Redraws the progress notification. A no-op for a suppressed upload — the - // worker never posted one, and `notify` would create it outside foreground mode. - private fun updateNotification() { - if (!upload.showsNotification) return - notificationManager.notify( - config.systemNotificationId, - buildUploadNotification(context, config, connectivity), - ) - } - - // An HTTP response came back. It is "completed" only for a 2xx or a matching - // accept rule (axios validateStatus semantics: a 400 is an error, not a - // completion). Every other response is a terminal http error that carries the - // full response. In both cases the request finished, so the worker does not - // retry. - private fun handleResponse(response: UploadResponse) { - UploadProgress.complete(upload.id) - val accepted = UploadOutcome.isAccepted(response.code, response.body, upload.accept) - val (body, truncated) = EventJournal.capBody(response.body) - journalAndEmit( - EventJournal.Entry( - eventId = UUID.randomUUID().toString(), - uploadId = upload.id, - type = if (accepted) "completed" else "error", - timestamp = System.currentTimeMillis(), - responseCode = response.code, - responseBody = body, - responseBodyTruncated = truncated, - responseHeaders = response.headers, - errorKind = if (accepted) null else "http", - error = if (accepted) null else "HTTP ${response.code}", - ) - ) - } - - private fun handleError(error: Throwable) { - UploadProgress.remove(upload.id) - // Default fileExists=true so a failed existence probe reads as network, not file. - val fileExists = runCatching { File(upload.path).exists() }.getOrDefault(true) - journalAndEmit( - EventJournal.Entry( - eventId = UUID.randomUUID().toString(), - uploadId = upload.id, - type = "error", - timestamp = System.currentTimeMillis(), - error = error.message ?: "Unknown exception", - errorKind = UploadOutcome.errorKind(error, fileExists), - ) - ) - } - - // Check if cancelled by user or new worker with same ID - // Worker won't rerun, perform teardown - private fun checkAndHandleCancellation(): Boolean { - if (!isStopped) return false - UploadProgress.remove(upload.id) - - // Only a user cancel is terminal, so only a user cancel is journaled. - // - // WorkManager decides whether to reschedule BEFORE it stops the worker, and - // it ignores the Result we return. cancelUniqueWork marks the row CANCELLED - // first, so a user cancel is genuinely the end. A system stop — a - // foreground-service timeout, quota, or memory pressure — leaves the row - // RUNNING and WorkManager re-runs this same upload. Journaling a terminal - // `cancelled` there would durably tell JS the upload was dead while it was in - // fact about to be retried, so the consumer would settle the transfer and the - // retry would land as a duplicate on the server. - // - // Emitting nothing is the honest answer for a system stop: the upload is - // still in flight as far as anyone should be concerned. If WorkManager ever - // declines to reschedule, `getAllUploads()` is how a consumer notices. - if (!UserCancellations.consume(upload.id)) return true - - journalAndEmit( - EventJournal.Entry( - eventId = UUID.randomUUID().toString(), - uploadId = upload.id, - type = "cancelled", - timestamp = System.currentTimeMillis(), - cancelReason = "user", + val verdict = RetryClassifier.classifyResponse(response.code, response.body, d.accept, policy.exempt) + EventReporter.attempt( + AttemptEvent.ofResponse( + entry, requestId, url, null, response, verdict == RetryClassifier.Verdict.Accepted, host.now(), + ), ) - ) - return true - } - - private fun journalAndEmit(entry: EventJournal.Entry) = - EventReporter.journalAndEmit(context, entry) - - /** @return whether to retry */ - private fun checkRetry(error: Throwable): Boolean { - var unlimitedRetry = false - - // Error was thrown due to unmet network preferences. - // Also happens every time you switch from one network to any other - if (!validateAndReportConnectivity()) unlimitedRetry = true - // Due to the flaky nature of networking, sometimes the network is - // valid but the URL is still inaccessible, so keep waiting until - // the URL is accessible - else if (error is UnknownHostException) unlimitedRetry = true - // There are many IOExceptions that only differ by messages, - // so we can't check using class, but theoretically, - // only the one caused by file not existing should stop the retry. - // The rest should be related to flaky network or flaky file I/O, - // where we can retry without limit. - else if (error is IOException) { - try { - if (!File(upload.path).exists()) return false - unlimitedRetry = true - } catch (_: Throwable) { - // read file error, can't do anything but retry - unlimitedRetry = false + when (verdict) { + RetryClassifier.Verdict.Accepted -> return Settlement.Completed(response, url, d.method) + RetryClassifier.Verdict.Auth -> { + // updateHeaders() landed while this attempt was in flight: re-issue now. + if (host.ops.hasNewerHeaders(host.entryId, host.generation, headerGeneration)) { + streak = 0 + continue + } + throw EntryWorker.ParkException(headerGeneration) + } + RetryClassifier.Verdict.Transient -> { + host.reportProgress(0L, total) + streak++ + host.backoffOrRelease(policy, streak, entry.expiresAt) + } + is RetryClassifier.Verdict.Terminal -> + return Settlement.Failed("http", verdict.message, response, null, url, d.method) } } - - retries = if (unlimitedRetry) 0 else retries + 1 - return retries <= MAX_RETRIES } - // Checks connection and alerts connection issues - private fun validateAndReportConnectivity(): Boolean { - this.connectivity = validateConnectivity(context, upload.wifiOnly) - // alert connectivity mode - updateNotification() - return this.connectivity == Connectivity.Ok + // OkHttp needs a body for POST, PUT, and PATCH, and forbids one for GET. + private fun requestBody(file: File?, method: String): RequestBody? = when { + file != null -> fileBody(file) + method == "GET" || method == "DELETE" -> null + else -> emptyBody() } - - override suspend fun getForegroundInfo(): ForegroundInfo = - uploadForegroundInfo(config, buildUploadNotification(context, config, connectivity)) } diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index cbd26a85..34f6fd2c 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -1,11 +1,5 @@ package ai.openspace.backgroundupload -import android.util.Log -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.workDataOf import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -13,16 +7,21 @@ 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 com.facebook.react.common.LifecycleState +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit /** * TurboModule (New Architecture). [NativeRNFileUploaderSpec] is generated by * codegen from `src/NativeRNFileUploader.ts` into this same package (see - * `codegenConfig.android.javaPackageName` in package.json), so it needs no import. + * `codegenConfig.android.javaPackageName` in package.json). + * + * A thin shell over [QueueController]. Every promise method runs on one + * single-thread executor ([queueExecutor]), because enqueue copies files and + * every method touches the disk. The promise resolves from that executor. + * getRequests() is synchronous and reads the in-memory index only. */ class UploaderModule(context: ReactApplicationContext) : NativeRNFileUploaderSpec(context) { @@ -30,359 +29,189 @@ class UploaderModule(context: ReactApplicationContext) : companion object { const val NAME = "RNFileUploader" 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 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 - // whenever JS is absent (headless worker, mid-reload); terminal outcomes are - // journaled before being emitted, so a dropped live event is never lost. - // - // Volatile: written on the module-creation thread and read from the - // WorkManager worker, OkHttp callbacks and main, with no other barrier. + + /** The longest getRequests() waits for the first-launch import. */ + private const val IMPORT_WAIT_MS = 2_000L + + // The live module, so EventReporter can reach the codegen emitters: they + // are protected on the generated spec. Null whenever JS is absent + // (headless worker, mid-reload); outcomes are journaled before they are + // emitted, so a dropped live event is never lost. @Volatile var instance: UploaderModule? = null private set + + /** One thread for every module-side disk operation. It outlives a JS reload. */ + val queueExecutor: ExecutorService = Executors.newSingleThreadExecutor { r -> + Thread(r, "RNFileUploader.queue") + } } - private val workManager = WorkManager.getInstance(context) + private val store = QueueStore.get(context) + private val journal = EventJournal.get(context) + private val settings = QueueSettingsStore.get(context) + private val scheduler = WorkManagerScheduler(context) + private val controller = QueueController(store, journal, settings, EventReporter, scheduler) + + /** + * True once JS subscribed to onSettled. JS subscribes, then calls + * getUnacknowledgedEvents() at once, so that first call is the signal. + * Until then a settled emit reaches no listener, so it must not count as + * a delivery. Cleared on teardown. + */ + @Volatile + var listening = false + private set + + // The v9 import, then the v9 work cancel, then the boot sweep. + // getRequests() waits for the import only (file reads and row saves), so + // the first call after an upgrade already shows the legacy rows. The + // cancel opens the WorkManager database, so it runs after that wait. + private val importDone: Future init { instance = this + importDone = queueExecutor.submit { + runCatching { LegacyImport.runOnce(context, store) } + .onFailure { Diag.error("v9 import failed", it) } + .getOrDefault(true) // a failed import still cancels the v9 work + } + queueExecutor.execute { + if (runCatching { importDone.get() }.getOrDefault(false)) { + runCatching { scheduler.cancelV9Work() }.onFailure { Diag.error("v9 work cancel failed", it) } + } + runCatching { controller.sweep() }.onFailure { Diag.error("boot sweep failed", it) } + } } override fun invalidate() { - // A reload constructs the replacement before tearing this one down, so only - // clear the pointer when it still refers to us. + // A reload constructs the replacement before tearing this one down, so + // only clear the pointer when it still refers to us. + listening = false if (instance === this) instance = null super.invalidate() } override fun getName(): String = NAME + /** Picks the progress throttle interval: 1 s in the foreground, 10 min otherwise. */ + fun isForeground(): Boolean = reactApplicationContext.lifecycleState == LifecycleState.RESUMED // MARK: - Event emission (called by EventReporter) - // 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 emitState(params: WritableMap) = safeEmit { emitOnState(params) } - fun emitCompletedEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit + fun emitProgress(params: WritableMap) = safeEmit { emitOnProgress(params) } - fun emitErrorEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit + fun emitAttempt(params: WritableMap) = safeEmit { emitOnAttempt(params) } - fun emitCancelledEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit + fun emitSettled(params: WritableMap) = safeEmit { emitOnSettled(params) } - fun emitNotificationEvent(params: WritableMap) = safeEmit { emitOnNotification(params) } + fun emitNotification(params: WritableMap) = safeEmit { emitOnNotification(params) } private inline fun safeEmit(emit: () -> Unit) { try { emit() } catch (exc: NullPointerException) { - // The generated spec's emitter callback is only installed when the C++ - // TurboModule is constructed, and is gone once the runtime tears down, so a - // null callback is expected in both gaps. It is ALSO null for the whole - // process on the old architecture, where this module still registers and its - // methods work but no event can ever be delivered — hence warn, not debug, - // so that case is diagnosable instead of silent. - Log.w(TAG, "live event dropped (no event emitter — New Architecture required)") - } catch (exc: Throwable) { - // Anything else is a real bridging or payload failure worth seeing. - Log.e(TAG, "failed to emit live event", exc) - } - } - - - /** - * Returns terminal events (completed/error/cancelled) that JS has not yet - * acknowledged, including ones that fired while JS was dead. Read these on - * startup, process them, then call ackEvents to remove them. - */ - override fun getUnacknowledgedEvents(promise: Promise) { - try { - val events = EventJournal.get(reactApplicationContext).unacknowledged() - val arr = Arguments.createArray() - events.forEach { arr.pushMap(it.toWritableMap()) } - promise.resolve(arr) + // The emitter callback exists only while the C++ TurboModule does, so a + // null callback is expected before setup and after teardown. It is also + // null for the whole process on the old architecture, so warn. + Diag.warn("live event dropped (no event emitter; New Architecture required)") } catch (exc: Throwable) { - Log.e(TAG, exc.message, exc) - promise.reject(exc) + Diag.error("failed to emit live event", exc) } } - - /** - * Removes journaled events by eventId once JS has processed them. Resolves - * void. Idempotent: an unknown id is ignored. - */ - override fun ackEvents(ids: ReadableArray, promise: Promise) { - try { - val eventIds = (0 until ids.size()).mapNotNull { ids.getString(it) } - val journal = EventJournal.get(reactApplicationContext) - // An acknowledged 'completed' is the ONE moment when a chunked upload's - // manifest and moved bytes may be deleted. Every other terminal keeps - // them for a resume. Resolve which uploads those are before the entries - // are removed. - val completedUploadIds = journal.unacknowledged() - .filter { it.type == "completed" && eventIds.contains(it.eventId) } - .map { it.uploadId } - journal.ack(eventIds) - releaseAckedCompletions( - completedUploadIds, - ChunkedManifestStore.get(reactApplicationContext), - ) { id -> workManager.cancelUniqueWork(id) } - promise.resolve(null) - } catch (exc: Throwable) { - Log.e(TAG, exc.message, exc) - promise.reject(exc) + // MARK: - Promise methods + + /** Runs [block] on the queue executor and settles [promise] with its value or a coded rejection. */ + private fun onQueue(promise: Promise, block: () -> Any?) { + queueExecutor.execute { + try { + promise.resolve(block()) + } catch (e: QueueException) { + promise.reject(e.code, e.message, e) + } catch (e: EntryParsing.InvalidEntryException) { + promise.reject(QueueException.E_INVALID, e.message, e) + } catch (e: Throwable) { + Diag.error("queue operation failed", e) + promise.reject(QueueException.E_STORAGE, e.message ?: e.javaClass.simpleName, e) + } } } - - /** - * 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 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. The v10 `lifetimeMs` and `retry` fields ride along in the same - * map; slice 2 persists them next to the queue. + * Saves the notification configuration (read by headless workers) and the + * retry defaults. Each call replaces the full configuration. lifetimeMs is + * not stored: JS already applied it to expiresAt. */ override fun configure(options: ReadableMap) { NotificationConfig.save(reactApplicationContext, NotificationConfig.fromReadableMap(options)) + @Suppress("UNCHECKED_CAST") + val retry = JsonBridge.valueOf(options, "retry") as? Map + val defaults = QueueSettingsStore.retryDefaults(retry) + queueExecutor.execute { + runCatching { controller.configureRetry(defaults) } + .onFailure { Diag.error("could not save the retry defaults", it) } + } } + override fun enqueue(entry: ReadableMap, promise: Promise) { + // Parse on the calling thread: the ReadableMap belongs to the bridge call. + val parsed = try { + EntryParsing.parse(entry) + } catch (e: EntryParsing.InvalidEntryException) { + promise.reject(QueueException.E_INVALID, e.message, e) + return + } + onQueue(promise) { controller.enqueue(parsed) } + } - // 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 pause(promise: Promise) = onQueue(promise) { controller.pause(); null } - override fun updateHeaders(patch: ReadableMap, promise: Promise) = notImplemented(promise, "updateHeaders") + override fun resume(promise: Promise) = onQueue(promise) { controller.resume(); null } + override fun cancel(id: String, promise: Promise) = onQueue(promise) { controller.cancel(id); null } - // MARK: - v9 enqueue paths, kept for slice 2 to wire behind enqueue() + override fun setWifiOnly(enabled: Boolean, promise: Promise) = + onQueue(promise) { controller.setWifiOnly(enabled); null } - /** - * @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) - - // Clear any stale user-cancel mark for this (possibly reused) id - // from a prior life, so a later system stop of this fresh upload isn't - // misreported as a user cancel. Done here (before enqueue), never in the - // worker, so a real cancel arriving as the worker starts can't be erased. - UserCancellations.consume(upload.id) - - val request = OneTimeWorkRequestBuilder() - .addTag(WORKER_TAG) - .addTag(ID_TAG_PREFIX + upload.id) - .setInputData(workDataOf(UploadWorker.PARAMS_KEY to data)) - .build() - - workManager - // Using KEEP policy to prevent it from cancelling the work if it's already running. - // Otherwise, it will emit "cancelled" and then go on to emit "progress" events, - // which is confusing and quite difficult to manage. "cancelled" should be reserved for - // when the user explicitly cancels the upload. - .beginUniqueWork(upload.id, ExistingWorkPolicy.KEEP, request) - .enqueue() - - return upload.id + override fun updateHeaders(patch: ReadableMap, promise: Promise) { + val headers = try { + EntryParsing.headerPatch(patch) + } catch (e: EntryParsing.InvalidEntryException) { + promise.reject(QueueException.E_INVALID, e.message, e) + return + } + onQueue(promise) { controller.updateHeaders(headers); null } } + /** Synchronous. From the in-memory index, never from disk. */ + override fun getRequests(): WritableArray { + runCatching { importDone.get(IMPORT_WAIT_MS, TimeUnit.MILLISECONDS) } + val out = Arguments.createArray() + RequestIndex.shared.snapshot().forEach { out.pushMap(JsonBridge.toWritableMap(it.toMap())) } + return out + } /** - * Starts, or resumes, a chunked upload. It is idempotent against the durable - * [ChunkedManifest]. A first call takes ownership of the source file (an - * O(1) rename into the library's directory) and persists the manifest. A - * re-call with the same id reconciles instead: identical parts are required, - * the stored headers are replaced, and the accepted parts are skipped. Crash - * recovery, a resume after a stop, and a resume with fresh auth are all this - * same call. + * Every unacknowledged outcome, each counted as one more delivery. Sets + * [listening] first: an outcome settled from here on is emitted live with + * deliveries 1; one settled before it was journaled with 0, and this + * drain makes it 1. */ - @Suppress("unused") - private fun enqueueChunkedUpload(options: ReadableMap): String { - val store = ChunkedManifestStore.get(reactApplicationContext) - val id = options.getString("id") - ?: throw Upload.MissingOptionException("id") - val blob = store.blobFile(id) - val incoming = ChunkedManifest.fromReadableMap( - options, - sourcePath = blob.absolutePath, - createdAt = System.currentTimeMillis(), - ) - - // One atomic store operation, persisted BEFORE the work is enqueued. The - // manifest is what a worker relaunched with no JS runs from. The store - // lock spans load, reconcile, and save. Thus a running worker's - // markAccepted can never land between them and be erased. The running flag - // inside the lock is race-free too. A worker acquires ChunkedWorkerGate - // before its first manifest read. Thus it either registers first (and the - // recreate is rejected), or it reads the manifest that this call saved. - store.compute(id) { existing -> - if (existing == null) { - val path = options.getString("path") ?: throw Upload.MissingOptionException("path") - takeOwnership(File(path), blob) - incoming - } else { - // `path` is deliberately ignored here. When a manifest exists, the - // owned bytes are the source of truth. - existing.reconcile( - incoming, - running = ChunkedWorkerGate.isRunning(id), - blobSize = File(existing.sourcePath).length(), - ) - } - } - - // The stale-mark reasoning is the same as in enqueueUpload. - UserCancellations.consume(id) - - // A queued successor (an unfinished row that is not RUNNING) already - // guarantees a run after the current one finishes. An appended second run - // would only stack duplicate no-op runs. The manifest reconcile above - // still landed. That is how this call's fresh headers reach the queued - // run. - val states = workManager.getWorkInfosForUniqueWork(id).get().map { it.state } - if (hasQueuedSuccessor(states)) return id - - val request = OneTimeWorkRequestBuilder() - .addTag(WORKER_TAG) - .addTag(ID_TAG_PREFIX + id) - .setInputData(workDataOf(ChunkedUploadWorker.ID_KEY to id)) - .build() - - // APPEND_OR_REPLACE, not KEEP. A worker journals its terminal error before - // doWork returns. Thus a consumer that resumes from the error handler can - // arrive while that run's row is still RUNNING. KEEP would silently drop - // the resume, and nothing would ever run it. An append keeps the runs - // strictly sequential, and a trailing run over an already-settled manifest - // is a clean no-op (see ChunkedEngine.startAction). Appended work is a - // chain DEPENDENT: WorkManager marks the dependents of a failed - // prerequisite FAILED without a run. That is why ChunkedUploadWorker - // always returns Result.success(), even after it journals a terminal error - // (see terminalErrorResult). The OR_REPLACE half only rescues enqueues - // that arrive AFTER the chain already settled failed or cancelled: it - // starts a fresh sequence. A re-call while the worker runs still never - // restarts it. The running worker re-reads the stored manifest before - // every part attempt, so a resume's fresh headers reach it. - workManager - .beginUniqueWork(id, ExistingWorkPolicy.APPEND_OR_REPLACE, request) - .enqueue() - - 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 - // the blob path with no manifest. Adopt them. Do not fail the retry. - if (blob.exists()) return - throw IllegalArgumentException("chunked source file does not exist: ${source.path}") + override fun getUnacknowledgedEvents(promise: Promise) { + listening = true + onQueue(promise) { + val out = Arguments.createArray() + controller.unacknowledged().forEach { out.pushMap(it.toWritableMap()) } + out } - blob.parentFile?.mkdirs() - if (blob.exists()) blob.delete() - if (source.renameTo(blob)) return - // renameTo cannot cross filesystems. Files.move falls back to copy+delete. - Files.move(source.toPath(), blob.toPath(), StandardCopyOption.REPLACE_EXISTING) } -} -/** - * Releases the uploads whose 'completed' events were just acknowledged. That - * is the ONE moment when a chunked upload's manifest and moved bytes may be - * deleted. The allAccepted guard protects a recreate: the id may have been - * RECREATED (a different parts array under the same id) and run again over - * these bytes. An ack of the old life's completion must not cancel that work, - * and it must not delete the blob under it. Simple uploads have no manifest - * and fall through untouched. A cancel of their unique work could kill an - * unrelated new upload that reuses the id. - */ -internal fun releaseAckedCompletions( - uploadIds: List, - store: ChunkedManifestStore, - cancelWork: (String) -> Unit, -) { - uploadIds.forEach { id -> - val manifest = store.load(id) ?: return@forEach - if (!manifest.allAccepted) return@forEach - // Cancel a still-enqueued trailing run BEFORE the delete. A worker that - // starts after the delete finds nothing. It exits silently, but there is - // no reason to run it at all. - cancelWork(id) - store.remove(id) + /** Resolves void. Idempotent; unknown ids are ignored. */ + override fun ackEvents(ids: ReadableArray, promise: Promise) { + val eventIds = (0 until ids.size()).mapNotNull { runCatching { ids.getString(it) }.getOrNull() } + onQueue(promise) { controller.ack(eventIds); null } } } - -/** - * Whether cancelUpload must journal and emit the 'cancelled' event itself. - * That is the case only when NO row is RUNNING. A never-started row (ENQUEUED, - * or BLOCKED as an appended chain's dependent) has no worker to run a stop - * handler. A RUNNING worker's stop handler owns the report, including a worker - * that still waits on the ChunkedWorkerGate. - */ -internal fun cancelReportsFromModule(unfinishedStates: List): Boolean = - unfinishedStates.isNotEmpty() && unfinishedStates.none { it == WorkInfo.State.RUNNING } - -/** An unfinished row that is not RUNNING: a queued run that did not start yet. */ -internal fun hasQueuedSuccessor(states: List): Boolean = - states.any { !it.isFinished && it != WorkInfo.State.RUNNING } - -/** - * 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. - * The state is never "cancelled". iOS getAllUploads has no lingering cancelled - * rows (a cancelled task leaves the session). And on Android, a cancelled - * chunked upload keeps its manifest. Its truthful state is - * stalled-awaiting-resume, that is, "error". - */ -internal fun chunkedUploadState(states: List, allAccepted: Boolean): String = - when { - WorkInfo.State.RUNNING in states -> "running" - states.any { !it.isFinished } -> "pending" - allAccepted -> "completed" - else -> "error" - } - -/** - * One state for a simple upload id, from all its WorkInfo rows. An id can have - * a lingering finished chain next to a live one. A live row wins. Otherwise - * the most conclusive finished state wins. - */ -internal fun simpleUploadState(states: List): String = - when { - WorkInfo.State.RUNNING in states -> "running" - states.any { !it.isFinished } -> "pending" - WorkInfo.State.SUCCEEDED in states -> "completed" - WorkInfo.State.FAILED in states -> "error" - else -> "cancelled" - } diff --git a/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt b/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt deleted file mode 100644 index 5b19cea6..00000000 --- a/android/src/main/java/ai/openspace/backgroundupload/UserCancellations.kt +++ /dev/null @@ -1,17 +0,0 @@ -package ai.openspace.backgroundupload - -// Upload ids the JS side explicitly cancelled. Consulted by the worker to -// distinguish user cancels from system kills (WorkManager 2.8.1 has no -// getStopReason). Same-process only: a user cancel always originates from live -// JS, so the set never needs to persist across process death. -object UserCancellations { - private val ids = mutableSetOf() - - @Synchronized - fun mark(id: String) { - ids.add(id) - } - - @Synchronized - fun consume(id: String): Boolean = ids.remove(id) -} diff --git a/android/src/main/java/ai/openspace/backgroundupload/WorkerGate.kt b/android/src/main/java/ai/openspace/backgroundupload/WorkerGate.kt new file mode 100644 index 00000000..d1313aeb --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/WorkerGate.kt @@ -0,0 +1,32 @@ +package ai.openspace.backgroundupload + +import java.util.concurrent.ConcurrentHashMap + +/** + * At most one worker EXECUTES per entry id, process-wide (renamed from the + * v9 ChunkedWorkerGate; both workers use it now). + * + * The unique-work chain almost guarantees this, but not across a cancel: + * cancelUniqueWork marks the row CANCELLED at once while the old worker's + * coroutine still winds down, and the wake-up work runs under a second + * unique name. Two concurrent requests for one chunked part are unsafe on + * the server. A starting worker acquires its id here and a second one waits. + * + * Same-process only. A worker in a dead process holds nothing. + */ +object WorkerGate { + private val holders = ConcurrentHashMap() + + /** True when [token] now holds the id, or already held it. False while another token holds it. */ + fun tryAcquire(id: String, token: Any): Boolean { + val current = holders.putIfAbsent(id, token) + return current == null || current === token + } + + /** Releases only when [token] is the holder, so a late release can not evict a successor. */ + fun release(id: String, token: Any) { + holders.remove(id, token) + } + + fun isRunning(id: String): Boolean = holders.containsKey(id) +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/WorkerOps.kt b/android/src/main/java/ai/openspace/backgroundupload/WorkerOps.kt new file mode 100644 index 00000000..4f1fe2ef --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/WorkerOps.kt @@ -0,0 +1,271 @@ +package ai.openspace.backgroundupload + +import java.io.IOException +import java.util.UUID + +/** How a run ended. */ +sealed class Settlement { + abstract val url: String + abstract val method: String + + /** [response] is null for a chunked completion. */ + data class Completed( + val response: UploadResponse?, + override val url: String, + override val method: String, + ) : Settlement() + + data class Failed( + val errorKind: String, + val message: String, + val response: UploadResponse?, + val partIndex: Int?, + override val url: String, + override val method: String, + ) : Settlement() +} + +/** + * The entry was paused, cancelled, replaced, or forgotten under a running + * worker. The worker stops without a transition; the module owns what + * happened. Not a CancellationException: it must fail a chunked part's + * scope so the sibling parts stop too. + */ +class NotOwnedException(id: String) : Exception("entry '$id' is no longer owned by this run") + +/** + * Every transition the network causes: running, one attempt, part accepted, + * awaiting-auth, queued-with-backoff, a system stop, and the settle. Each is + * a `compute` guarded by the run's [generation], so a cancel, pause, or + * replace that landed first always wins. + */ +class WorkerOps( + private val store: QueueStore, + private val journal: EventJournal, + private val settings: QueueSettingsStore, + private val events: QueueEvents, + private val scheduler: WorkScheduler, + private val clock: () -> Long = System::currentTimeMillis, +) { + enum class ParkResult { PARKED, REISSUE, NOT_OWNED } + + /** Takes a queued entry. Null when there is nothing to run. */ + fun begin(id: String): QueueEntry? { + val now = clock() + var changed = false + val entry = store.compute(id) { e -> + if (e != null && !e.legacy && (e.state == EntryState.QUEUED || e.state == EntryState.RUNNING)) { + changed = true + EntryTransitions.toRunning(e, now) + } else e + } + if (entry == null || entry.state != EntryState.RUNNING) return null + if (changed) events.state(entry.toRow()) + return entry + } + + /** The stored entry, while this run still owns it. */ + fun latest(id: String, generation: Int): QueueEntry { + val e = store.load(id) + if (!EntryTransitions.isOwnedRun(e, generation)) throw NotOwnedException(id) + return e!! + } + + /** + * Write-ahead for one attempt: attempts + 1 and the X-Request-Id, persisted + * before the request is sent. Clears a short backoff's nextAttemptAt, and + * emits the row when it did. Returns the fresh entry, whose headers the + * attempt uses. + */ + fun recordAttempt(id: String, generation: Int, requestId: String): QueueEntry { + val now = clock() + var clearedBackoff = false + val next = store.compute(id) { e -> + if (EntryTransitions.isOwnedRun(e, generation)) { + clearedBackoff = e!!.nextAttemptAt != null + EntryTransitions.toAttempt(e, requestId, now) + } else e + } + if (next == null || next.lastRequestId != requestId || !EntryTransitions.isOwnedRun(next, generation)) { + throw NotOwnedException(id) + } + if (clearedBackoff) events.state(next.toRow()) + return next + } + + /** + * A short backoff the worker waits out in place: the row stays running + * and carries [nextAttemptAt]. Best effort; a lost write only hides the + * time. For a chunked entry, a sibling part's next attempt clears it. + */ + fun backingOff(id: String, generation: Int, nextAttemptAt: Long) { + val now = clock() + var applied = false + val next = store.update(id) { e -> + if (EntryTransitions.isOwnedRun(e, generation)) { + applied = true + EntryTransitions.toBackingOff(e, nextAttemptAt, now) + } else e + } + if (applied && next != null) events.state(next.toRow()) + } + + /** A chunked part the server accepted. Best effort, as v9: a lost flag only re-sends that part later. */ + fun markAccepted(id: String, generation: Int, index: Int): QueueEntry? = + store.update(id) { e -> + val parts = e.descriptor?.parts + if (e.generation != generation || parts == null || index !in parts.indices) e + else { + val next = ChunkedParts.withAccepted(parts, index) + e.copy( + descriptor = e.descriptor.copy(parts = next), + bytesSent = ChunkedParts.acceptedBytes(next), + backoffStreak = 0, + ) + } + } + + /** + * Journal, then transition, then emit. When a cancel or a replace landed + * first, the record is an orphan: it is acked at once and nothing is + * emitted. Returns whether this run's outcome stands. + */ + fun settle(id: String, generation: Int, s: Settlement): Boolean { + val e = store.load(id) + if (!EntryTransitions.canSettle(e, generation)) return false + e!! + val now = clock() + val completed = s is Settlement.Completed + val state = if (completed) EntryState.COMPLETED else EntryState.ERROR + val bytesSent = if (completed) e.totalBytes else e.bytesSent + val failed = s as? Settlement.Failed + val record = EventJournal.SettledRecord( + eventId = UUID.randomUUID().toString(), + id = e.id, + key = e.key, + varsJson = e.varsJson, + at = now, + attempts = e.attempts, + requestId = e.lastRequestId, + deliveries = if (events.canDeliver()) 1 else 0, + state = state.wire, + bytesSent = bytesSent, + totalBytes = e.totalBytes, + url = s.url, + method = s.method, + partIndex = failed?.partIndex, + kind = if (completed) EventJournal.KIND_COMPLETED else EventJournal.KIND_ERROR, + response = when (s) { + is Settlement.Completed -> s.response?.let { EventJournal.Response.of(it) } ?: EventJournal.Response.NONE + is Settlement.Failed -> s.response?.let { EventJournal.Response.of(it) } + }, + errorKind = failed?.errorKind, + message = failed?.message, + cancelReason = null, + generation = generation, + ) + // 1. The durable outcome. It never throws. + journal.append(record) + // JS can subscribe between the check above and the append, and its first + // drain can miss this record. Count it as a live delivery then. + val live = if (record.deliveries == 0 && events.canDeliver()) { + journal.incrementDeliveries(record.eventId) ?: record + } else record + // 2. The transition, atomic against cancel(). + var applied = false + val next = try { + store.compute(id) { cur -> + if (EntryTransitions.canSettle(cur, generation)) { + applied = true + EntryTransitions.toSettled(cur!!, state, record.eventId, bytesSent, now) + } else cur + } + } catch (error: IOException) { + // The record is durable; the boot sweep applies it to the entry. + Diag.error("settle could not save '$id'; its ack or the boot sweep repairs it", error) + if (live.deliveries > 0) events.settled(live) + return true + } + if (!applied || next == null) { + journal.ack(listOf(record.eventId)) + return false + } + // 3 and 4. Best effort. + if (live.deliveries > 0) events.settled(live) + events.state(next.toRow()) + return true + } + + /** + * Whether the stored entry holds newer headers than the ones an attempt + * sent. [headerGeneration] is the entry's own value from [recordAttempt], + * so it always belongs to the headers that went out. The settings value + * is not used: updateHeaders() bumps it before it patches the entries. + */ + fun hasNewerHeaders(id: String, generation: Int, headerGeneration: Int): Boolean = + latest(id, generation).headerGeneration > headerGeneration + + /** + * A 401/403. [headerGeneration] is the entry's value from [recordAttempt]. + * When the entry got newer headers since, the attempt re-issues at once + * instead of parking. The check is inside the store lock, and + * updateHeaders() patches entries inside it too, so it either patched + * this entry first (REISSUE) or finds it parked and requeues it. + */ + fun park(id: String, generation: Int, headerGeneration: Int): ParkResult { + val now = clock() + var result = ParkResult.NOT_OWNED + val next = store.compute(id) { e -> + when { + !EntryTransitions.isOwnedRun(e, generation) -> e + e!!.headerGeneration > headerGeneration -> { + result = ParkResult.REISSUE + e + } + else -> { + result = ParkResult.PARKED + EntryTransitions.toParked(e, headerGeneration, now) + } + } + } + if (result == ParkResult.PARKED && next != null) { + events.state(next.toRow()) + // A parked entry still expires on time. + scheduler.scheduleWake(next, next.expiresAt, replace = true) + } + return result + } + + /** A backoff longer than a worker waits: back to queued, woken at [nextAttemptAt]. */ + fun release(id: String, generation: Int, nextAttemptAt: Long, streak: Int): Boolean { + val now = clock() + var applied = false + val next = store.compute(id) { e -> + if (EntryTransitions.isOwnedRun(e, generation)) { + applied = true + EntryTransitions.toReleased(e!!, nextAttemptAt, streak, now) + } else e + } + if (!applied || next == null) return false + events.state(next.toRow()) + scheduler.scheduleWake(next, nextAttemptAt, replace = true) + return true + } + + /** A system stop. A paused or cancelled entry is the module's, so only a running one moves. Never journals. */ + fun stopped(id: String, generation: Int) { + val now = clock() + var applied = false + val next = runCatching { + store.compute(id) { e -> + if (EntryTransitions.isOwnedRun(e, generation)) { + applied = true + EntryTransitions.toStopped(e!!, now) + } else e + } + }.getOrNull() + if (applied && next != null) events.state(next.toRow()) + } + + fun settings(): QueueSettings = settings.load() +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/AckReleaseTest.kt b/android/src/test/java/ai/openspace/backgroundupload/AckReleaseTest.kt deleted file mode 100644 index 0afe1e88..00000000 --- a/android/src/test/java/ai/openspace/backgroundupload/AckReleaseTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package ai.openspace.backgroundupload - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder - -// An acknowledged 'completed' is the one moment when a chunked upload's stored -// state may be released. But only the completed life's state may go. A recreate -// under the same id can run over the same bytes, and it must survive the old -// life's ack. -class AckReleaseTest { - @get:Rule - val tmp = TemporaryFolder() - - private fun manifest(id: String, accepted: Boolean) = ChunkedManifest( - id = id, - sourcePath = "/data/blob", - parts = listOf( - ChunkedManifest.Part( - url = "https://example.com/1", - headers = emptyMap(), - start = 0, - end = 100, - accepted = accepted, - ), - ), - accept = emptyList(), - expiresAt = 5_000, - wifiOnly = false, - noNotification = false, - createdAt = 1_000, - ) - - @Test - fun `releases a completed upload's manifest and cancels its trailing runs`() { - val store = ChunkedManifestStore(tmp.newFolder()) - store.save(manifest("u1", accepted = true)) - val cancelled = mutableListOf() - releaseAckedCompletions(listOf("u1"), store) { cancelled.add(it) } - assertNull(store.load("u1")) - assertEquals(listOf("u1"), cancelled) - } - - @Test - fun `spares a recreate running under the same id`() { - // The acknowledged completion belongs to the id's PREVIOUS life. The - // manifest now holds a recreate's unaccepted parts, and a worker can be - // mid-transfer. A work cancel or a blob delete here would destroy its - // bytes. - val store = ChunkedManifestStore(tmp.newFolder()) - store.save(manifest("u1", accepted = false)) - val cancelled = mutableListOf() - releaseAckedCompletions(listOf("u1"), store) { cancelled.add(it) } - assertNotNull(store.load("u1")) - assertTrue(cancelled.isEmpty()) - } - - @Test - fun `ignores ids with no manifest (simple uploads)`() { - val store = ChunkedManifestStore(tmp.newFolder()) - val cancelled = mutableListOf() - releaseAckedCompletions(listOf("raw-upload"), store) { cancelled.add(it) } - assertTrue(cancelled.isEmpty()) - } -} diff --git a/android/src/test/java/ai/openspace/backgroundupload/BodyStagingTest.kt b/android/src/test/java/ai/openspace/backgroundupload/BodyStagingTest.kt new file mode 100644 index 00000000..216c69a6 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/BodyStagingTest.kt @@ -0,0 +1,207 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class BodyStagingTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun source(name: String, bytes: ByteArray) = File(tmp.newFolder(), name).apply { writeBytes(bytes) } + + @Test + fun `json bytes are the data text, with a default content type`() { + val dir = tmp.newFolder() + val staged = BodyStaging.stage(desc(dataJson = """{"n":1,"t":"é"}""", headers = mapOf()), dir, 1) + assertEquals(StagedBody.JSON, staged.body.kind) + assertEquals("body-1.json", staged.body.fileName) + assertEquals("""{"n":1,"t":"é"}""", File(dir, "body-1.json").readText()) + assertEquals(File(dir, "body-1.json").length(), staged.body.totalBytes) + assertEquals(mapOf("Content-Type" to "application/json"), staged.headers) + } + + @Test + fun `a caller content type wins for json, in any case`() { + val staged = BodyStaging.stage(desc(dataJson = "{}", headers = mapOf("content-type" to "application/vnd+json")), tmp.newFolder(), 1) + assertEquals(mapOf("content-type" to "application/vnd+json"), staged.headers) + } + + @Test + fun `multipart bytes follow RFC 7578`() { + val photo = source("photo.jpg", byteArrayOf(1, 2, 3)) + val form = listOf( + FormPart("meta", "application/json", "{\"a\":\"b\"}", null, null), + FormPart("pho\"to", "image/jpeg", null, photo.path, null), + FormPart("named", "image/jpeg", null, photo.path, "new\nname.jpg"), + ) + val target = File(tmp.newFolder(), "body.multipart") + BodyStaging.writeMultipart(form, "BOUND", target) + val expected = ("--BOUND\r\n" + + "Content-Disposition: form-data; name=\"meta\"\r\n" + + "Content-Type: application/json\r\n\r\n" + + "{\"a\":\"b\"}\r\n" + + "--BOUND\r\n" + + "Content-Disposition: form-data; name=\"pho%22to\"; filename=\"photo.jpg\"\r\n" + + "Content-Type: image/jpeg\r\n\r\n").toByteArray() + byteArrayOf(1, 2, 3) + ("\r\n" + + "--BOUND\r\n" + + "Content-Disposition: form-data; name=\"named\"; filename=\"new%0Aname.jpg\"\r\n" + + "Content-Type: image/jpeg\r\n\r\n").toByteArray() + byteArrayOf(1, 2, 3) + ("\r\n" + + "--BOUND--\r\n").toByteArray() + assertArrayEquals(expected, target.readBytes()) + } + + @Test + fun `form staging always sets the library content type with its boundary`() { + val dir = tmp.newFolder() + val staged = BodyStaging.stage( + desc(form = listOf(FormPart("a", "text/plain", "x", null, null)), headers = mapOf("CONTENT-TYPE" to "text/plain")), + dir, 2, + ) + val boundary = staged.body.boundary!! + assertTrue(boundary.startsWith("----RNBGU")) + assertEquals(mapOf("Content-Type" to "multipart/form-data; boundary=$boundary"), staged.headers) + assertTrue(File(dir, "body-2.multipart").readText().startsWith("--$boundary\r\n")) + } + + @Test + fun `a file body is copied and the source stays`() { + val src = source("a.bin", ByteArray(1000) { it.toByte() }) + val dir = tmp.newFolder() + val staged = BodyStaging.stage(desc(file = src.path), dir, 3) + assertTrue(src.exists()) + assertArrayEquals(src.readBytes(), File(dir, "file-3").readBytes()) + assertEquals(1000, staged.body.totalBytes) + assertEquals(mapOf("Authorization" to "Bearer old"), staged.headers) // no content type added + src.delete() + assertTrue(File(dir, "file-3").exists()) + } + + @Test + fun `a chunked file is moved and must tile the parts`() { + val src = source("video.bin", ByteArray(20)) + val dir = tmp.newFolder() + val staged = BodyStaging.stage(desc(url = null, file = src.path, parts = listOf(part(0, 10), part(10, 20))), dir, 1) + assertFalse(src.exists()) + assertEquals(20, File(dir, "blob").length()) + assertEquals(StagedBody.CHUNKED, staged.body.kind) + assertEquals(20, staged.body.totalBytes) + } + + @Test + fun `an orphan blob is adopted when the source is gone`() { + val dir = tmp.newFolder() + File(dir, "blob").writeBytes(ByteArray(20)) + val staged = BodyStaging.stage(desc(url = null, file = "/gone.bin", parts = listOf(part(0, 20))), dir, 1) + assertEquals(StagedBody.CHUNKED, staged.body.kind) + } + + @Test + fun `a tiling mismatch rejects E_INVALID before the move, so the source stays`() { + val src = source("video.bin", ByteArray(20)) + val dir = tmp.newFolder() + val e = assertThrows(QueueException::class.java) { + BodyStaging.stage(desc(url = null, file = src.path, parts = listOf(part(0, 15))), dir, 1) + } + assertEquals(QueueException.E_INVALID, e.code) + assertTrue(src.exists()) + assertFalse(File(dir, "blob").exists()) + } + + @Test + fun `keepOwned runs over the owned blob and ignores the path`() { + val dir = tmp.newFolder() + val owned = File(dir, "blob").apply { writeBytes(ByteArray(20)) } + val other = source("other.bin", ByteArray(5)) + val staged = BodyStaging.stage( + desc(url = null, file = other.path, parts = listOf(part(0, 20))), dir, 2, owned, keepOwned = true, + ) + assertTrue(other.exists()) + assertEquals("blob", staged.body.fileName) + assertEquals(20, owned.length()) + } + + @Test + fun `a present source wins over the owned blob and moves to this generation's name`() { + val dir = tmp.newFolder() + val owned = File(dir, "blob").apply { writeBytes(ByteArray(20)) } + val bytes = ByteArray(20) { (it + 1).toByte() } + val src = source("new.bin", bytes) + val staged = BodyStaging.stage(desc(url = null, file = src.path, parts = listOf(part(0, 5), part(5, 20))), dir, 3, owned) + assertEquals("blob-3", staged.body.fileName) + assertArrayEquals(bytes, File(dir, "blob-3").readBytes()) + assertFalse(src.exists()) + // The old entry's blob is untouched until the new entry is saved and prunes it. + assertArrayEquals(ByteArray(20), owned.readBytes()) + } + + @Test + fun `a present source of another size is checked against its own length`() { + val dir = tmp.newFolder() + val owned = File(dir, "blob").apply { writeBytes(ByteArray(20)) } + val src = source("new.bin", ByteArray(30)) + val staged = BodyStaging.stage(desc(url = null, file = src.path, parts = listOf(part(0, 30))), dir, 2, owned) + assertEquals("blob-2", staged.body.fileName) + assertEquals(30, staged.body.totalBytes) + assertEquals(20, owned.length()) + } + + @Test + fun `with the source gone, the owned blob is the fallback`() { + val dir = tmp.newFolder() + File(dir, "blob").writeBytes(ByteArray(20)) + val staged = BodyStaging.stage( + desc(url = null, file = "/gone.bin", parts = listOf(part(0, 5), part(5, 20))), dir, 2, File(dir, "blob"), + ) + assertEquals("blob", staged.body.fileName) + } + + @Test + fun `with the source gone, this generation's crash leftover wins over the owned blob`() { + val dir = tmp.newFolder() + File(dir, "blob").writeBytes(ByteArray(20)) + File(dir, "blob-2").writeBytes(ByteArray(30)) + val staged = BodyStaging.stage(desc(url = null, file = "/gone.bin", parts = listOf(part(0, 30))), dir, 2, File(dir, "blob")) + assertEquals("blob-2", staged.body.fileName) + } + + @Test + fun `a chunked body with no source and no blob rejects E_FILE_MISSING`() { + val e = assertThrows(QueueException::class.java) { + BodyStaging.stage(desc(url = null, file = "/gone.bin", parts = listOf(part(0, 20))), tmp.newFolder(), 2, null) + } + assertEquals(QueueException.E_FILE_MISSING, e.code) + } + + @Test + fun `a missing source rejects E_FILE_MISSING and writes nothing`() { + val dir = tmp.newFolder() + val form = listOf( + FormPart("a", "text/plain", "x", null, null), + FormPart("b", "image/jpeg", null, "/missing.jpg", null), + ) + val e = assertThrows(QueueException::class.java) { BodyStaging.stage(desc(form = form), dir, 1) } + assertEquals(QueueException.E_FILE_MISSING, e.code) + assertEquals(0, dir.list()!!.size) + val f = assertThrows(QueueException::class.java) { BodyStaging.stage(desc(file = "/missing.bin"), dir, 1) } + assertEquals(QueueException.E_FILE_MISSING, f.code) + val c = assertThrows(QueueException::class.java) { + BodyStaging.stage(desc(url = null, file = "/missing.bin", parts = listOf(part(0, 1))), dir, 1) + } + assertEquals(QueueException.E_FILE_MISSING, c.code) + } + + @Test + fun `no body stages nothing`() { + val dir = tmp.newFolder() + val staged = BodyStaging.stage(desc(method = "DELETE"), dir, 1) + assertEquals(StagedBody(StagedBody.NONE, null, null, 0), staged.body) + assertEquals(0, dir.list()!!.size) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/ChunkedEngineTest.kt b/android/src/test/java/ai/openspace/backgroundupload/ChunkedEngineTest.kt index 8c909d62..453bab91 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/ChunkedEngineTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/ChunkedEngineTest.kt @@ -11,8 +11,8 @@ import java.util.concurrent.ConcurrentHashMap class ChunkedEngineTest { // runBlocking is single-threaded, so the overlap is deterministic. Every - // executor suspends at yield(). Thus all launchable siblings start before any - // executor finishes. + // executor suspends at yield(), so all launchable siblings start before + // any executor finishes. private class Tracker { var inFlight = 0 var maxInFlight = 0 @@ -39,6 +39,7 @@ class ChunkedEngineTest { fun `never more than WINDOW parts in flight`() = runBlocking { val tracker = Tracker() ChunkedEngine.run((0 until 10).toList()) { tracker.execute(it) } + assertEquals(3, ChunkedEngine.WINDOW) assertEquals(ChunkedEngine.WINDOW, tracker.maxInFlight) } @@ -55,7 +56,7 @@ class ChunkedEngineTest { } @Test - fun `a terminal part failure propagates and cancels the remaining parts`() { + fun `a part failure propagates and cancels the remaining parts`() { val tracker = Tracker() val thrown = assertThrows(IllegalStateException::class.java) { runBlocking { @@ -70,112 +71,17 @@ class ChunkedEngineTest { } @Test - fun `backoff grows exponentially and caps`() { - assertEquals(1_000, ChunkedEngine.backoffMs(1)) - assertEquals(2_000, ChunkedEngine.backoffMs(2)) - assertEquals(4_000, ChunkedEngine.backoffMs(3)) - assertEquals(60_000, ChunkedEngine.backoffMs(7)) - assertEquals(60_000, ChunkedEngine.backoffMs(100)) - // Defensive: a nonsense attempt number must not shift into a huge delay. - assertEquals(1_000, ChunkedEngine.backoffMs(0)) - } - - // MARK: - startAction - - private fun manifest(vararg accepted: Boolean) = ChunkedManifest( - id = "u1", - sourcePath = "/data/blob", - parts = accepted.mapIndexed { i, a -> - ChunkedManifest.Part( - url = "https://example.com/part?n=$i", - headers = emptyMap(), - start = i * 100L, - end = (i + 1) * 100L, - accepted = a, - ) - }, - accept = emptyList(), - expiresAt = 5_000, - wifiOnly = false, - noNotification = false, - createdAt = 1_000, - ) - - @Test - fun `no manifest at start is a silent success, never a journaled error`() { - // A completed ack or removeUpload deleted the manifest while this run sat - // in the queue. That is a legitimate end, already settled. - assertEquals(ChunkedEngine.StartAction.NO_MANIFEST, ChunkedEngine.startAction(null)) - } - - @Test - fun `an all-accepted manifest re-reports completion instead of running`() { - assertEquals( - ChunkedEngine.StartAction.ALREADY_COMPLETE, - ChunkedEngine.startAction(manifest(true, true)), - ) - } - - @Test - fun `pending parts run the engine`() { - assertEquals(ChunkedEngine.StartAction.RUN, ChunkedEngine.startAction(manifest(true, false))) - } - - // MARK: - completionReport - - private fun completedEntry(uploadId: String) = EventJournal.Entry( - eventId = "e-$uploadId", - uploadId = uploadId, - type = "completed", - timestamp = 1, - ) - - @Test - fun `an unacked completed entry is re-emitted, never minted twice`() { - assertEquals( - ChunkedEngine.CompletionReport.ReEmit(completedEntry("u1")), - ChunkedEngine.completionReport(listOf(completedEntry("u1")), "u1", freshCompletion = false), - ) - assertEquals( - ChunkedEngine.CompletionReport.ReEmit(completedEntry("u1")), - ChunkedEngine.completionReport(listOf(completedEntry("u1")), "u1", freshCompletion = true), - ) - } - - @Test - fun `a fresh completion with nothing journaled mints a new entry`() { - assertEquals( - ChunkedEngine.CompletionReport.Mint, - ChunkedEngine.completionReport(emptyList(), "u1", freshCompletion = true), - ) - } - - @Test - fun `a trailing run over an acked completion reports nothing`() { - // The trailing run raced ackEvents. The journal entry is already gone, but - // the manifest still exists for a moment. An acknowledged completion means - // that nobody is owed an event. A minted event would be a duplicate - // 'completed' for an upload that the consumer already settled. - assertEquals( - ChunkedEngine.CompletionReport.None, - ChunkedEngine.completionReport(emptyList(), "u1", freshCompletion = false), - ) - } - - @Test - fun `another upload's completed entry does not satisfy the lookup`() { - assertEquals( - ChunkedEngine.CompletionReport.None, - ChunkedEngine.completionReport(listOf(completedEntry("other")), "u1", freshCompletion = false), - ) - } - - @Test - fun `only 5xx responses are transient`() { - assertTrue(ChunkedEngine.isTransientHttp(500)) - assertTrue(ChunkedEngine.isTransientHttp(599)) - for (code in listOf(400, 401, 403, 404, 409, 429, 499, 600)) { - assertEquals("code $code", false, ChunkedEngine.isTransientHttp(code)) + fun `a park from one part stops the siblings with the park itself`() { + // The worker needs the ParkException back, not a CancellationException. + val thrown = assertThrows(EntryWorker.ParkException::class.java) { + runBlocking { + ChunkedEngine.run((0 until 6).toList()) { index -> + yield() + if (index == 1) throw EntryWorker.ParkException(4) + yield() + } + } } + assertEquals(4, thrown.headerGeneration) } } diff --git a/android/src/test/java/ai/openspace/backgroundupload/ChunkedManifestTest.kt b/android/src/test/java/ai/openspace/backgroundupload/ChunkedManifestTest.kt deleted file mode 100644 index 798852ef..00000000 --- a/android/src/test/java/ai/openspace/backgroundupload/ChunkedManifestTest.kt +++ /dev/null @@ -1,384 +0,0 @@ -package ai.openspace.backgroundupload - -import ai.openspace.backgroundupload.UploadOutcome.AcceptRule -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -class ChunkedManifestTest { - @get:Rule - val tmp = TemporaryFolder() - - private fun manifest( - id: String = "u1", - parts: List = listOf( - part(0, 100), - part(100, 250), - ), - expiresAt: Long = 5_000, - ) = ChunkedManifest( - id = id, - sourcePath = "/data/blob", - parts = parts, - accept = listOf(AcceptRule(409, "already completed")), - expiresAt = expiresAt, - wifiOnly = false, - noNotification = false, - createdAt = 1_000, - ) - - private fun part(start: Long, end: Long, accepted: Boolean = false) = - ChunkedManifest.Part( - url = "https://example.com/part?start=$start", - headers = mapOf("Authorization" to "Bearer old"), - start = start, - end = end, - accepted = accepted, - ) - - // MARK: - Model - - @Test - fun `byte math is range-based`() { - val m = manifest(parts = listOf(part(0, 100, accepted = true), part(100, 250))) - assertEquals(250, m.totalBytes) - assertEquals(100, m.acceptedBytes) - } - - @Test - fun `completed only when every part is accepted`() { - val none = manifest() - assertFalse(none.allAccepted) - val partial = none.withPartAccepted(0) - assertFalse(partial.allAccepted) - val all = partial.withPartAccepted(1) - assertTrue(all.allAccepted) - assertEquals(emptyList(), all.pendingIndexes()) - assertEquals(listOf(1), partial.pendingIndexes()) - } - - @Test - fun `expiry is inclusive of the deadline`() { - val m = manifest(expiresAt = 5_000) - assertFalse(m.isExpired(4_999)) - assertTrue(m.isExpired(5_000)) - assertTrue(m.isExpired(5_001)) - } - - // MARK: - Reconcile: resume (same parts array) - - private val blobSize = 250L - - @Test - fun `resume replaces headers and deadline, keeps accepted parts and the moved source`() { - val stored = manifest().withPartAccepted(0) - val fresh = manifest(expiresAt = 99_000).copy( - sourcePath = "/ignored/by/reconcile", - createdAt = 42, - accept = listOf(AcceptRule(208)), - wifiOnly = true, - parts = stored.parts.map { it.copy(headers = mapOf("Authorization" to "Bearer new"), accepted = false) }, - ) - - val merged = stored.reconcile(fresh, running = false, blobSize = blobSize) - - assertEquals("Bearer new", merged.parts[0].headers["Authorization"]) - assertEquals(99_000, merged.expiresAt) - assertEquals(listOf(AcceptRule(208)), merged.accept) - assertTrue(merged.wifiOnly) - // Accepted statuses, ownership, and identity survive from the stored copy. - assertTrue(merged.parts[0].accepted) - assertFalse(merged.parts[1].accepted) - assertEquals("/data/blob", merged.sourcePath) - assertEquals(1_000, merged.createdAt) - } - - @Test - fun `resume is allowed while the upload is running`() { - // Fresh auth must reach a running worker's stalled parts. - val stored = manifest().withPartAccepted(0) - val fresh = manifest().copy( - parts = stored.parts.map { it.copy(headers = mapOf("Authorization" to "Bearer new"), accepted = false) }, - ) - val merged = stored.reconcile(fresh, running = true, blobSize = blobSize) - assertTrue(merged.parts[0].accepted) - assertEquals("Bearer new", merged.parts[1].headers["Authorization"]) - } - - @Test - fun `resume matches the same parts authored in a different order`() { - // Identical tiles, reordered, are the SAME upload: a resume, never a - // recreate (running = true would reject a recreate). Accepted flags follow - // the range, not the array index. - val stored = manifest().withPartAccepted(0) - val fresh = manifest().copy( - parts = listOf(stored.parts[1], stored.parts[0]).map { - it.copy(headers = mapOf("Authorization" to "Bearer new"), accepted = false) - }, - ) - val merged = stored.reconcile(fresh, running = true, blobSize = blobSize) - assertTrue(merged.parts.first { it.start == 0L }.accepted) - assertFalse(merged.parts.first { it.start == 100L }.accepted) - assertEquals("Bearer new", merged.parts[0].headers["Authorization"]) - } - - // MARK: - Reconcile: recreate (different parts array) - - @Test - fun `recreate from a stalled upload replaces parts and resets every status`() { - // The consumer re-authored under a fresh server uploadId: new urls, a new - // split, and fresh headers, accept, and expiresAt. The owned bytes stay. - val stored = manifest().withPartAccepted(0) - val fresh = manifest( - parts = listOf( - part(0, 120).copy(url = "https://example.com/v2?part=1"), - part(120, 250).copy(url = "https://example.com/v2?part=2"), - ), - expiresAt = 99_000, - ).copy(sourcePath = "/ignored/by/reconcile", createdAt = 42, accept = listOf(AcceptRule(208))) - - val recreated = stored.reconcile(fresh, running = false, blobSize = blobSize) - - assertTrue(recreated.parts.none { it.accepted }) - assertEquals(listOf("https://example.com/v2?part=1", "https://example.com/v2?part=2"), recreated.parts.map { it.url }) - assertEquals(99_000, recreated.expiresAt) - assertEquals(listOf(AcceptRule(208)), recreated.accept) - // Ownership survives. The blob is reused for the full re-upload. - assertEquals("/data/blob", recreated.sourcePath) - assertEquals(1_000, recreated.createdAt) - } - - @Test - fun `recreate with the same ranges but new urls also resets statuses`() { - // New part urls embed a new server uploadId, even when the split is - // identical. Nothing sent under the old id counts for the new one. - val stored = manifest().withPartAccepted(0) - val fresh = manifest( - parts = listOf( - part(0, 100).copy(url = "https://example.com/v2?part=1"), - part(100, 250).copy(url = "https://example.com/v2?part=2"), - ), - ) - val recreated = stored.reconcile(fresh, running = false, blobSize = blobSize) - assertTrue(recreated.parts.none { it.accepted }) - } - - @Test - fun `recreate is rejected while the upload is running`() { - val stored = manifest() - val fresh = manifest(parts = listOf(part(0, 250).copy(url = "https://example.com/v2"))) - assertThrows(ChunkedManifest.ReconcileException::class.java) { - stored.reconcile(fresh, running = true, blobSize = blobSize) - } - } - - @Test - fun `recreate rejects parts that do not tile the blob exactly`() { - val stored = manifest() - for ( - bad in listOf( - listOf(part(0, 100), part(150, 250)), // gap - listOf(part(0, 150), part(100, 250)), // overlap - listOf(part(50, 250)), // does not start at 0 - listOf(part(0, 200)), // short of the blob size - listOf(part(0, 100), part(100, 251)), // past the blob size - ) - ) { - assertThrows(ChunkedManifest.ReconcileException::class.java) { - stored.reconcile(manifest(parts = bad), running = false, blobSize = blobSize) - } - } - } - - @Test - fun `recreate accepts parts authored in any order`() { - val stored = manifest() - val fresh = manifest(parts = listOf(part(100, 250), part(0, 100)).map { it.copy(url = it.url + "&v=2") }) - val recreated = stored.reconcile(fresh, running = false, blobSize = blobSize) - assertEquals(2, recreated.parts.size) - } - - @Test - fun `tilesExactly covers the edge shapes`() { - assertTrue(ChunkedManifest.tilesExactly(listOf(part(0, 250)), 250)) - assertFalse(ChunkedManifest.tilesExactly(emptyList(), 0)) - assertFalse(ChunkedManifest.tilesExactly(listOf(part(0, 0)), 0)) // empty range - assertFalse(ChunkedManifest.tilesExactly(listOf(part(0, 250)), 300)) - } - - // MARK: - Create validation - - @Test - fun `create accepts parts that tile the blob exactly`() { - val m = manifest() - assertEquals(m, ChunkedManifest.validatedForCreate(m, blobSize)) - } - - @Test - fun `create rejects parts that do not tile the blob`() { - for ( - bad in listOf( - listOf(part(0, 100), part(150, 250)), // gap - listOf(part(0, 150), part(100, 250)), // overlap - listOf(part(50, 250)), // does not start at 0 - listOf(part(0, 200)), // short of the blob size - listOf(part(0, 100), part(100, 251)), // past the blob size - ) - ) { - assertThrows(ChunkedManifest.ReconcileException::class.java) { - ChunkedManifest.validatedForCreate(manifest(parts = bad), blobSize) - } - } - } - - @Test - fun `a rejected create writes no manifest, leaving the blob adoptable`() { - // startUpload validates AFTER takeOwnership moved the bytes. The throw - // propagates out of compute before a save. Thus the blob sits ownerless at - // its path. That is exactly what takeOwnership's orphan branch adopts on - // the corrected retry. - val store = ChunkedManifestStore(tmp.newFolder()) - store.blobFile("u1").apply { parentFile!!.mkdirs() }.writeText("owned bytes") - assertThrows(ChunkedManifest.ReconcileException::class.java) { - store.compute("u1") { ChunkedManifest.validatedForCreate(manifest(), 999L) } - } - assertNull(store.load("u1")) - assertTrue(store.blobFile("u1").exists()) - } - - // MARK: - Store - - @Test - fun `save then load round-trips, across store instances`() { - val dir = tmp.newFolder() - val m = manifest().withPartAccepted(1) - ChunkedManifestStore(dir).save(m) - // A new instance over the same dir is what a process relaunch looks like. - assertEquals(m, ChunkedManifestStore(dir).load("u1")) - } - - @Test - fun `load returns null for an unknown id`() { - assertNull(ChunkedManifestStore(tmp.newFolder()).load("nope")) - } - - @Test - fun `update persists the transformed manifest`() { - val store = ChunkedManifestStore(tmp.newFolder()) - store.save(manifest()) - val updated = store.update("u1") { it.withPartAccepted(0) } - assertTrue(updated!!.parts[0].accepted) - assertTrue(store.load("u1")!!.parts[0].accepted) - } - - @Test - fun `update of a missing manifest returns null`() { - assertNull(ChunkedManifestStore(tmp.newFolder()).update("nope") { it }) - } - - @Test - fun `compute creates when no manifest exists`() { - val store = ChunkedManifestStore(tmp.newFolder()) - val created = store.compute("u1") { existing -> - assertNull(existing) - manifest() - } - assertEquals(created, store.load("u1")) - } - - @Test - fun `compute holds the store lock across load, transform, and save`() { - // The startUpload reconcile and a running worker's markAccepted race. If - // the lock did not span all three steps, the update below could land - // between compute's load and save, and it would be erased from disk. When - // they are serialized, both effects must survive. - val store = ChunkedManifestStore(tmp.newFolder()) - store.save(manifest()) - val inTransform = CountDownLatch(1) - val computing = Thread { - store.compute("u1") { existing -> - inTransform.countDown() - Thread.sleep(300) // hold the lock with load done and save not yet run - existing!!.copy(expiresAt = 99_000) - } - }.apply { start() } - assertTrue(inTransform.await(5, TimeUnit.SECONDS)) - val updating = Thread { store.update("u1") { it.withPartAccepted(0) } }.apply { start() } - computing.join() - updating.join() - val final = store.load("u1")!! - assertEquals(99_000, final.expiresAt) - assertTrue(final.parts[0].accepted) - } - - @Test - fun `a throwing compute transform propagates and writes nothing`() { - val store = ChunkedManifestStore(tmp.newFolder()) - store.save(manifest()) - assertThrows(ChunkedManifest.ReconcileException::class.java) { - store.compute("u1") { throw ChunkedManifest.ReconcileException("rejected") } - } - assertEquals(manifest(), store.load("u1")) - } - - @Test - fun `contains tracks save and remove`() { - val store = ChunkedManifestStore(tmp.newFolder()) - assertFalse(store.contains("u1")) - store.save(manifest()) - assertTrue(store.contains("u1")) - store.remove("u1") - assertFalse(store.contains("u1")) - } - - @Test - fun `remove deletes the manifest and the blob`() { - val store = ChunkedManifestStore(tmp.newFolder()) - store.save(manifest()) - store.blobFile("u1").writeText("bytes") - store.remove("u1") - assertNull(store.load("u1")) - assertFalse(store.blobFile("u1").exists()) - } - - @Test - fun `remove of an unknown id is a no-op`() { - ChunkedManifestStore(tmp.newFolder()).remove("simple-upload-id") - } - - @Test - fun `ids with filesystem-hostile characters round-trip`() { - val store = ChunkedManifestStore(tmp.newFolder()) - val id = "a/b:c dü..\\e" - store.save(manifest(id = id)) - assertEquals(id, store.load(id)!!.id) - store.remove(id) - assertNull(store.load(id)) - } - - @Test - fun `a corrupt manifest reads as absent, not fatal`() { - val dir = tmp.newFolder() - val store = ChunkedManifestStore(dir) - store.save(manifest()) - File(File(dir, dir.list()!!.first()), "manifest.json").writeText("{not json") - assertNull(store.load("u1")) - assertEquals(emptyList(), store.all()) - } - - @Test - fun `all lists every stored manifest`() { - val store = ChunkedManifestStore(tmp.newFolder()) - store.save(manifest(id = "u1")) - store.save(manifest(id = "u2").withPartAccepted(0)) - assertEquals(setOf("u1", "u2"), store.all().map { it.id }.toSet()) - } -} diff --git a/android/src/test/java/ai/openspace/backgroundupload/ChunkedPartsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/ChunkedPartsTest.kt new file mode 100644 index 00000000..e7ba858e --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/ChunkedPartsTest.kt @@ -0,0 +1,56 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChunkedPartsTest { + + @Test + fun `tilesExactly covers the edge shapes`() { + assertTrue(ChunkedParts.tilesExactly(listOf(part(0, 100), part(100, 250)), 250)) + assertTrue(ChunkedParts.tilesExactly(listOf(part(100, 250), part(0, 100)), 250)) // any order + assertFalse(ChunkedParts.tilesExactly(emptyList(), 0)) + assertFalse(ChunkedParts.tilesExactly(listOf(part(0, 0)), 0)) // empty range + assertFalse(ChunkedParts.tilesExactly(listOf(part(0, 100), part(150, 250)), 250)) // gap + assertFalse(ChunkedParts.tilesExactly(listOf(part(0, 150), part(100, 250)), 250)) // overlap + assertFalse(ChunkedParts.tilesExactly(listOf(part(50, 250)), 250)) // not from 0 + assertFalse(ChunkedParts.tilesExactly(listOf(part(0, 200)), 250)) // short + assertFalse(ChunkedParts.tilesExactly(listOf(part(0, 251)), 250)) // past the end + } + + @Test + fun `the same parts in another order are the same upload`() { + val a = listOf(part(0, 100), part(100, 250)) + assertTrue(ChunkedParts.sameParts(a, a.reversed())) + // Headers are not compared: a resume sends fresh ones. + assertTrue(ChunkedParts.sameParts(a, a.map { it.copy(headers = mapOf("X" to "new")) })) + } + + @Test + fun `new urls or a new split are different parts`() { + val a = listOf(part(0, 100), part(100, 250)) + assertFalse(ChunkedParts.sameParts(a, listOf(part(0, 100, url = "https://v2/1"), part(100, 250)))) + assertFalse(ChunkedParts.sameParts(a, listOf(part(0, 120), part(120, 250)))) + assertFalse(ChunkedParts.sameParts(a, listOf(part(0, 250)))) + } + + @Test + fun `accepted flags follow the range, not the index`() { + val stored = listOf(part(0, 100, accepted = true), part(100, 250)) + val incoming = listOf(part(100, 250), part(0, 100)) + val carried = ChunkedParts.carryAccepted(stored, incoming) + assertTrue(carried.first { it.start == 0L }.accepted) + assertFalse(carried.first { it.start == 100L }.accepted) + } + + @Test + fun `byte math and pending indexes`() { + val parts = listOf(part(0, 100, accepted = true), part(100, 250)) + assertEquals(250, ChunkedParts.totalBytes(parts)) + assertEquals(100, ChunkedParts.acceptedBytes(parts)) + assertEquals(listOf(1), ChunkedParts.pendingIndexes(parts)) + assertEquals(emptyList(), ChunkedParts.pendingIndexes(ChunkedParts.withAccepted(parts, 1))) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/ChunkedWorkerGateTest.kt b/android/src/test/java/ai/openspace/backgroundupload/ChunkedWorkerGateTest.kt deleted file mode 100644 index 0b65d54b..00000000 --- a/android/src/test/java/ai/openspace/backgroundupload/ChunkedWorkerGateTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package ai.openspace.backgroundupload - -import org.junit.After -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class ChunkedWorkerGateTest { - private val a = Any() - private val b = Any() - - @After - fun tearDown() { - // The gate is a process-wide singleton. Leave nothing for other tests. - ChunkedWorkerGate.release("u1", a) - ChunkedWorkerGate.release("u1", b) - ChunkedWorkerGate.release("u2", a) - } - - @Test - fun `a second worker for the same id must wait`() { - assertTrue(ChunkedWorkerGate.tryAcquire("u1", a)) - // The replacement worker after a cancel-then-start: it must not run a part - // PUT while the cancelled worker still holds the id. - assertFalse(ChunkedWorkerGate.tryAcquire("u1", b)) - ChunkedWorkerGate.release("u1", a) - assertTrue(ChunkedWorkerGate.tryAcquire("u1", b)) - } - - @Test - fun `reacquiring with the same token is idempotent`() { - assertTrue(ChunkedWorkerGate.tryAcquire("u1", a)) - assertTrue(ChunkedWorkerGate.tryAcquire("u1", a)) - } - - @Test - fun `a stale release cannot evict a successor`() { - assertTrue(ChunkedWorkerGate.tryAcquire("u1", a)) - ChunkedWorkerGate.release("u1", a) - assertTrue(ChunkedWorkerGate.tryAcquire("u1", b)) - ChunkedWorkerGate.release("u1", a) // the old worker's finally, arriving late - assertTrue(ChunkedWorkerGate.isRunning("u1")) - assertFalse(ChunkedWorkerGate.tryAcquire("u1", a)) - } - - @Test - fun `ids are independent and isRunning tracks the holder`() { - assertFalse(ChunkedWorkerGate.isRunning("u1")) - assertTrue(ChunkedWorkerGate.tryAcquire("u1", a)) - assertTrue(ChunkedWorkerGate.isRunning("u1")) - assertFalse(ChunkedWorkerGate.isRunning("u2")) - assertTrue(ChunkedWorkerGate.tryAcquire("u2", a)) - ChunkedWorkerGate.release("u1", a) - assertFalse(ChunkedWorkerGate.isRunning("u1")) - assertTrue(ChunkedWorkerGate.isRunning("u2")) - } -} diff --git a/android/src/test/java/ai/openspace/backgroundupload/EntryParsingTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EntryParsingTest.kt new file mode 100644 index 00000000..1d0fc10c --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/EntryParsingTest.kt @@ -0,0 +1,124 @@ +package ai.openspace.backgroundupload + +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class EntryParsingTest { + + private fun entryMap(descriptor: JavaOnlyMap, vars: Any? = JavaOnlyMap.of("n", 1.0)) = + JavaOnlyMap.of("id", "e1", "key", "note", "vars", vars, "descriptor", descriptor) + + private fun base(vararg extra: Any?) = + JavaOnlyMap.of("url", "https://example.com/items", "expiresAt", 9_000.0, *extra) + + @Test + fun `a JSON POST parses with defaults`() { + val p = EntryParsing.parse(entryMap(base("data", JavaOnlyMap.of("n", 1.0, "text", "hi")))) + assertEquals("e1", p.id) + assertEquals("note", p.key) + assertEquals("""{"n":1}""", p.varsJson) + assertEquals(9_000L, p.expiresAt) + assertEquals("POST", p.descriptor.method) + assertEquals("""{"n":1,"text":"hi"}""", p.descriptor.dataJson) + assertEquals(StagedBody.JSON, p.descriptor.bodyKind) + } + + @Test + fun `null vars store as the text null`() { + assertEquals("null", EntryParsing.parse(entryMap(base(), vars = null)).varsJson) + } + + @Test + fun `a null data is no body, because the bridge turns undefined into null`() { + assertNull(EntryParsing.parse(entryMap(base("data", null))).descriptor.dataJson) + } + + @Test + fun `chunked parts, headers, accept, retry, and android parse`() { + val d = JavaOnlyMap.of( + "method", "PUT", + "file", "file:///data/a%20b.bin", + "expiresAt", 9_000.0, + "headers", JavaOnlyMap.of("Content-Type", "video/mp4", "X-N", 5.0), + "parts", JavaOnlyArray.of( + JavaOnlyMap.of("url", "https://s3/1", "range", JavaOnlyMap.of("start", 0.0, "end", 10.0)), + JavaOnlyMap.of( + "url", "https://s3/2", "headers", JavaOnlyMap.of("Content-Range", "10-19"), + "range", JavaOnlyMap.of("start", 10.0, "end", 20.0), + ), + ), + "accept", JavaOnlyArray.of(JavaOnlyMap.of("status", 409.0, "bodyIncludes", "already completed")), + "retry", JavaOnlyMap.of( + "backoff", JavaOnlyMap.of("baseMs", 50.0), + "terminalHttp", JavaOnlyMap.of("exempt", JavaOnlyArray()), + ), + "android", JavaOnlyMap.of("noNotification", true), + ) + val parsed = EntryParsing.parse(entryMap(d)).descriptor + assertEquals("/data/a b.bin", parsed.file) + assertEquals(StagedBody.CHUNKED, parsed.bodyKind) + assertEquals(listOf(Part("https://s3/1", mapOf(), 0, 10), Part("https://s3/2", mapOf("Content-Range" to "10-19"), 10, 20)), parsed.parts) + assertEquals(mapOf("Content-Type" to "video/mp4", "X-N" to "5"), parsed.headers) + assertEquals(listOf(UploadOutcome.AcceptRule(409, "already completed")), parsed.accept) + assertEquals(RetryOverride(50, null, null, emptyList()), parsed.retry) + assertEquals(true, parsed.noNotification) + assertEquals("https://s3/2", parsed.reportUrl) + } + + @Test + fun `form parts parse with exactly one of string or path`() { + val d = base( + "form", JavaOnlyArray.of( + JavaOnlyMap.of("name", "meta", "contentType", "application/json", "string", "{}"), + JavaOnlyMap.of("name", "photo", "contentType", "image/jpeg", "path", "/p.jpg", "fileName", "p.jpg"), + ), + ) + assertEquals( + listOf( + FormPart("meta", "application/json", "{}", null, null), + FormPart("photo", "image/jpeg", null, "/p.jpg", "p.jpg"), + ), + EntryParsing.parse(entryMap(d)).descriptor.form, + ) + val both = base("form", JavaOnlyArray.of(JavaOnlyMap.of("name", "x", "contentType", "t", "string", "s", "path", "/p"))) + assertThrows(EntryParsing.InvalidEntryException::class.java) { EntryParsing.parse(entryMap(both)) } + } + + @Test + fun `what native can not run is rejected`() { + val cases = listOf( + JavaOnlyMap.of("url", "https://example.com"), // no expiresAt + JavaOnlyMap.of("expiresAt", 1.0), // no url and no parts + base("data", 1.0, "file", "/a"), // two body kinds + base("method", "GET", "data", 1.0), // GET with a body + base("method", "TRACE"), + JavaOnlyMap.of("url", "not a url", "expiresAt", 1.0), + base("headers", JavaOnlyMap.of("Bad\nName", "v")), + base("parts", JavaOnlyArray.of(JavaOnlyMap.of("url", "https://s3/1", "range", JavaOnlyMap.of("start", 0.0, "end", 1.0)))), // parts without file + ) + cases.forEach { d -> + assertThrows("$d", EntryParsing.InvalidEntryException::class.java) { EntryParsing.parse(entryMap(d)) } + } + assertThrows(EntryParsing.InvalidEntryException::class.java) { + EntryParsing.parse(JavaOnlyMap.of("key", "k", "descriptor", base())) + } + } + + @Test + fun `an updateHeaders patch is checked like descriptor headers`() { + assertEquals(mapOf("Authorization" to "Bearer new"), EntryParsing.headerPatch(JavaOnlyMap.of("Authorization", "Bearer new"))) + assertThrows(EntryParsing.InvalidEntryException::class.java) { + EntryParsing.headerPatch(JavaOnlyMap.of("Authorization", "Bearer\nnew")) + } + } + + @Test + fun `file scheme stripping`() { + assertEquals("/a/b c.jpg", EntryParsing.stripFileScheme("file:///a/b%20c.jpg")) + assertEquals("/a/b.jpg", EntryParsing.stripFileScheme("/a/b.jpg")) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/EntryTransitionsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EntryTransitionsTest.kt new file mode 100644 index 00000000..e5a37f73 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/EntryTransitionsTest.kt @@ -0,0 +1,176 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EntryTransitionsTest { + + @Test + fun `a settle on a cancelled entry or an older generation is not allowed`() { + assertTrue(EntryTransitions.canSettle(entry(state = EntryState.RUNNING), 1)) + assertTrue(EntryTransitions.canSettle(entry(state = EntryState.PAUSED), 1)) // an in-flight response under pause + assertFalse(EntryTransitions.canSettle(entry(state = EntryState.CANCELLED), 1)) + assertFalse(EntryTransitions.canSettle(entry(state = EntryState.COMPLETED), 1)) + assertFalse(EntryTransitions.canSettle(entry(state = EntryState.RUNNING, generation = 2), 1)) + assertFalse(EntryTransitions.canSettle(null, 1)) + } + + @Test + fun `resume returns to awaiting-auth only when the parked generation is current`() { + val paused = entry(state = EntryState.PAUSED, parkedGeneration = 3) + assertEquals(EntryState.AWAITING_AUTH, EntryTransitions.toResumed(paused, headerGeneration = 3, now = 9).state) + val stale = EntryTransitions.toResumed(paused, headerGeneration = 4, now = 9) + assertEquals(EntryState.QUEUED, stale.state) + assertNull(stale.parkedGeneration) + assertEquals(EntryState.QUEUED, EntryTransitions.toResumed(entry(state = EntryState.PAUSED), 0, 9).state) + } + + @Test + fun `pause keeps the parked generation and clears the wake time`() { + val p = EntryTransitions.toPaused(entry(state = EntryState.AWAITING_AUTH, parkedGeneration = 2, nextAttemptAt = 50), 9) + assertEquals(EntryState.PAUSED, p.state) + assertEquals(2, p.parkedGeneration) + assertNull(p.nextAttemptAt) + } + + @Test + fun `park, release, run, stop, settle`() { + val running = EntryTransitions.toRunning(entry(nextAttemptAt = 5), 9) + assertEquals(EntryState.RUNNING, running.state) + assertNull(running.nextAttemptAt) + + val parked = EntryTransitions.toParked(running.copy(backoffStreak = 4), 7, 10) + assertEquals(EntryState.AWAITING_AUTH, parked.state) + assertEquals(7, parked.parkedGeneration) + assertEquals(0, parked.backoffStreak) + + val released = EntryTransitions.toReleased(running, 99, 6, 11) + assertEquals(EntryState.QUEUED, released.state) + assertEquals(99L, released.nextAttemptAt) + assertEquals(6, released.backoffStreak) + + assertEquals(EntryState.QUEUED, EntryTransitions.toStopped(running, 12).state) + + val settled = EntryTransitions.toSettled(parked, EntryState.ERROR, "ev", 5, 13) + assertEquals("ev", settled.settledEventId) + assertNull(settled.parkedGeneration) + assertEquals(13, settled.updatedAt) + } + + @Test + fun `a short backoff keeps the row running and shows the time, and the next attempt clears it`() { + val waiting = EntryTransitions.toBackingOff(entry(state = EntryState.RUNNING), 9_000, 5) + assertEquals(EntryState.RUNNING, waiting.state) + assertEquals(9_000L, waiting.nextAttemptAt) + assertEquals(9_000.0, waiting.toRow().toMap()["nextAttemptAt"]) + val attempt = EntryTransitions.toAttempt(waiting, "req-2", 6) + assertNull(attempt.nextAttemptAt) + assertEquals(1, attempt.attempts) + assertEquals("req-2", attempt.lastRequestId) + assertFalse(attempt.toRow().toMap().containsKey("nextAttemptAt")) + } +} + +class EnqueueRulesTest { + private val body = desc(dataJson = """{"a":1}""") + private val other = desc(dataJson = """{"a":2}""") + + private fun decide(existing: QueueEntry?, incoming: Descriptor = body, hasRecord: Boolean = true, v9: LegacyManifest? = null) = + EnqueueRules.decide(existing, v9, incoming) { hasRecord } + + @Test + fun `the same-id table`() { + assertEquals(EnqueueRules.Action.Create, decide(null)) + val v9 = LegacyManifest("e1", "/b", listOf(part(0, 10)), emptyList(), 1, false, 1) + assertEquals(EnqueueRules.Action.AdoptV9(v9), decide(null, v9 = v9)) + assertEquals(EnqueueRules.Action.Replace, decide(entry(legacy = true, descriptor = null, body = null))) + assertEquals(EnqueueRules.Action.ReEmit("ev"), decide(entry(state = EntryState.COMPLETED, settledEventId = "ev"))) + assertEquals(EnqueueRules.Action.Replace, decide(entry(state = EntryState.COMPLETED, settledEventId = "ev"), hasRecord = false)) + EntryState.values().filter { it != EntryState.COMPLETED }.forEach { state -> + assertEquals("$state", EnqueueRules.Action.Resume, decide(entry(state = state))) + } + assertEquals(EnqueueRules.Action.RejectRunning, decide(entry(state = EntryState.RUNNING), other)) + EntryState.values().filter { it != EntryState.RUNNING }.forEach { state -> + assertEquals("$state", EnqueueRules.Action.Replace, decide(entry(state = state), other)) + } + } + + @Test + fun `resume replaces the metadata, keeps the body and accepted parts`() { + val parts = listOf(part(0, 10, accepted = true), part(10, 20)) + val stored = entry( + state = EntryState.AWAITING_AUTH, + descriptor = desc(url = null, method = "PUT", file = "/f", parts = parts), + body = StagedBody(StagedBody.CHUNKED, "blob", null, 20), + attempts = 4, + parkedGeneration = 1, + ) + val incoming = parsed( + descriptor = desc(url = null, method = "PUT", file = "/f", headers = mapOf("Authorization" to "Bearer new"), + parts = parts.map { it.copy(accepted = false) }), + varsJson = """{"n":2}""", + expiresAt = 77, + ) + val next = EnqueueRules.resumed(stored, incoming, paused = false, headerGeneration = 5, now = 9) + assertEquals(EntryState.QUEUED, next.state) + assertEquals(mapOf("Authorization" to "Bearer new"), next.descriptor!!.headers) + assertTrue(next.descriptor!!.parts!![0].accepted) + assertEquals(10, next.bytesSent) + assertEquals(4, next.attempts) + assertEquals(77, next.expiresAt) + assertEquals("""{"n":2}""", next.varsJson) + assertEquals(5, next.headerGeneration) + assertNull(next.parkedGeneration) + assertEquals(1, next.generation) // a live entry keeps its life + } + + @Test + fun `resume of a settled entry reopens it with a fresh generation`() { + val next = EnqueueRules.resumed(entry(state = EntryState.ERROR, settledEventId = "ev", generation = 2), parsed(), false, 0, 9) + assertEquals(EntryState.QUEUED, next.state) + assertEquals(3, next.generation) + assertNull(next.settledEventId) + } + + @Test + fun `resume of a running entry stays running, and under pause becomes paused`() { + assertEquals(EntryState.RUNNING, EnqueueRules.resumed(entry(state = EntryState.RUNNING), parsed(), true, 0, 9).state) + assertEquals(EntryState.PAUSED, EnqueueRules.resumed(entry(state = EntryState.QUEUED), parsed(), true, 0, 9).state) + } + + @Test + fun `resume re-applies the json content type`() { + val next = EnqueueRules.resumed(entry(), parsed(descriptor = desc(dataJson = """{"a":1}""", headers = mapOf())), false, 0, 9) + assertEquals(mapOf("Content-Type" to "application/json"), next.descriptor!!.headers) + } + + @Test + fun `adopting v9 parts carries the flags only for the same parts`() { + val v9 = LegacyManifest("e1", "/b", listOf(part(0, 10, accepted = true), part(10, 20)), emptyList(), 1, false, 1) + assertTrue(EnqueueRules.adoptedParts(v9, listOf(part(0, 10), part(10, 20)))[0].accepted) + assertFalse(EnqueueRules.adoptedParts(v9, listOf(part(0, 20)))[0].accepted) + } + + @Test + fun `a copied body is staged outside the lock only when no worker can change the decision`() { + val json = desc(dataJson = """{"a":2}""") + val create = EnqueueRules.Action.Create + val replace = EnqueueRules.Action.Replace + assertEquals(1, EnqueueRules.preStageGeneration(null, create, json)) + assertEquals(1, EnqueueRules.preStageGeneration(null, create, desc(file = "/f"))) + assertEquals(3, EnqueueRules.preStageGeneration(entry(state = EntryState.ERROR, generation = 2), replace, json)) + assertEquals(2, EnqueueRules.preStageGeneration(entry(state = EntryState.PAUSED), replace, json)) + // A worker can take a queued entry meanwhile, and a running one is the worker's. + assertNull(EnqueueRules.preStageGeneration(entry(state = EntryState.QUEUED), replace, json)) + assertNull(EnqueueRules.preStageGeneration(entry(state = EntryState.RUNNING), replace, json)) + // A chunked move and a bodiless request stage under the lock. + assertNull(EnqueueRules.preStageGeneration(null, create, desc(url = null, file = "/f", parts = listOf(part(0, 10))))) + assertNull(EnqueueRules.preStageGeneration(null, create, desc())) + // Nothing to stage. + assertNull(EnqueueRules.preStageGeneration(entry(), EnqueueRules.Action.Resume, json)) + assertNull(EnqueueRules.preStageGeneration(entry(), EnqueueRules.Action.RejectRunning, json)) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt index 811f1bd2..b095c4c7 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt @@ -2,6 +2,7 @@ package ai.openspace.backgroundupload import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -12,93 +13,153 @@ class EventJournalTest { @get:Rule val tmp = TemporaryFolder() - private fun entry(id: String, uploadId: String = "u1") = EventJournal.Entry( - eventId = id, - uploadId = uploadId, - type = "completed", - timestamp = System.currentTimeMillis(), - responseCode = 200, - responseBody = "ok", - responseHeaders = mapOf("x-a" to "b"), - ) + private val id1 = "00000000-0000-0000-0000-000000000001" + private val id2 = "00000000-0000-0000-0000-000000000002" @Test - fun `append then read returns the entry`() { + fun `append then read returns the record`() { val journal = EventJournal(tmp.newFolder()) - journal.append(entry("e1")) + assertTrue(journal.append(record(id1))) val events = journal.unacknowledged() - assertEquals(1, events.size) - assertEquals("e1", events[0].eventId) - assertEquals(200, events[0].responseCode) - assertEquals("ok", events[0].responseBody) + assertEquals(listOf(record(id1)), events) } @Test - fun `ack removes only the acked entry`() { + fun `ack removes only the acked record and is idempotent`() { val journal = EventJournal(tmp.newFolder()) - journal.append(entry("e1")) - journal.append(entry("e2")) - journal.ack(listOf("e1")) - assertEquals(listOf("e2"), journal.unacknowledged().map { it.eventId }) + journal.append(record(id1)) + journal.append(record(id2)) + journal.ack(listOf(id1, id1, "unknown")) + assertEquals(listOf(id2), journal.unacknowledged().map { it.eventId }) } @Test - fun `entries survive a new journal instance over the same dir`() { + fun `ack ignores ids that are not event ids`() { val dir = tmp.newFolder() - EventJournal(dir).append(entry("e1")) + val outside = File(dir.parentFile, "precious.json").apply { writeText("x") } + EventJournal(dir).ack(listOf("../precious")) + assertTrue(outside.exists()) + } + + @Test + fun `records survive a new journal instance`() { + val dir = tmp.newFolder() + EventJournal(dir).append(record(id1)) assertEquals(1, EventJournal(dir).unacknowledged().size) } @Test - fun `oversized body is truncated and flagged`() { + fun `a body over 1 MB is cut and flagged`() { val journal = EventJournal(tmp.newFolder()) val big = "x".repeat(EventJournal.MAX_BODY_CHARS + 100) - journal.append(entry("e1").copy(responseBody = big)) - val read = journal.unacknowledged()[0] - assertTrue(read.responseBodyTruncated) - assertTrue(read.responseBody!!.length <= EventJournal.MAX_BODY_CHARS) + journal.append(record(id1).copy(response = EventJournal.Response(200, null, big, false))) + val read = journal.unacknowledged()[0].response!! + assertTrue(read.bodyTruncated) + assertEquals(EventJournal.MAX_BODY_CHARS, read.body!!.length) } @Test - fun `corrupt file is skipped, not fatal`() { + fun `a corrupt file is skipped`() { val dir = tmp.newFolder() val journal = EventJournal(dir) - journal.append(entry("e1")) - java.io.File(dir, "garbage.json").writeText("{not json") - assertEquals(1, journal.unacknowledged().size) + journal.append(record(id1)) + File(dir, "garbage.json").writeText("{not json") + File(dir, "partial.json").writeText("""{"eventId":"x"}""") + assertEquals(listOf(id1), journal.unacknowledged().map { it.eventId }) } @Test - fun `entries are ordered by timestamp`() { + fun `records are ordered by time`() { val journal = EventJournal(tmp.newFolder()) - journal.append(entry("late").copy(timestamp = 2000)) - journal.append(entry("early").copy(timestamp = 1000)) - assertEquals(listOf("early", "late"), journal.unacknowledged().map { it.eventId }) + journal.append(record(id1, at = 2_000)) + journal.append(record(id2, at = 1_000)) + assertEquals(listOf(id2, id1), journal.unacknowledged().map { it.eventId }) } @Test - fun `append does not throw when the directory is unwritable`() { - // A regular file where a directory is expected: mkdirs() and every write fail. - val notADir = tmp.newFile() - val journal = EventJournal(notADir) - journal.append(entry("e1")) // must not throw - assertEquals(emptyList(), journal.unacknowledged().map { it.eventId }) + fun `append never throws, and says whether it wrote`() { + val journal = EventJournal(tmp.newFile()) // a file where the directory should be + assertFalse(journal.append(record(id1))) + assertEquals(emptyList(), journal.unacknowledged()) } @Test - fun `prunes the oldest entries beyond the cap`() { + fun `prunes the oldest records beyond the cap`() { val dir = tmp.newFolder() val journal = EventJournal(dir, maxEntries = 3) - // Stamp increasing mtimes so pruning order is deterministic. Each mtime is - // set before the next append, which is when pruning reads it. - journal.append(entry("e1")); File(dir, "e1.json").setLastModified(1000) - journal.append(entry("e2")); File(dir, "e2.json").setLastModified(2000) - journal.append(entry("e3")); File(dir, "e3.json").setLastModified(3000) - journal.append(entry("e4")) // 4th write trips the cap; oldest (e1) is dropped - - val ids = journal.unacknowledged().map { it.eventId } - assertEquals(3, ids.size) - assertFalse(ids.contains("e1")) - assertTrue(ids.contains("e4")) + val ids = (1..4).map { "00000000-0000-0000-0000-00000000000$it" } + ids.take(3).forEachIndexed { i, id -> + journal.append(record(id)) + File(dir, "$id.json").setLastModified(1_000L * (i + 1)) + } + journal.append(record(ids[3])) + val left = journal.unacknowledged().map { it.eventId } + assertEquals(3, left.size) + assertFalse(left.contains(ids[0])) + } + + @Test + fun `incrementDeliveries persists`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir) + journal.append(record(id1)) + assertEquals(2, journal.incrementDeliveries(id1)!!.deliveries) + assertEquals(3, journal.incrementDeliveries(id1)!!.deliveries) + assertEquals(3, EventJournal(dir).find(id1)!!.deliveries) + assertNull(journal.incrementDeliveries(id2)) + } + + @Test + fun `forEntry filters by entry id`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(record(id1, id = "a")) + journal.append(record(id2, id = "b")) + assertEquals(listOf(id2), journal.forEntry("b").map { it.eventId }) + } + + @Test + fun `the completed shape is SettledEvent`() { + val map = record(id1).toMap() + assertEquals( + listOf( + "eventId", "id", "key", "vars", "at", "attempts", "requestId", "deliveries", "state", + "bytesSent", "totalBytes", "url", "method", "kind", "response", + ), + map.keys.toList(), + ) + assertEquals(mapOf("n" to 1.0), map["vars"]) + assertEquals(mapOf("status" to 200.0, "headers" to mapOf(), "body" to "ok", "bodyTruncated" to false), map["response"]) + } + + @Test + fun `a chunked completion has a response with no status`() { + val map = record(id1).copy(response = null).toMap() + assertEquals(mapOf("bodyTruncated" to false), map["response"]) + } + + @Test + fun `the error shape nests errorKind, message, response, and partIndex`() { + val map = record(id1, kind = EventJournal.KIND_ERROR).copy( + partIndex = 2, + response = EventJournal.Response(404, null, "gone", false), + ).toMap() + assertEquals(2.0, map["partIndex"]) + assertEquals( + mapOf( + "errorKind" to "http", "message" to "HTTP 400", + "response" to mapOf("status" to 404.0, "body" to "gone", "bodyTruncated" to false), + "partIndex" to 2.0, + ), + map["error"], + ) + assertFalse(map.containsKey("response")) + } + + @Test + fun `the cancelled shape carries the reason`() { + val map = record(id1, kind = EventJournal.KIND_CANCELLED).toMap() + assertEquals("user", map["cancelReason"]) + assertFalse(map.containsKey("error")) + assertFalse(map.containsKey("response")) } } diff --git a/android/src/test/java/ai/openspace/backgroundupload/JsonBridgeTest.kt b/android/src/test/java/ai/openspace/backgroundupload/JsonBridgeTest.kt new file mode 100644 index 00000000..bb420821 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/JsonBridgeTest.kt @@ -0,0 +1,81 @@ +package ai.openspace.backgroundupload + +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class JsonBridgeTest { + + @Test + fun `integral doubles print as integers, as JSON stringify does`() { + assertEquals("""{"n":1,"neg":-3,"zero":0}""", JsonBridge.toJson(mapOf("n" to 1.0, "neg" to -3.0, "zero" to -0.0))) + assertEquals("12345678901", JsonBridge.toJson(12_345_678_901.0)) + } + + @Test + fun `fractions and very large magnitudes keep a decimal form`() { + assertEquals("1.5", JsonBridge.toJson(1.5)) + assertEquals("0.1", JsonBridge.toJson(0.1)) + // Above 2^53 a double can not hold every integer, so it stays a double. + assertEquals(1e20, (JsonBridge.parse(JsonBridge.toJson(1e20)) as Double), 0.0) + } + + @Test + fun `nested maps and lists round trip`() { + val value = mapOf("a" to listOf(1.0, "x", true, null, mapOf("b" to 2.5)), "c" to mapOf()) + val text = JsonBridge.toJson(value) + assertEquals("""{"a":[1,"x",true,null,{"b":2.5}],"c":{}}""", text) + assertEquals(value, JsonBridge.parse(text)) + } + + @Test + fun `keys are sorted so the same object always gives the same text`() { + assertEquals(JsonBridge.toJson(mapOf("b" to 1.0, "a" to 2.0)), JsonBridge.toJson(mapOf("a" to 2.0, "b" to 1.0))) + } + + @Test + fun `null is the text null, and HTML characters are not escaped`() { + assertEquals("null", JsonBridge.toJson(null)) + assertNull(JsonBridge.parse("null")) + assertEquals("\"\"", JsonBridge.toJson("")) + } + + @Test + fun `malformed text throws`() { + assertThrows(Exception::class.java) { JsonBridge.parse("{\"a\":") } + assertThrows(Exception::class.java) { JsonBridge.parse("[1,") } + } + + @Test + fun `bridge maps read into plain values`() { + val map = JavaOnlyMap.of( + "n", 2.0, + "s", "x", + "b", false, + "z", null, + "m", JavaOnlyMap.of("k", 1.0), + "a", JavaOnlyArray.of(1.0, "y"), + ) + assertEquals( + mapOf("n" to 2.0, "s" to "x", "b" to false, "z" to null, "m" to mapOf("k" to 1.0), "a" to listOf(1.0, "y")), + JsonBridge.fromReadable(map), + ) + assertNull(JsonBridge.valueOf(map, "absent")) + } + + @Test + fun `plain values write to the bridge`() { + val out = JsonBridge.toWritableMap( + mapOf("n" to 1.0, "list" to listOf("a", 2.0), "nested" to mapOf("k" to true), "none" to null), + ::JavaOnlyMap, + ::JavaOnlyArray, + ) as JavaOnlyMap + assertEquals(1.0, out.getDouble("n"), 0.0) + assertEquals("a", out.getArray("list")!!.getString(0)) + assertEquals(true, out.getMap("nested")!!.getBoolean("k")) + assertEquals(true, out.isNull("none")) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/LegacyImportTest.kt b/android/src/test/java/ai/openspace/backgroundupload/LegacyImportTest.kt new file mode 100644 index 00000000..9f7be8ef --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/LegacyImportTest.kt @@ -0,0 +1,82 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class LegacyImportTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun v9(dir: File, eventId: String, uploadId: String, type: String, timestamp: Long) = + File(dir, "$eventId.json").writeText( + """{"eventId":"$eventId","uploadId":"$uploadId","type":"$type","timestamp":$timestamp,"responseCode":200}""", + ) + + @Test + fun `each v9 id becomes one legacy row with its newest outcome`() { + val v9Dir = tmp.newFolder() + v9(v9Dir, "a", "up-1", "completed", 100) + v9(v9Dir, "b", "up-2", "error", 200) + v9(v9Dir, "c", "up-3", "cancelled", 300) + v9(v9Dir, "d", "up-2", "completed", 250) // newer for up-2 + File(v9Dir, "bad.json").writeText("{not json") + val store = QueueStore(tmp.newFolder(), RequestIndex()) + + assertTrue(LegacyImport.import(v9Dir, store)) + + val rows = store.all().associateBy { it.id } + assertEquals(setOf("up-1", "up-2", "up-3"), rows.keys) + assertEquals(EntryState.COMPLETED, rows["up-1"]!!.state) + assertEquals(EntryState.COMPLETED, rows["up-2"]!!.state) + assertEquals(EntryState.CANCELLED, rows["up-3"]!!.state) + val row = rows["up-2"]!! + assertEquals("legacy", row.key) + assertEquals("null", row.varsJson) + assertTrue(row.legacy) + assertEquals(0, row.attempts) + assertEquals(250, row.updatedAt) + assertNull(row.descriptor) + assertEquals(0, v9Dir.list()!!.size) // every v9 file is gone + } + + @Test + fun `a second run imports nothing`() { + val v9Dir = tmp.newFolder() + val store = QueueStore(tmp.newFolder(), RequestIndex()) + v9(v9Dir, "a", "up-1", "completed", 100) + LegacyImport.import(v9Dir, store) + store.remove("up-1") + assertTrue(LegacyImport.import(v9Dir, store)) + assertEquals(emptyList(), store.all()) + } + + @Test + fun `an id that a v10 entry owns is left alone`() { + val v9Dir = tmp.newFolder() + val store = QueueStore(tmp.newFolder(), RequestIndex()) + store.save(entry(id = "up-1")) + v9(v9Dir, "a", "up-1", "error", 100) + LegacyImport.import(v9Dir, store) + assertEquals("note", store.load("up-1")!!.key) + } + + @Test + fun `a failed save keeps the v9 file and reports incomplete`() { + val v9Dir = tmp.newFolder() + v9(v9Dir, "a", "up-1", "error", 100) + val broken = QueueStore(tmp.newFile(), RequestIndex()) // a file where the directory should be + assertEquals(false, LegacyImport.import(v9Dir, broken)) + assertTrue(File(v9Dir, "a.json").exists()) + } + + @Test + fun `an unknown type makes no row`() { + assertNull(LegacyImport.legacyRow(LegacyImport.V9Entry("a", "up", "progress", 1))) + assertNull(LegacyImport.legacyRow(LegacyImport.V9Entry("a", null, "completed", 1))) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt b/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt new file mode 100644 index 00000000..92663412 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt @@ -0,0 +1,617 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class QueueControllerTest { + @get:Rule + val tmp = TemporaryFolder() + + private lateinit var root: File + private lateinit var store: QueueStore + private lateinit var journal: EventJournal + private lateinit var settings: QueueSettingsStore + private val events = RecordingEvents() + private val scheduler = FakeScheduler() + private val running = mutableSetOf() + private var now = 10_000L + private lateinit var controller: QueueController + + @Before + fun setUp() { + root = tmp.newFolder("queue") + store = QueueStore(root, RequestIndex()) + journal = EventJournal(tmp.newFolder("journal")) + settings = QueueSettingsStore(File(root, "settings.json")) + controller = QueueController(store, journal, settings, events, scheduler, { it in running }, { now }) + } + + private fun source(name: String, size: Int) = File(tmp.newFolder(), name).apply { writeBytes(ByteArray(size) { it.toByte() }) } + + private fun dirFiles(id: String = "e1") = store.entryDir(id).list()!!.toSet() + + // MARK: - enqueue + + @Test + fun `create stages the json body, persists, schedules, then emits`() { + assertEquals("e1", controller.enqueue(parsed())) + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertEquals(1, e.generation) + assertEquals("""{"a":1}""", File(store.entryDir("e1"), e.body!!.fileName!!).readText()) + assertEquals("application/json", e.descriptor!!.headers["Content-Type"]) + assertEquals(setOf("entry.json", "body-1.json"), dirFiles()) + assertEquals(listOf("e1"), scheduler.scheduled) + assertEquals(listOf("state:e1:queued"), events.log) + } + + @Test + fun `create while paused is paused and not scheduled`() { + controller.pause() + controller.enqueue(parsed()) + assertEquals(EntryState.PAUSED, store.load("e1")!!.state) + assertEquals(emptyList(), scheduler.scheduled) + } + + @Test + fun `a file body is copied, so the caller may delete its source`() { + val src = source("photo.jpg", 100) + controller.enqueue(parsed(descriptor = desc(file = src.path))) + src.delete() + val e = store.load("e1")!! + assertEquals(100, File(store.entryDir("e1"), e.body!!.fileName!!).length()) + assertEquals(100, e.totalBytes) + } + + @Test + fun `a missing file rejects E_FILE_MISSING and persists nothing`() { + val e = assertThrows(QueueException::class.java) { controller.enqueue(parsed(descriptor = desc(file = "/nope.bin"))) } + assertEquals(QueueException.E_FILE_MISSING, e.code) + assertNull(store.load("e1")) + assertEquals(emptyList(), events.log) + } + + @Test + fun `same body on a queued entry resumes with the new headers, vars, and expiry`() { + controller.enqueue(parsed()) + val bodyFile = store.load("e1")!!.body!!.fileName + controller.enqueue(parsed(descriptor = desc(dataJson = """{"a":1}""", headers = mapOf("Authorization" to "Bearer new")), varsJson = """{"n":2}""", expiresAt = 5)) + val e = store.load("e1")!! + assertEquals("Bearer new", e.descriptor!!.headers["Authorization"]) + assertEquals("""{"n":2}""", e.varsJson) + assertEquals(5, e.expiresAt) + assertEquals(1, e.generation) + assertEquals(bodyFile, e.body!!.fileName) + } + + @Test + fun `same body on a running entry stays running and is not scheduled again`() { + store.save(entry(state = EntryState.RUNNING)) + controller.enqueue(parsed(descriptor = desc(dataJson = """{"a":1}""", headers = mapOf("Authorization" to "Bearer new")))) + val e = store.load("e1")!! + assertEquals(EntryState.RUNNING, e.state) + assertEquals("Bearer new", e.descriptor!!.headers["Authorization"]) + assertEquals(emptyList(), scheduler.scheduled) + } + + @Test + fun `a different body on a running entry rejects E_RUNNING and changes nothing`() { + store.save(entry(state = EntryState.RUNNING)) + val e = assertThrows(QueueException::class.java) { controller.enqueue(parsed(descriptor = desc(dataJson = """{"a":2}"""))) } + assertEquals(QueueException.E_RUNNING, e.code) + assertEquals(entry(state = EntryState.RUNNING), store.load("e1")) + } + + @Test + fun `a different body on a queued entry with a worker sleeping out a short backoff is accepted`() { + // The gate is held, but the entry is queued. The contract: queued is not running. + controller.enqueue(parsed()) + running += "e1" + controller.enqueue(parsed(descriptor = desc(dataJson = """{"a":2}"""))) + assertEquals("""{"a":2}""", store.load("e1")!!.descriptor!!.dataJson) + } + + @Test + fun `a different body on an error entry replaces it and reopens it`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(state = EntryState.ERROR, attempts = 3, settledEventId = "ev")) + controller.enqueue(parsed(descriptor = desc(dataJson = """{"a":2}"""))) + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertEquals(2, e.generation) + assertEquals(0, e.attempts) + assertNull(e.settledEventId) + assertEquals("""{"a":2}""", File(store.entryDir("e1"), "body-2.json").readText()) + assertEquals(setOf("entry.json", "body-2.json"), dirFiles()) // the old body is pruned + } + + @Test + fun `same body on a completed unacked entry re-emits with one more delivery and does not re-run`() { + controller.enqueue(parsed()) + val eventId = "00000000-0000-0000-0000-00000000000a" + journal.append(record(eventId)) + store.save(store.load("e1")!!.copy(state = EntryState.COMPLETED, settledEventId = eventId)) + scheduler.scheduled.clear() + events.log.clear() + controller.enqueue(parsed()) + assertEquals(listOf("settled:e1:completed"), events.log) + assertEquals(2, events.records.single().deliveries) + assertEquals(2, journal.find(eventId)!!.deliveries) + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + assertEquals(emptyList(), scheduler.scheduled) + } + + @Test + fun `same body on a cancelled unacked entry gets a fresh generation, and the old ack forgets nothing`() { + controller.enqueue(parsed()) + controller.cancel("e1") + val cancelled = journal.unacknowledged().single() + controller.enqueue(parsed()) + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertEquals(2, e.generation) + controller.ack(listOf(cancelled.eventId)) + assertNotNull(store.load("e1")) + } + + @Test + fun `same body on an error entry reopens it and keeps attempts`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(state = EntryState.ERROR, attempts = 3, settledEventId = "ev")) + controller.enqueue(parsed(expiresAt = FAR_FUTURE + 1)) + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertEquals(2, e.generation) + assertEquals(3, e.attempts) + } + + @Test + fun `enqueue over a legacy row replaces it`() { + store.save(LegacyImport.legacyRow(LegacyImport.V9Entry("x", "e1", "completed", 5))!!) + controller.enqueue(parsed()) + val e = store.load("e1")!! + assertFalse(e.legacy) + assertEquals(2, e.generation) + assertEquals(EntryState.QUEUED, e.state) + } + + // MARK: - chunked replace (a present file wins over the old blob) + + private fun chunkedErrorEntry(): File { + val first = source("first.bin", 20) + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = first.path, parts = listOf(part(0, 10), part(10, 20))))) + store.save(store.load("e1")!!.copy(state = EntryState.ERROR, settledEventId = "ev")) + return File(store.entryDir("e1"), "blob") + } + + @Test + fun `a different-parts replace with a present file uploads that file, not the old blob`() { + chunkedErrorEntry() + val bytes = ByteArray(20) { (it * 3).toByte() } + val second = File(tmp.newFolder(), "second.bin").apply { writeBytes(bytes) } + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = second.path, parts = listOf(part(0, 20))))) + val e = store.load("e1")!! + assertEquals(2, e.generation) + assertEquals("blob-2", e.body!!.fileName) + assertArrayEquals(bytes, store.bodyFile(e)!!.readBytes()) + assertEquals(setOf("entry.json", "blob-2"), dirFiles()) // the old blob is pruned + } + + @Test + fun `a different-parts replace with a present file of another size is accepted`() { + chunkedErrorEntry() + val second = source("second.bin", 30) + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = second.path, parts = listOf(part(0, 30))))) + assertEquals(30, store.load("e1")!!.totalBytes) + } + + @Test + fun `a different-parts replace whose file was moved away runs over the old blob`() { + chunkedErrorEntry() + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = "/moved.bin", parts = listOf(part(0, 20))))) + val e = store.load("e1")!! + assertEquals("blob", e.body!!.fileName) + assertEquals(setOf("entry.json", "blob"), dirFiles()) + } + + @Test + fun `a replace whose plan does not tile the present file changes nothing`() { + val oldBlob = chunkedErrorEntry() + val second = source("second.bin", 30) + val e = assertThrows(QueueException::class.java) { + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = second.path, parts = listOf(part(0, 20))))) + } + assertEquals(QueueException.E_INVALID, e.code) + assertTrue(second.exists()) + assertEquals(20, oldBlob.length()) + assertEquals(1, store.load("e1")!!.generation) + } + + // MARK: - staging outside the lock + + @Test + fun `a new file body is copied outside the store lock`() { + val src = source("big.bin", 1000) + var hookRan = false + controller.afterPreStage = { + hookRan = true + assertFalse(Thread.holdsLock(store)) + assertTrue(File(store.entryDir("e1"), "file-1").exists()) + } + controller.enqueue(parsed(descriptor = desc(file = src.path))) + assertTrue(hookRan) + assertEquals("file-1", store.load("e1")!!.body!!.fileName) + assertEquals(setOf("entry.json", "file-1"), dirFiles()) + } + + @Test + fun `a replace over a queued entry stages under the lock`() { + controller.enqueue(parsed()) + var hookRan = false + controller.afterPreStage = { hookRan = true } + controller.enqueue(parsed(descriptor = desc(dataJson = """{"a":2}"""))) + assertFalse(hookRan) + assertEquals(setOf("entry.json", "body-2.json"), dirFiles()) + } + + @Test + fun `a pre-staged body that no longer fits is deleted and staged again under the lock`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(state = EntryState.ERROR)) + // Between the staging and the lock, the entry moves on to another generation. + controller.afterPreStage = { store.save(store.load("e1")!!.copy(generation = 5)) } + controller.enqueue(parsed(descriptor = desc(dataJson = """{"a":2}"""))) + val e = store.load("e1")!! + assertEquals(6, e.generation) + assertEquals("body-6.json", e.body!!.fileName) + assertEquals(setOf("entry.json", "body-6.json"), dirFiles()) // body-2.json is gone + } + + @Test + fun `a pre-staged body is deleted when the enqueue rejects`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(state = EntryState.ERROR)) + controller.afterPreStage = { store.save(store.load("e1")!!.copy(state = EntryState.RUNNING)) } + val e = assertThrows(QueueException::class.java) { controller.enqueue(parsed(descriptor = desc(dataJson = """{"a":2}"""))) } + assertEquals(QueueException.E_RUNNING, e.code) + assertEquals(setOf("entry.json", "body-1.json"), dirFiles()) + } + + // MARK: - v9 adoption + + private fun v9Dir(id: String, parts: String, blobSize: Int): File { + val dir = store.entryDir(id).apply { mkdirs() } + File(dir, "blob").writeBytes(ByteArray(blobSize)) + File(dir, "manifest.json").writeText( + """{"id":"$id","sourcePath":"${File(dir, "blob").path}","parts":$parts,"accept":[],"expiresAt":1,"wifiOnly":false,"noNotification":false,"createdAt":1}""", + ) + return dir + } + + private val v9Parts = """[{"url":"https://example.com/part?start=0","headers":{},"start":0,"end":10,"accepted":true},""" + + """{"url":"https://example.com/part?start=10","headers":{},"start":10,"end":20,"accepted":false}]""" + + @Test + fun `a same-id enqueue with the same parts adopts the v9 blob and accepted parts`() { + v9Dir("e1", v9Parts, 20) + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = "/gone.bin", parts = listOf(part(0, 10), part(10, 20))))) + val e = store.load("e1")!! + assertTrue(e.descriptor!!.parts!![0].accepted) + assertFalse(e.descriptor!!.parts!![1].accepted) + assertEquals(10, e.bytesSent) + assertEquals(setOf("entry.json", "blob"), dirFiles()) + } + + @Test + fun `a v9 adoption with the same parts keeps the v9 blob even when the caller's file is present`() { + v9Dir("e1", v9Parts, 20) + val present = source("again.bin", 20) + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = present.path, parts = listOf(part(0, 10), part(10, 20))))) + assertTrue(present.exists()) + assertTrue(store.load("e1")!!.descriptor!!.parts!![0].accepted) + assertArrayEquals(ByteArray(20), File(store.entryDir("e1"), "blob").readBytes()) + } + + @Test + fun `a v9 adoption with different parts and a present file uploads that file`() { + v9Dir("e1", v9Parts, 20) + val present = source("new.bin", 30) + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = present.path, parts = listOf(part(0, 30))))) + assertFalse(present.exists()) + val e = store.load("e1")!! + assertFalse(e.descriptor!!.parts!![0].accepted) + assertEquals(30, store.bodyFile(e)!!.length()) + assertEquals(setOf("entry.json", "blob"), dirFiles()) + } + + @Test + fun `a v9 adoption with parts that do not tile rejects E_INVALID and keeps the v9 files`() { + v9Dir("e1", v9Parts, 20) + val e = assertThrows(QueueException::class.java) { + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = "/gone.bin", parts = listOf(part(0, 30))))) + } + assertEquals(QueueException.E_INVALID, e.code) + assertEquals(setOf("manifest.json", "blob"), dirFiles()) + } + + // MARK: - cancel + + @Test + fun `cancel of a live entry journals, settles cancelled, stops work, emits, and forgets after ack`() { + controller.enqueue(parsed()) + events.log.clear() + controller.cancel("e1") + val record = journal.unacknowledged().single() + assertEquals(EventJournal.KIND_CANCELLED, record.kind) + assertEquals("user", record.cancelReason) + assertEquals("https://example.com/items", record.url) + val e = store.load("e1")!! + assertEquals(EntryState.CANCELLED, e.state) + assertEquals(record.eventId, e.settledEventId) + assertEquals(listOf("e1"), scheduler.cancelled) + assertEquals(listOf("settled:e1:cancelled", "state:e1:cancelled"), events.log) + controller.ack(listOf(record.eventId)) + assertNull(store.load("e1")) + assertFalse(store.entryDir("e1").exists()) + } + + @Test + fun `cancel of a settled entry forgets it now and keeps its unacked record`() { + controller.enqueue(parsed()) + val eventId = "00000000-0000-0000-0000-00000000000b" + journal.append(record(eventId, kind = EventJournal.KIND_ERROR)) + store.save(store.load("e1")!!.copy(state = EntryState.ERROR, settledEventId = eventId)) + events.log.clear() + controller.cancel("e1") + assertNull(store.load("e1")) + assertFalse(store.entryDir("e1").exists()) + assertEquals(emptyList(), events.log) + assertNotNull(journal.find(eventId)) + } + + @Test + fun `cancel of an unknown id is a no-op`() { + controller.cancel("nope") + assertEquals(emptyList(), events.log) + } + + // MARK: - pause, resume, wifi, headers + + @Test + fun `pause moves live rows to paused with no outcome, and resume brings them back`() { + store.save(entry(id = "q", state = EntryState.QUEUED)) + store.save(entry(id = "r", state = EntryState.RUNNING)) + store.save(entry(id = "a", state = EntryState.AWAITING_AUTH, parkedGeneration = 0)) + store.save(entry(id = "s", state = EntryState.AWAITING_AUTH, parkedGeneration = 0)) + store.save(entry(id = "x", state = EntryState.ERROR)) + controller.pause() + assertTrue(settings.load().paused) + assertEquals(listOf("paused", "paused", "paused", "paused", "error"), listOf("q", "r", "a", "s", "x").map { store.load(it)!!.state.wire }) + assertEquals(setOf("q", "r", "a", "s"), scheduler.cancelled.toSet()) + assertEquals(emptyList(), journal.unacknowledged()) + + // A header change while paused makes one parked entry's generation stale. + store.save(store.load("s")!!.copy(parkedGeneration = -1)) + controller.resume() + assertFalse(settings.load().paused) + assertEquals(EntryState.QUEUED, store.load("q")!!.state) + assertEquals(EntryState.QUEUED, store.load("r")!!.state) + assertEquals(EntryState.AWAITING_AUTH, store.load("a")!!.state) + assertEquals(EntryState.QUEUED, store.load("s")!!.state) + assertTrue(scheduler.scheduled.containsAll(listOf("q", "r", "s"))) + assertEquals(listOf("a" to FAR_FUTURE), scheduler.wakes) // the parked one waits for its expiry + } + + @Test + fun `setWifiOnly persists`() { + controller.setWifiOnly(true) + assertTrue(QueueSettingsStore(File(root, "settings.json")).load().wifiOnly) + } + + @Test + fun `updateHeaders patches every entry, bumps the generation, and requeues the parked`() { + store.save(entry(id = "p", state = EntryState.AWAITING_AUTH, parkedGeneration = 0)) + store.save(entry(id = "x", state = EntryState.ERROR)) + store.save(entry(id = "c", state = EntryState.QUEUED, descriptor = desc(url = null, file = "/f", + parts = listOf(Part("https://p/1", mapOf("authorization" to "stale"), 0, 10))))) + controller.updateHeaders(mapOf("Authorization" to "Bearer new")) + assertEquals(1, settings.load().headerGeneration) + val p = store.load("p")!! + assertEquals(EntryState.QUEUED, p.state) + assertNull(p.parkedGeneration) + assertEquals("Bearer new", p.descriptor!!.headers["Authorization"]) + assertEquals(1, p.headerGeneration) + assertEquals("Bearer new", store.load("x")!!.descriptor!!.headers["Authorization"]) + assertEquals(mapOf("Authorization" to "Bearer new"), store.load("c")!!.descriptor!!.parts!![0].headers) + assertEquals(listOf("p"), scheduler.scheduled) + assertEquals(listOf("state:p:queued"), events.log) + } + + // MARK: - ack and replay + + @Test + fun `ack forgets a completed entry of the current generation only`() { + val current = "00000000-0000-0000-0000-00000000000c" + val old = "00000000-0000-0000-0000-00000000000d" + store.save(entry(state = EntryState.COMPLETED, settledEventId = current, generation = 2)) + journal.append(record(old, generation = 1)) + journal.append(record(current, generation = 2)) + controller.ack(listOf(old, "unknown", "../../x")) + assertNotNull(store.load("e1")) + controller.ack(listOf(current)) + assertNull(store.load("e1")) + assertEquals(listOf("e1"), scheduler.cancelled) + controller.ack(listOf(current)) // idempotent + } + + @Test + fun `ack of an error removes the record and keeps the row`() { + val id = "00000000-0000-0000-0000-00000000000e" + store.save(entry(state = EntryState.ERROR, settledEventId = id)) + journal.append(record(id, kind = EventJournal.KIND_ERROR)) + controller.ack(listOf(id)) + assertNull(journal.find(id)) + assertNotNull(store.load("e1")) + } + + // A settle journaled and emitted its record, but the store write failed: + // the entry is still live at the record's generation. + + @Test + fun `ack of a completed record on a still-running entry settles and forgets it, so the sweep does not re-run it`() { + val id = "00000000-0000-0000-0000-000000000011" + store.save(entry(state = EntryState.RUNNING)) + journal.append(record(id)) + controller.ack(listOf(id)) + assertNull(store.load("e1")) + assertNull(journal.find(id)) + controller.sweep() + assertEquals(emptyList(), scheduler.scheduled) + } + + @Test + fun `ack of an error record on a still-running entry settles it as error and keeps the row`() { + val id = "00000000-0000-0000-0000-000000000012" + store.save(entry(state = EntryState.RUNNING)) + journal.append(record(id, kind = EventJournal.KIND_ERROR)) + controller.ack(listOf(id)) + val e = store.load("e1")!! + assertEquals(EntryState.ERROR, e.state) + assertEquals(id, e.settledEventId) + assertNull(journal.find(id)) + assertEquals(listOf("state:e1:error"), events.log) + controller.sweep() + assertEquals(emptyList(), scheduler.scheduled) + } + + @Test + fun `ack of a record of an older generation does not settle the live entry`() { + val id = "00000000-0000-0000-0000-000000000013" + store.save(entry(state = EntryState.QUEUED, generation = 2)) + journal.append(record(id, generation = 1)) + controller.ack(listOf(id)) + assertEquals(EntryState.QUEUED, store.load("e1")!!.state) + assertNull(journal.find(id)) + } + + @Test + fun `when the ack repair can not save, the record stays for the sweep`() { + val id = "00000000-0000-0000-0000-000000000014" + store.save(entry(state = EntryState.RUNNING)) + journal.append(record(id)) + val dir = store.entryDir("e1") + dir.setWritable(false) + try { + controller.ack(listOf(id)) + } finally { + dir.setWritable(true) + } + assertNotNull(journal.find(id)) + assertEquals(EntryState.RUNNING, store.load("e1")!!.state) + controller.sweep() + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + } + + @Test + fun `each replay counts one more delivery`() { + journal.append(record("00000000-0000-0000-0000-00000000000f")) + assertEquals(2, controller.unacknowledged().single().deliveries) + assertEquals(3, controller.unacknowledged().single().deliveries) + } + + // MARK: - boot sweep + + @Test + fun `sweep applies a record that the store transition missed`() { + val id = "00000000-0000-0000-0000-000000000010" + store.save(entry(state = EntryState.RUNNING)) + journal.append(record(id)) + controller.sweep() + val e = store.load("e1")!! + assertEquals(EntryState.COMPLETED, e.state) + assertEquals(id, e.settledEventId) + assertEquals(listOf("state:e1:completed"), events.log) + } + + @Test + fun `sweep applies a cancel whose store save was lost`() { + val id = "00000000-0000-0000-0000-000000000011" + store.save(entry(state = EntryState.QUEUED)) + journal.append(record(id, kind = EventJournal.KIND_CANCELLED)) + controller.sweep() + assertEquals(EntryState.CANCELLED, store.load("e1")!!.state) + assertEquals(emptyList(), scheduler.scheduled) + } + + @Test + fun `sweep queues a running entry with no worker and schedules queued work`() { + store.save(entry(id = "r", state = EntryState.RUNNING)) + store.save(entry(id = "q", state = EntryState.QUEUED)) + store.save(entry(id = "w", state = EntryState.QUEUED, nextAttemptAt = now + 3_600_000)) + store.save(entry(id = "p", state = EntryState.AWAITING_AUTH, expiresAt = now + 50)) + controller.sweep() + assertEquals(EntryState.QUEUED, store.load("r")!!.state) + assertEquals(setOf("r", "q"), scheduler.scheduled.toSet()) + assertEquals(setOf("w" to now + 3_600_000, "p" to now + 50), scheduler.wakes.toSet()) + } + + @Test + fun `sweep skips an entry whose worker runs in this process`() { + store.save(entry(state = EntryState.RUNNING)) + journal.append(record("00000000-0000-0000-0000-000000000012")) + running += "e1" + controller.sweep() + assertEquals(EntryState.RUNNING, store.load("e1")!!.state) + } + + @Test + fun `sweep forgets a completed entry whose record was acked, and acks orphans`() { + store.save(entry(id = "done", state = EntryState.COMPLETED, settledEventId = "gone")) + val own = "00000000-0000-0000-0000-000000000013" + val orphan = "00000000-0000-0000-0000-000000000014" + store.save(entry(id = "c", state = EntryState.CANCELLED, settledEventId = own)) + journal.append(record(own, id = "c", kind = EventJournal.KIND_CANCELLED)) + journal.append(record(orphan, id = "c")) + controller.sweep() + assertNull(store.load("done")) + assertEquals(listOf(own), journal.unacknowledged().map { it.eventId }) + assertNotNull(store.load("c")) + } + + @Test + fun `sweep leaves paused entries alone`() { + controller.pause() + store.save(entry(state = EntryState.PAUSED)) + controller.sweep() + assertEquals(EntryState.PAUSED, store.load("e1")!!.state) + assertEquals(emptyList(), scheduler.scheduled) + } + + @Test + fun `sweep finishes a pause or resume that a process death cut short`() { + // resume() saved the setting, then died before the rows. + store.save(entry(id = "p", state = EntryState.PAUSED)) + controller.sweep() + assertEquals(EntryState.QUEUED, store.load("p")!!.state) + assertEquals(listOf("p"), scheduler.scheduled) + + // pause() saved the setting, then died before the rows. + settings.update { it.copy(paused = true) } + store.save(entry(id = "q", state = EntryState.QUEUED)) + store.save(entry(id = "r", state = EntryState.RUNNING)) + controller.sweep() + assertEquals(EntryState.PAUSED, store.load("q")!!.state) + assertEquals(EntryState.PAUSED, store.load("r")!!.state) + assertEquals(listOf("p"), scheduler.scheduled) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/QueueEntryTest.kt b/android/src/test/java/ai/openspace/backgroundupload/QueueEntryTest.kt new file mode 100644 index 00000000..c0b286b3 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/QueueEntryTest.kt @@ -0,0 +1,103 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class QueueEntryTest { + + private val form = listOf(FormPart("photo", "image/jpeg", null, "/p.jpg", null)) + + @Test + fun `sameBodyAs compares each body kind by content`() { + val json = entry(descriptor = desc(dataJson = "{\"a\":1}")) + assertTrue(json.sameBodyAs(desc(dataJson = "{\"a\":1}", headers = mapOf("New" to "h")))) + assertFalse(json.sameBodyAs(desc(dataJson = "{\"a\":2}"))) + + val multipart = entry(descriptor = desc(form = form)) + assertTrue(multipart.sameBodyAs(desc(form = form))) + assertFalse(multipart.sameBodyAs(desc(form = form.map { it.copy(name = "other") }))) + + val file = entry(descriptor = desc(file = "/a.bin")) + assertTrue(file.sameBodyAs(desc(file = "/a.bin"))) + assertFalse(file.sameBodyAs(desc(file = "/b.bin"))) + + val none = entry(descriptor = desc(method = "DELETE")) + assertTrue(none.sameBodyAs(desc(method = "DELETE"))) + } + + @Test + fun `chunked compares parts and ignores the path`() { + val parts = listOf(part(0, 100), part(100, 250)) + val chunked = entry(descriptor = desc(url = null, method = "PUT", file = "/moved.bin", parts = parts)) + assertTrue(chunked.sameBodyAs(desc(url = null, method = "PUT", file = "/elsewhere.bin", parts = parts.reversed()))) + assertFalse(chunked.sameBodyAs(desc(url = null, method = "PUT", file = "/moved.bin", parts = listOf(part(0, 250))))) + } + + @Test + fun `a kind change, url change, or method change is a different body`() { + val json = entry(descriptor = desc(dataJson = "{}")) + assertFalse(json.sameBodyAs(desc(form = form))) + assertFalse(json.sameBodyAs(desc(url = "https://example.com/other", dataJson = "{}"))) + assertFalse(json.sameBodyAs(desc(method = "PUT", dataJson = "{}"))) + assertFalse(entry(descriptor = null, legacy = true).sameBodyAs(desc(dataJson = "{}"))) + } + + @Test + fun `withHeadersPatched matches names in any case and keeps the patch spelling`() { + val e = entry(descriptor = desc(headers = mapOf("authorization" to "Bearer old", "X-Keep" to "1"))) + val patched = e.withHeadersPatched(mapOf("Authorization" to "Bearer new", "X-Add" to "2"), generation = 3) + assertEquals( + mapOf("X-Keep" to "1", "Authorization" to "Bearer new", "X-Add" to "2"), + patched.descriptor!!.headers, + ) + assertEquals(3, patched.headerGeneration) + } + + @Test + fun `withHeadersPatched replaces a part's own copy of a patched header only`() { + val parts = listOf( + Part("https://p/1", mapOf("AUTHORIZATION" to "Bearer stale", "Content-Range" to "0-99"), 0, 100), + Part("https://p/2", mapOf("Content-Range" to "100-249"), 100, 250), + ) + val e = entry(descriptor = desc(url = null, file = "/f", parts = parts)) + val patched = e.withHeadersPatched(mapOf("Authorization" to "Bearer new"), 1).descriptor!!.parts!! + assertEquals(mapOf("Content-Range" to "0-99", "Authorization" to "Bearer new"), patched[0].headers) + assertEquals(mapOf("Content-Range" to "100-249"), patched[1].headers) + } + + @Test + fun `toRow carries vars as an object and nextAttemptAt only when set`() { + val row = entry().toRow().toMap() + assertEquals(mapOf("n" to 1.0), row["vars"]) + assertEquals("queued", row["state"]) + assertFalse(row.containsKey("nextAttemptAt")) + assertEquals( + setOf("id", "key", "vars", "state", "bytesSent", "totalBytes", "attempts", "updatedAt"), + row.keys, + ) + val waiting = entry(nextAttemptAt = 9_000).toRow().toMap() + assertEquals(9_000.0, waiting["nextAttemptAt"]) + } + + @Test + fun `a malformed vars text reads as null in the row`() { + assertNull(entry().copy(varsJson = "{bad").toRow().vars) + } + + @Test + fun `isLive covers the four live states`() { + val live = EntryState.values().filter { it.isLive }.toSet() + assertEquals(setOf(EntryState.QUEUED, EntryState.RUNNING, EntryState.AWAITING_AUTH, EntryState.PAUSED), live) + } + + @Test + fun `header maps match names without regard to case`() { + assertTrue(HeaderMap.contains(mapOf("Content-Type" to "x"), "content-type")) + assertEquals("x", HeaderMap.get(mapOf("content-type" to "x"), "Content-Type")) + assertEquals(mapOf("B" to "2", "a" to "3"), HeaderMap.merge(mapOf("A" to "1", "B" to "2"), mapOf("a" to "3"))) + assertEquals(mapOf("B" to "2"), HeaderMap.without(mapOf("a" to "1", "B" to "2"), "A")) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/QueueSettingsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/QueueSettingsTest.kt new file mode 100644 index 00000000..6dbcfff7 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/QueueSettingsTest.kt @@ -0,0 +1,68 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class QueueSettingsTest { + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `defaults when there is no file`() { + val s = QueueSettingsStore(File(tmp.newFolder(), "settings.json")).load() + assertEquals(QueueSettings(), s) + assertEquals(RetryDefaults(1_000, 7_200_000, 0.2, listOf(404)), s.retry) + } + + @Test + fun `update persists and reloads in a new instance`() { + val file = File(tmp.newFolder(), "settings.json") + QueueSettingsStore(file).update { it.copy(wifiOnly = true, paused = true) } + val reloaded = QueueSettingsStore(file).load() + assertTrue(reloaded.wifiOnly) + assertTrue(reloaded.paused) + } + + @Test + fun `a corrupt file reads as the defaults`() { + val file = File(tmp.newFolder(), "settings.json").apply { writeText("{not json") } + assertEquals(QueueSettings(), QueueSettingsStore(file).load()) + } + + @Test + fun `a file missing fields keeps the defaults for them`() { + val file = File(tmp.newFolder(), "settings.json").apply { writeText("""{"wifiOnly":true}""") } + val s = QueueSettingsStore(file).load() + assertTrue(s.wifiOnly) + assertFalse(s.paused) + assertEquals(RetryDefaults(), s.retry) + } + + @Test + fun `header generation increments`() { + val store = QueueSettingsStore(File(tmp.newFolder(), "settings.json")) + assertEquals(1, store.update { it.copy(headerGeneration = it.headerGeneration + 1) }.headerGeneration) + assertEquals(2, store.update { it.copy(headerGeneration = it.headerGeneration + 1) }.headerGeneration) + } + + @Test + fun `a failed write keeps the old value`() { + // A regular file where the directory should be: the write fails. + val notADir = tmp.newFile() + val store = QueueSettingsStore(File(notADir, "settings.json")) + runCatching { store.update { it.copy(paused = true) } } + assertFalse(store.load().paused) + } + + @Test + fun `configure retry fills absent fields with the library defaults`() { + val d = QueueSettingsStore.retryDefaults(mapOf("backoff" to mapOf("baseMs" to 500.0), "terminalHttp" to mapOf("exempt" to listOf()))) + assertEquals(RetryDefaults(baseMs = 500, exempt = emptyList()), d) + assertEquals(RetryDefaults(), QueueSettingsStore.retryDefaults(null)) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/QueueStoreTest.kt b/android/src/test/java/ai/openspace/backgroundupload/QueueStoreTest.kt new file mode 100644 index 00000000..a4ae7232 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/QueueStoreTest.kt @@ -0,0 +1,245 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class QueueStoreTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun store(dir: File = tmp.newFolder(), index: RequestIndex = RequestIndex()) = QueueStore(dir, index) + + @Test + fun `save then load round-trips across store instances`() { + val dir = tmp.newFolder() + val e = entry( + descriptor = desc(url = null, method = "PUT", file = "/f", parts = listOf(part(0, 10, accepted = true), part(10, 20))), + body = StagedBody(StagedBody.CHUNKED, "blob", null, 20), + nextAttemptAt = 5, + parkedGeneration = 2, + ) + store(dir).save(e) + // A new instance over the same dir is what a process relaunch looks like. + assertEquals(e, store(dir).load("e1")) + } + + @Test + fun `the state enum is stored by its wire string`() { + val s = store() + s.save(entry(state = EntryState.AWAITING_AUTH)) + assertTrue(File(s.entryDir("e1"), QueueStore.ENTRY_FILE).readText().contains("\"awaiting-auth\"")) + } + + @Test + fun `ids with filesystem-hostile characters round-trip`() { + val s = store() + val id = "a/b:c dü..\\e" + s.save(entry(id = id)) + assertEquals(id, s.load(id)!!.id) + s.remove(id) + assertNull(s.load(id)) + } + + @Test + fun `a corrupt entry reads as absent`() { + val s = store() + s.save(entry()) + File(s.entryDir("e1"), QueueStore.ENTRY_FILE).writeText("{not json") + assertNull(s.load("e1")) + assertEquals(emptyList(), s.all()) + } + + @Test + fun `an entry missing a required field reads as absent`() { + val s = store() + s.entryDir("e1").mkdirs() + File(s.entryDir("e1"), QueueStore.ENTRY_FILE).writeText("""{"id":"e1","key":"k","state":"queued"}""") + assertNull(s.load("e1")) // not legacy, and no descriptor or body + } + + @Test + fun `an older file gets safe defaults`() { + val s = store() + s.entryDir("e1").mkdirs() + File(s.entryDir("e1"), QueueStore.ENTRY_FILE).writeText( + """{"id":"e1","key":"k","state":"queued","descriptor":{"url":"https://x"},"body":{"kind":"none","totalBytes":0}}""", + ) + val e = s.load("e1")!! + assertEquals("null", e.varsJson) + assertEquals("POST", e.descriptor!!.method) + assertEquals(emptyMap(), e.descriptor!!.headers) + assertEquals(1, e.generation) + } + + @Test + fun `all skips v9-only directories and legacyManifest reads them`() { + val s = store() + val dir = s.entryDir("v9").apply { mkdirs() } + File(dir, QueueStore.V9_MANIFEST_FILE).writeText( + """{"id":"v9","sourcePath":"/x/blob","parts":[{"url":"https://p/1","headers":{},"start":0,"end":10,"accepted":true}],""" + + """"accept":[{"status":409}],"expiresAt":99,"wifiOnly":false,"noNotification":true,"createdAt":1}""", + ) + s.save(entry(id = "v10")) + assertEquals(listOf("v10"), s.all().map { it.id }) + val m = s.legacyManifest("v9")!! + assertEquals(listOf(Part("https://p/1", mapOf(), 0, 10, accepted = true)), m.parts) + assertEquals(listOf(UploadOutcome.AcceptRule(409)), m.accept) + assertNull(s.legacyManifest("v10")) + assertNull(s.legacyManifest("nope")) + } + + @Test + fun `compute holds the lock across load, transform, and save`() { + // A module transition and a worker's update race. If the lock did not + // span all three steps, the update could land between load and save and + // be erased. Serialized, both effects survive. + val s = store() + s.save(entry(descriptor = desc(url = null, file = "/f", parts = listOf(part(0, 10), part(10, 20))))) + val inTransform = CountDownLatch(1) + val computing = Thread { + s.compute("e1") { e -> + inTransform.countDown() + Thread.sleep(300) + e!!.copy(expiresAt = 99_000) + } + }.apply { start() } + assertTrue(inTransform.await(5, TimeUnit.SECONDS)) + val updating = Thread { + s.update("e1") { e -> e.copy(descriptor = e.descriptor!!.copy(parts = ChunkedParts.withAccepted(e.descriptor.parts!!, 0))) } + }.apply { start() } + computing.join() + updating.join() + val final = s.load("e1")!! + assertEquals(99_000, final.expiresAt) + assertTrue(final.descriptor!!.parts!![0].accepted) + } + + @Test + fun `a throwing transform writes nothing`() { + val s = store() + s.save(entry()) + assertThrows(IllegalStateException::class.java) { s.compute("e1") { throw IllegalStateException("no") } } + assertEquals(entry(), s.load("e1")) + } + + @Test + fun `compute returning the same object or null writes nothing`() { + val s = store() + assertNull(s.compute("e1") { null }) + assertFalse(s.entryDir("e1").exists() && File(s.entryDir("e1"), QueueStore.ENTRY_FILE).exists()) + s.save(entry()) + val file = File(s.entryDir("e1"), QueueStore.ENTRY_FILE) + file.setLastModified(1_000) + s.compute("e1") { it } + assertEquals(1_000, file.lastModified()) + } + + @Test + fun `remove deletes the row and every staged byte`() { + val index = RequestIndex() + val s = store(index = index) + s.save(entry()) + File(s.entryDir("e1"), "body-1.json").writeText("{}") + File(s.entryDir("e1"), "blob").writeText("bytes") + s.remove("e1") + assertNull(s.load("e1")) + assertFalse(s.entryDir("e1").exists()) + assertNull(index.get("e1")) + } + + @Test + fun `the index follows every save and remove, and loads on start`() { + val dir = tmp.newFolder() + val index = RequestIndex() + val s = store(dir, index) + s.save(entry(id = "a")) + s.save(entry(id = "b", state = EntryState.ERROR)) + assertEquals(listOf("a", "b"), index.snapshot().map { it.id }) + s.remove("a") + assertEquals(listOf("b"), index.snapshot().map { it.id }) + // A process relaunch: a fresh index loaded from disk. + val fresh = RequestIndex() + QueueStore(dir, fresh).loadIndex() + assertEquals("error", fresh.get("b")!!.state) + } + + @Test + fun `pruneUnreferenced keeps only the entry file and its body`() { + val s = store() + val e = entry(body = StagedBody(StagedBody.JSON, "body-2.json", null, 2)) + s.save(e) + val dir = s.entryDir("e1") + listOf("body-1.json", "body-2.json", "blob", "manifest.json", "entry.json.tmp").forEach { File(dir, it).writeText("x") } + s.pruneUnreferenced(e) + assertEquals(setOf("entry.json", "body-2.json"), dir.list()!!.toSet()) + } + + // MARK: - crash mid-write + + @Test + fun `crash mid-write (a) a partial entry tmp next to a valid entry`() { + val s = store() + s.save(entry(attempts = 1)) + // The process died while writing the next version: only the tmp is partial. + File(s.entryDir("e1"), "entry.json.tmp").writeText("""{"id":"e1","key":"no""") + assertEquals(1, s.load("e1")!!.attempts) + s.save(entry(attempts = 2)) + assertEquals(2, s.load("e1")!!.attempts) + assertFalse(File(s.entryDir("e1"), "entry.json.tmp").exists()) + } + + @Test + fun `crash mid-write (b) a staged body with no entry is not a row`() { + val s = store() + val dir = s.entryDir("e1").apply { mkdirs() } + File(dir, "body-1.json.tmp").writeText("{\"a\":") + File(dir, "body-1.json").writeText("{\"a\":1}") + assertEquals(emptyList(), s.all()) + assertNull(s.load("e1")) + // The next create saves an entry and prunes what it does not use. + val e = entry(body = StagedBody(StagedBody.JSON, "body-1.json", null, 7)) + s.save(e) + s.pruneUnreferenced(e) + assertEquals(setOf("entry.json", "body-1.json"), dir.list()!!.toSet()) + } + + @Test + fun `crash mid-write (c) a write that throws leaves the old target intact`() { + val target = File(tmp.newFolder(), "entry.json").apply { writeText("old") } + assertThrows(IOException::class.java) { + AtomicFiles.writeAtomically(target) { out -> + out.write("new, half".toByteArray()) + throw IOException("disk full") + } + } + assertEquals("old", target.readText()) + assertFalse(AtomicFiles.tmpFor(target).exists()) + } + + @Test + fun `a save into an unwritable directory throws`() { + val notADir = tmp.newFile() + val s = QueueStore(notADir, RequestIndex()) + assertThrows(IOException::class.java) { s.save(entry()) } + } + + @Test + fun `update is best effort`() { + val s = store() + assertNull(s.update("nope") { it }) + s.save(entry()) + assertNotNull(s.update("e1") { it.copy(attempts = 3) }) + assertEquals(3, s.load("e1")!!.attempts) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/RetryClassifierTest.kt b/android/src/test/java/ai/openspace/backgroundupload/RetryClassifierTest.kt new file mode 100644 index 00000000..cb21bf72 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/RetryClassifierTest.kt @@ -0,0 +1,102 @@ +package ai.openspace.backgroundupload + +import ai.openspace.backgroundupload.RetryClassifier.Verdict +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import kotlin.random.Random + +class RetryClassifierTest { + private val defaultExempt = listOf(404) + + private fun classify(code: Int, body: String = "", accept: List = emptyList(), exempt: List = defaultExempt) = + RetryClassifier.classifyResponse(code, body, accept, exempt) + + @Test + fun `the retry table`() { + assertEquals(Verdict.Accepted, classify(200)) + assertEquals(Verdict.Accepted, classify(204)) + assertEquals(Verdict.Accepted, classify(409, "upload already completed", listOf(UploadOutcome.AcceptRule(409, "already completed")))) + assertEquals(Verdict.Terminal("http", "HTTP 409"), classify(409, "conflict", listOf(UploadOutcome.AcceptRule(409, "already completed")))) + assertEquals(Verdict.Auth, classify(401)) + assertEquals(Verdict.Auth, classify(403)) + assertEquals(Verdict.Transient, classify(408)) + assertEquals(Verdict.Transient, classify(429)) + assertEquals(Verdict.Transient, classify(500)) + assertEquals(Verdict.Transient, classify(599)) + assertEquals(Verdict.Transient, classify(404)) // default exempt + assertEquals(Verdict.Terminal("http", "HTTP 400"), classify(400)) + assertEquals(Verdict.Terminal("http", "HTTP 409"), classify(409)) + assertEquals(Verdict.Terminal("http", "HTTP 304"), classify(304)) + assertEquals(Verdict.Terminal("http", "HTTP 101"), classify(101)) + } + + @Test + fun `a chunked part 404 with exempt empty is terminal`() { + assertEquals(Verdict.Terminal("http", "HTTP 404"), classify(404, exempt = emptyList())) + } + + @Test + fun `an auth status stays auth even when exempt lists it`() { + assertEquals(Verdict.Auth, classify(401, exempt = listOf(401))) + } + + @Test + fun `transport failures`() { + assertEquals(Verdict.Transient, RetryClassifier.classifyFailure(IOException("reset"), fileExists = true)) + val file = RetryClassifier.classifyFailure(IOException("ENOENT"), fileExists = false) + assertTrue(file is Verdict.Terminal && file.errorKind == "file") + val other = RetryClassifier.classifyFailure(IllegalArgumentException("bad url"), fileExists = true) + assertEquals(Verdict.Terminal("unknown", "bad url"), other) + assertEquals("network", RetryClassifier.failureKind(IOException(), true)) + } + + private val policy = RetryClassifier.Policy(baseMs = 1_000, maxMs = 7_200_000, jitter = 0.0, exempt = defaultExempt) + + @Test + fun `backoff doubles from base and caps at max`() { + assertEquals(1_000, RetryClassifier.backoffMs(policy, 1)) + assertEquals(2_000, RetryClassifier.backoffMs(policy, 2)) + assertEquals(4_000, RetryClassifier.backoffMs(policy, 3)) + assertEquals(7_200_000, RetryClassifier.backoffMs(policy, 14)) + assertEquals(7_200_000, RetryClassifier.backoffMs(policy, 10_000)) + assertEquals(1_000, RetryClassifier.backoffMs(policy, 0)) // defensive + } + + @Test + fun `jitter stays within bounds and under max`() { + val jittered = policy.copy(jitter = 0.2) + val random = Random(42) + repeat(1_000) { + val ms = RetryClassifier.backoffMs(jittered, 3, random) + assertTrue("$ms", ms in 3_200..4_800) + } + repeat(1_000) { + assertTrue(RetryClassifier.backoffMs(jittered, 30, random) <= 7_200_000) + } + } + + @Test + fun `nextAttemptAt clamps to expiresAt`() { + assertEquals(3_000, RetryClassifier.nextAttemptAt(now = 1_000, backoffMs = 2_000, expiresAt = 10_000)) + assertEquals(10_000, RetryClassifier.nextAttemptAt(now = 1_000, backoffMs = 7_200_000, expiresAt = 10_000)) + } + + @Test + fun `expiry is inclusive of the deadline`() { + assertFalse(RetryClassifier.isExpired(4_999, 5_000)) + assertTrue(RetryClassifier.isExpired(5_000, 5_000)) + } + + @Test + fun `policy overrides field by field`() { + val defaults = RetryDefaults() + assertEquals(RetryClassifier.Policy(1_000, 7_200_000, 0.2, listOf(404)), RetryClassifier.policy(defaults, null)) + assertEquals( + RetryClassifier.Policy(50, 7_200_000, 0.2, emptyList()), + RetryClassifier.policy(defaults, RetryOverride(baseMs = 50, maxMs = null, jitter = null, exempt = emptyList())), + ) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/SmallPartsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/SmallPartsTest.kt new file mode 100644 index 00000000..f133bc9e --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/SmallPartsTest.kt @@ -0,0 +1,167 @@ +package ai.openspace.backgroundupload + +import androidx.work.WorkInfo.State.BLOCKED +import androidx.work.WorkInfo.State.CANCELLED +import androidx.work.WorkInfo.State.ENQUEUED +import androidx.work.WorkInfo.State.FAILED +import androidx.work.WorkInfo.State.RUNNING +import androidx.work.WorkInfo.State.SUCCEEDED +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AttemptEventTest { + private val response = UploadResponse(401, "x".repeat(5_000), mapOf("a" to "b")) + + @Test + fun `the body is cut at 4 KB and flagged`() { + val e = AttemptEvent.ofResponse(entry(attempts = 2), "r1", "https://x", null, response, accepted = false, at = 7) + assertEquals(AttemptEvent.MAX_BODY_CHARS, e.responseBody!!.length) + assertEquals(true, e.responseBodyTruncated) + assertEquals(2, e.attempt) + } + + @Test + fun `outcome is completed only when accepted`() { + val rejected = AttemptEvent.ofResponse(entry(), "r1", "https://x", null, response, accepted = false, at = 7) + assertEquals("error", rejected.outcome) + assertEquals(401, rejected.httpCode) + assertEquals("http", rejected.errorKind) + val ok = AttemptEvent.ofResponse(entry(), "r1", "https://x", 3, response.copy(code = 200, body = "ok"), accepted = true, at = 7) + assertEquals("completed", ok.outcome) + assertNull(ok.errorKind) + assertEquals(3.0, ok.toMap()["partIndex"]) + } + + @Test + fun `partIndex and response fields are optional in the map`() { + val failure = AttemptEvent.ofFailure(entry(), "r1", "https://x", null, "network", "reset", 7).toMap() + assertEquals( + setOf("id", "key", "requestId", "attempt", "url", "method", "outcome", "errorKind", "errorMessage", "at"), + failure.keys, + ) + } +} + +class ProgressThrottleTest { + private var now = 0L + private val emitted = mutableListOf() + private val throttle = ProgressThrottle({ now }) { _, sent, _ -> emitted += sent } + + @Test + fun `the first offer emits, then at most one per second in the foreground`() { + throttle.offer("a", 1, 10, foreground = true) + now = 500 + throttle.offer("a", 2, 10, foreground = true) + assertEquals(listOf(1L), emitted) + now = 1_000 + throttle.offer("a", 3, 10, foreground = true) + assertEquals(listOf(1L, 3L), emitted) + } + + @Test + fun `the background interval is 10 minutes`() { + throttle.offer("a", 1, 10, foreground = false) + now = 599_999 + throttle.offer("a", 2, 10, foreground = false) + assertEquals(listOf(1L), emitted) + now = 600_000 + throttle.offer("a", 3, 10, foreground = false) + assertEquals(listOf(1L, 3L), emitted) + } + + @Test + fun `flush sends the held value once`() { + throttle.offer("a", 1, 10, foreground = true) + throttle.offer("a", 2, 10, foreground = true) + throttle.flush("a") + throttle.flush("a") + assertEquals(listOf(1L, 2L), emitted) + } + + @Test + fun `ids are independent, and drop forgets an id`() { + throttle.offer("a", 1, 10, foreground = true) + throttle.offer("b", 5, 10, foreground = true) + assertEquals(listOf(1L, 5L), emitted) + throttle.offer("a", 2, 10, foreground = true) + throttle.drop("a") + throttle.flush("a") + assertEquals(listOf(1L, 5L), emitted) + } +} + +class RequestIndexTest { + @Test + fun `put, remove, and snapshot order`() { + val index = RequestIndex() + index.put(entry(id = "b", createdAt = 2).toRow()) + index.put(entry(id = "a", createdAt = 2).toRow()) + index.put(entry(id = "c", createdAt = 1).toRow()) + assertEquals(listOf("c", "a", "b"), index.snapshot().map { it.id }) + index.remove("a") + assertEquals(listOf("c", "b"), index.snapshot().map { it.id }) + } + + @Test + fun `setBytes on a missing id is a no-op`() { + val index = RequestIndex() + index.setBytes("nope", 5) + assertNull(index.get("nope")) + } + + @Test + fun `a save of a running entry does not move bytes backwards`() { + val index = RequestIndex() + val running = entry(state = EntryState.RUNNING, body = StagedBody(StagedBody.FILE, "f", null, 100)) + index.put(running.toRow()) + index.setBytes("e1", 60) + index.put(running.copy(attempts = 2).toRow()) + assertEquals(60, index.get("e1")!!.bytesSent) + index.put(running.copy(state = EntryState.QUEUED).toRow()) + assertEquals(0, index.get("e1")!!.bytesSent) + } +} + +class SchedulerTest { + @Test + fun `initialDelayMs is the time left, never negative`() { + assertEquals(0, WorkManagerScheduler.initialDelayMs(null, 1_000)) + assertEquals(0, WorkManagerScheduler.initialDelayMs(500, 1_000)) + assertEquals(4_000, WorkManagerScheduler.initialDelayMs(5_000, 1_000)) + } + + @Test + fun `a queued successor suppresses another append`() { + assertTrue(WorkManagerScheduler.hasQueuedSuccessor(listOf(RUNNING, BLOCKED))) + assertTrue(WorkManagerScheduler.hasQueuedSuccessor(listOf(ENQUEUED))) + assertFalse(WorkManagerScheduler.hasQueuedSuccessor(listOf(RUNNING))) + assertFalse(WorkManagerScheduler.hasQueuedSuccessor(listOf(SUCCEEDED, FAILED, CANCELLED))) + assertFalse(WorkManagerScheduler.hasQueuedSuccessor(emptyList())) + } + + @Test + fun `the wake name differs from the main chain`() { + assertEquals("e1#wake", WorkManagerScheduler.wakeName("e1")) + } +} + +class TransferSemaphoreTest { + @Test + fun `the global cap is 4 and a fifth request waits`() = runBlocking { + assertEquals(4, MAX_TRANSFER_CONCURRENCY) + val holders = (1..4).map { async { transferSemaphore.withPermit { delay(200) } } } + delay(20) + val fifth = withTimeoutOrNull(50) { transferSemaphore.withPermit { } } + assertNull(fifth) + holders.forEach { it.await() } + assertEquals(Unit, withTimeoutOrNull(500) { transferSemaphore.withPermit { } }) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/TestSupport.kt b/android/src/test/java/ai/openspace/backgroundupload/TestSupport.kt new file mode 100644 index 00000000..f8e405f5 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/TestSupport.kt @@ -0,0 +1,123 @@ +package ai.openspace.backgroundupload + +// Builders and fakes shared by the JVM tests. No android.* here. + +internal const val FAR_FUTURE = 4_000_000_000_000L + +internal fun desc( + url: String? = "https://example.com/items", + method: String = "POST", + headers: Map = mapOf("Authorization" to "Bearer old"), + dataJson: String? = null, + form: List? = null, + file: String? = null, + parts: List? = null, + accept: List = emptyList(), + retry: RetryOverride? = null, + noNotification: Boolean = false, +) = Descriptor(url, method, headers, dataJson, form, file, parts, accept, retry, noNotification) + +internal fun part(start: Long, end: Long, accepted: Boolean = false, url: String? = null) = Part( + url = url ?: "https://example.com/part?start=$start", + headers = mapOf("Content-Range" to "$start-${end - 1}"), + start = start, + end = end, + accepted = accepted, +) + +internal fun entry( + id: String = "e1", + state: EntryState = EntryState.QUEUED, + descriptor: Descriptor? = desc(dataJson = """{"a":1}"""), + body: StagedBody? = StagedBody(StagedBody.JSON, "body-1.json", null, 7), + generation: Int = 1, + attempts: Int = 0, + settledEventId: String? = null, + parkedGeneration: Int? = null, + nextAttemptAt: Long? = null, + expiresAt: Long = FAR_FUTURE, + legacy: Boolean = false, + key: String = "note", + createdAt: Long = 1_000, +) = QueueEntry( + id = id, + key = key, + varsJson = """{"n":1}""", + descriptor = descriptor, + body = body, + state = state, + attempts = attempts, + bytesSent = 0, + totalBytes = body?.totalBytes ?: 0, + expiresAt = expiresAt, + createdAt = createdAt, + updatedAt = createdAt, + nextAttemptAt = nextAttemptAt, + parkedGeneration = parkedGeneration, + generation = generation, + settledEventId = settledEventId, + legacy = legacy, +) + +internal fun parsed( + id: String = "e1", + descriptor: Descriptor = desc(dataJson = """{"a":1}"""), + varsJson: String = """{"n":1}""", + expiresAt: Long = FAR_FUTURE, + key: String = "note", +) = EntryParsing.Parsed(id, key, varsJson, descriptor, expiresAt) + +internal fun record( + eventId: String, + id: String = "e1", + kind: String = EventJournal.KIND_COMPLETED, + generation: Int = 1, + at: Long = 5_000, + state: String = kind, +) = EventJournal.SettledRecord( + eventId = eventId, id = id, key = "note", varsJson = """{"n":1}""", at = at, attempts = 1, + requestId = "r1", deliveries = 1, state = state, bytesSent = 0, totalBytes = 0, + url = "https://example.com/items", method = "POST", partIndex = null, kind = kind, + response = if (kind == EventJournal.KIND_COMPLETED) EventJournal.Response(200, mapOf(), "ok", false) else null, + errorKind = if (kind == EventJournal.KIND_ERROR) "http" else null, + message = if (kind == EventJournal.KIND_ERROR) "HTTP 400" else null, + cancelReason = if (kind == EventJournal.KIND_CANCELLED) "user" else null, + generation = generation, +) + +/** Records every event in order, as "state::" and "settled::". */ +internal class RecordingEvents(var live: Boolean = true) : QueueEvents { + val log = mutableListOf() + val rows = mutableListOf() + val records = mutableListOf() + + override fun state(row: RequestRow) { + rows += row + log += "state:${row.id}:${row.state}" + } + + override fun settled(record: EventJournal.SettledRecord) { + records += record + log += "settled:${record.id}:${record.kind}" + } + + override fun canDeliver() = live +} + +internal class FakeScheduler : WorkScheduler { + val scheduled = mutableListOf() + val wakes = mutableListOf>() + val cancelled = mutableListOf() + + override fun schedule(entry: QueueEntry) { + scheduled += entry.id + } + + override fun scheduleWake(entry: QueueEntry, at: Long, replace: Boolean) { + wakes += entry.id to at + } + + override fun cancel(id: String) { + cancelled += id + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/UploadStatesTest.kt b/android/src/test/java/ai/openspace/backgroundupload/UploadStatesTest.kt deleted file mode 100644 index bef1858c..00000000 --- a/android/src/test/java/ai/openspace/backgroundupload/UploadStatesTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package ai.openspace.backgroundupload - -import androidx.work.WorkInfo.State.BLOCKED -import androidx.work.WorkInfo.State.CANCELLED -import androidx.work.WorkInfo.State.ENQUEUED -import androidx.work.WorkInfo.State.FAILED -import androidx.work.WorkInfo.State.RUNNING -import androidx.work.WorkInfo.State.SUCCEEDED -import androidx.work.ListenableWorker -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -// getAllUploads must return ONE row per upload id, although WorkManager can -// hold several rows for it (finished chains linger for roughly a day, and -// APPEND_OR_REPLACE resumes add rows). The state vocabulary is the same as -// iOS's. -class UploadStatesTest { - - @Test - fun `a live row wins for a chunked upload`() { - assertEquals("running", chunkedUploadState(listOf(CANCELLED, RUNNING), allAccepted = false)) - assertEquals("running", chunkedUploadState(listOf(RUNNING, BLOCKED), allAccepted = false)) - assertEquals("pending", chunkedUploadState(listOf(FAILED, ENQUEUED), allAccepted = false)) - assertEquals("pending", chunkedUploadState(listOf(BLOCKED), allAccepted = false)) - } - - @Test - fun `with no live row the manifest speaks, never a lingering finished row`() { - // A cancelled chunked upload keeps its manifest. Its truthful state is - // stalled-awaiting-resume ("error"), not "cancelled". iOS's getAllUploads - // never reports "cancelled" for a lingering upload. - assertEquals("error", chunkedUploadState(listOf(CANCELLED), allAccepted = false)) - assertEquals("error", chunkedUploadState(listOf(FAILED), allAccepted = false)) - assertEquals("error", chunkedUploadState(emptyList(), allAccepted = false)) - assertEquals("completed", chunkedUploadState(listOf(SUCCEEDED), allAccepted = true)) - assertEquals("completed", chunkedUploadState(emptyList(), allAccepted = true)) - // The row that a run leaves after it journals a terminal error is - // SUCCEEDED (see terminalErrorResult). The manifest, not the row, carries - // the outcome. - assertEquals("error", chunkedUploadState(listOf(SUCCEEDED), allAccepted = false)) - } - - @Test - fun `a journaled terminal error still succeeds the row`() { - // WorkManager marks the dependents of a FAILED prerequisite FAILED without - // a run. Thus a resume appended during a failing run's teardown would - // silently never run. The journal and the manifest are the outcome record, - // never the row state. - assertTrue(terminalErrorResult() is ListenableWorker.Result.Success) - } - - @Test - fun `cancel reports from the module only when no worker is running`() { - assertTrue(cancelReportsFromModule(listOf(ENQUEUED))) - // An appended chain's dependent is BLOCKED, not ENQUEUED. It is still - // never-started, and it is still owed a module-side 'cancelled'. - assertTrue(cancelReportsFromModule(listOf(BLOCKED))) - assertTrue(cancelReportsFromModule(listOf(ENQUEUED, BLOCKED))) - // A RUNNING worker's stop handler owns the report. - assertFalse(cancelReportsFromModule(listOf(RUNNING))) - assertFalse(cancelReportsFromModule(listOf(RUNNING, BLOCKED))) - assertFalse(cancelReportsFromModule(emptyList())) - } - - @Test - fun `a queued successor suppresses another append`() { - assertTrue(hasQueuedSuccessor(listOf(RUNNING, BLOCKED))) - assertTrue(hasQueuedSuccessor(listOf(ENQUEUED))) - assertFalse(hasQueuedSuccessor(listOf(RUNNING))) - assertFalse(hasQueuedSuccessor(listOf(SUCCEEDED, FAILED, CANCELLED))) - assertFalse(hasQueuedSuccessor(emptyList())) - } - - @Test - fun `a simple upload reports its live row first, then the most conclusive finished one`() { - assertEquals("running", simpleUploadState(listOf(CANCELLED, RUNNING))) - assertEquals("pending", simpleUploadState(listOf(SUCCEEDED, ENQUEUED))) - assertEquals("completed", simpleUploadState(listOf(CANCELLED, SUCCEEDED))) - assertEquals("error", simpleUploadState(listOf(CANCELLED, FAILED))) - assertEquals("cancelled", simpleUploadState(listOf(CANCELLED))) - } -} diff --git a/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt b/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt deleted file mode 100644 index a99178d1..00000000 --- a/android/src/test/java/ai/openspace/backgroundupload/UploadTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -package ai.openspace.backgroundupload - -import com.google.gson.Gson -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class UploadTest { - private val gson = Gson() - - private fun upload(noNotification: Boolean) = Upload( - id = "u1", - url = "https://example.com/upload", - path = "/tmp/file", - method = "POST", - wifiOnly = false, - accept = listOf(), - headers = mapOf(), - noNotification = noNotification, - ) - - @Test - fun `an upload notifies unless it opts out`() { - assertTrue(upload(noNotification = false).showsNotification) - assertFalse(upload(noNotification = true).showsNotification) - } - - @Test - fun `the opt-out survives a serialization round trip`() { - val json = gson.toJson(upload(noNotification = true)) - assertFalse(gson.fromJson(json, Upload::class.java).showsNotification) - } - - // WorkManager stores this model as JSON, so an upload can be enqueued by one - // build and run by the next. A job from a build without the option must keep - // its notification rather than silently losing foreground mode. - @Test - fun `a job enqueued without the option still notifies`() { - val json = gson.toJsonTree(upload(noNotification = true)).asJsonObject - json.remove(Upload::noNotification.name) - assertTrue(gson.fromJson(json, Upload::class.java).showsNotification) - } - - // One build can enqueue a WorkManager job, and the next build can replay it. - // This is the exact JSON shape that a v8 build serialized into input data - // (Gson.toJson of the v8 Upload model): `acceptStatus: List`, and no - // `accept`. Gson does not use the constructor. Thus, without normalized(), - // the replayed object's `accept` is NULL, and the worker NPEs after the file - // has fully transmitted. WorkManager then re-runs it and re-sends the whole - // file. - private val v8JobJson = """ - { - "id": "u1", - "url": "https://example.com/upload", - "path": "/tmp/file", - "method": "PUT", - "maxRetries": 5, - "wifiOnly": false, - "acceptStatus": [409, 208], - "headers": {"Authorization": "Bearer t"}, - "notificationId": 123456, - "notificationTitle": "Uploading…", - "notificationTitleNoInternet": "Waiting for connection…", - "notificationTitleNoWifi": "Waiting for Wi-Fi…", - "notificationChannel": "background-upload", - "noNotification": false - } - """ - - @Test - fun `a replayed v8 job maps acceptStatus to accept rules and is safe to run`() { - val replayed = gson.fromJson(v8JobJson, Upload::class.java).normalized() - assertEquals( - listOf(UploadOutcome.AcceptRule(409), UploadOutcome.AcceptRule(208)), - replayed.accept, - ) - // The worker-facing calls that NPE'd on the un-normalized object. - assertTrue(UploadOutcome.isAccepted(409, "duplicate", replayed.accept)) - assertFalse(UploadOutcome.isAccepted(400, "", replayed.accept)) - assertEquals("u1", replayed.id) - assertEquals(mapOf("Authorization" to "Bearer t"), replayed.headers) - assertTrue(replayed.showsNotification) - } - - @Test - fun `a replayed v8 job with an empty acceptStatus gets no rules`() { - val json = gson.fromJson(v8JobJson, com.google.gson.JsonObject::class.java) - json.add("acceptStatus", com.google.gson.JsonArray()) - val replayed = gson.fromJson(json, Upload::class.java).normalized() - assertEquals(emptyList(), replayed.accept) - assertTrue(UploadOutcome.isAccepted(200, "", replayed.accept)) - } - - @Test - fun `a job with neither accept nor acceptStatus normalizes to no rules`() { - val json = gson.fromJson(v8JobJson, com.google.gson.JsonObject::class.java) - json.remove("acceptStatus") - val replayed = gson.fromJson(json, Upload::class.java).normalized() - assertEquals(emptyList(), replayed.accept) - assertFalse(UploadOutcome.isAccepted(409, "duplicate", replayed.accept)) - } - - @Test - fun `normalized passes a current-shape job through unchanged`() { - val current = upload(noNotification = true).copy( - accept = listOf(UploadOutcome.AcceptRule(409, "already completed")), - ) - val replayed = gson.fromJson(gson.toJson(current), Upload::class.java).normalized() - assertEquals(current, replayed) - } -} diff --git a/android/src/test/java/ai/openspace/backgroundupload/WorkerGateTest.kt b/android/src/test/java/ai/openspace/backgroundupload/WorkerGateTest.kt new file mode 100644 index 00000000..dde4a6b0 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/WorkerGateTest.kt @@ -0,0 +1,51 @@ +package ai.openspace.backgroundupload + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkerGateTest { + private val a = Any() + private val b = Any() + + @After + fun tearDown() { + // A process-wide singleton. Leave nothing for other tests. + listOf("g1", "g2").forEach { id -> WorkerGate.release(id, a); WorkerGate.release(id, b) } + } + + @Test + fun `a second worker for the same id must wait`() { + assertTrue(WorkerGate.tryAcquire("g1", a)) + assertFalse(WorkerGate.tryAcquire("g1", b)) + WorkerGate.release("g1", a) + assertTrue(WorkerGate.tryAcquire("g1", b)) + } + + @Test + fun `reacquiring with the same token is idempotent`() { + assertTrue(WorkerGate.tryAcquire("g1", a)) + assertTrue(WorkerGate.tryAcquire("g1", a)) + } + + @Test + fun `a stale release can not evict a successor`() { + assertTrue(WorkerGate.tryAcquire("g1", a)) + WorkerGate.release("g1", a) + assertTrue(WorkerGate.tryAcquire("g1", b)) + WorkerGate.release("g1", a) + assertTrue(WorkerGate.isRunning("g1")) + assertFalse(WorkerGate.tryAcquire("g1", a)) + } + + @Test + fun `ids are independent`() { + assertTrue(WorkerGate.tryAcquire("g1", a)) + assertFalse(WorkerGate.isRunning("g2")) + assertTrue(WorkerGate.tryAcquire("g2", a)) + WorkerGate.release("g1", a) + assertFalse(WorkerGate.isRunning("g1")) + assertTrue(WorkerGate.isRunning("g2")) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt new file mode 100644 index 00000000..0e7e1df9 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt @@ -0,0 +1,283 @@ +package ai.openspace.backgroundupload + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class WorkerOpsTest { + @get:Rule + val tmp = TemporaryFolder() + + private lateinit var store: QueueStore + private lateinit var journal: EventJournal + private lateinit var settings: QueueSettingsStore + private val events = RecordingEvents() + private val scheduler = FakeScheduler() + private var now = 20_000L + private lateinit var ops: WorkerOps + private lateinit var controller: QueueController + + @Before + fun setUp() { + val root = tmp.newFolder("queue") + store = QueueStore(root, RequestIndex()) + journal = EventJournal(tmp.newFolder("journal")) + settings = QueueSettingsStore(File(root, "settings.json")) + ops = WorkerOps(store, journal, settings, events, scheduler) { now } + controller = QueueController(store, journal, settings, events, scheduler, { false }, { now }) + } + + private val ok = UploadResponse(200, """{"id":7}""", mapOf("x" to "y")) + + @Test + fun `begin takes a queued entry and emits running`() { + store.save(entry(nextAttemptAt = 5)) + val e = ops.begin("e1")!! + assertEquals(EntryState.RUNNING, e.state) + assertNull(e.nextAttemptAt) + assertEquals(listOf("state:e1:running"), events.log) + } + + @Test + fun `begin does nothing for a paused, settled, or legacy entry`() { + store.save(entry(id = "p", state = EntryState.PAUSED)) + store.save(entry(id = "x", state = EntryState.ERROR)) + store.save(entry(id = "l", state = EntryState.QUEUED, legacy = true, descriptor = null, body = null)) + assertNull(ops.begin("p")) + assertNull(ops.begin("x")) + assertNull(ops.begin("l")) + assertNull(ops.begin("nope")) + } + + @Test + fun `recordAttempt persists attempts and the request id before the send`() { + store.save(entry(state = EntryState.RUNNING, attempts = 2)) + val e = ops.recordAttempt("e1", 1, "req-3") + assertEquals(3, e.attempts) + assertEquals("req-3", store.load("e1")!!.lastRequestId) + assertEquals(3, store.load("e1")!!.attempts) + } + + @Test + fun `recordAttempt refuses once the module took the entry`() { + store.save(entry(state = EntryState.PAUSED)) + assertThrows(NotOwnedException::class.java) { ops.recordAttempt("e1", 1, "r") } + store.save(entry(state = EntryState.RUNNING, generation = 2)) + assertThrows(NotOwnedException::class.java) { ops.recordAttempt("e1", 1, "r") } + } + + @Test + fun `settle journals, transitions, then emits settled before state`() { + store.save(entry(state = EntryState.RUNNING, attempts = 1).copy(lastRequestId = "req-1")) + assertTrue(ops.settle("e1", 1, Settlement.Completed(ok, "https://example.com/items", "POST"))) + val record = journal.unacknowledged().single() + assertEquals(1, record.deliveries) + assertEquals("req-1", record.requestId) + assertEquals(200, record.response!!.status) + assertEquals(1, record.generation) + val e = store.load("e1")!! + assertEquals(EntryState.COMPLETED, e.state) + assertEquals(record.eventId, e.settledEventId) + assertEquals(e.totalBytes, e.bytesSent) + assertEquals(listOf("settled:e1:completed", "state:e1:completed"), events.log) + } + + @Test + fun `a settle with JS dead starts at 0 deliveries, so the first replay is 1`() { + events.live = false + store.save(entry(state = EntryState.RUNNING)) + assertTrue(ops.settle("e1", 1, Settlement.Completed(ok, "u", "POST"))) + assertEquals(0, journal.unacknowledged().single().deliveries) + assertEquals(listOf("state:e1:completed"), events.log) // nothing to emit to + assertEquals(1, controller.unacknowledged().single().deliveries) + } + + @Test + fun `a response that lands after cancel drops its record`() { + controller.enqueue(parsed()) + ops.begin("e1") + controller.cancel("e1") + events.log.clear() + assertFalse(ops.settle("e1", 1, Settlement.Completed(ok, "u", "POST"))) + val left = journal.unacknowledged().single() + assertEquals(EventJournal.KIND_CANCELLED, left.kind) + assertEquals(EntryState.CANCELLED, store.load("e1")!!.state) + assertEquals(emptyList(), events.log) + } + + @Test + fun `a settle from an older generation is dropped`() { + store.save(entry(state = EntryState.QUEUED, generation = 2)) + assertFalse(ops.settle("e1", 1, Settlement.Completed(ok, "u", "POST"))) + assertEquals(emptyList(), journal.unacknowledged()) + } + + @Test + fun `a response that lands during pause still settles`() { + store.save(entry(state = EntryState.PAUSED)) + assertTrue(ops.settle("e1", 1, Settlement.Failed("http", "HTTP 400", ok.copy(code = 400), null, "u", "POST"))) + val e = store.load("e1")!! + assertEquals(EntryState.ERROR, e.state) + assertEquals("http", journal.unacknowledged().single().errorKind) + } + + @Test + fun `park sets awaiting-auth once and wakes at expiry`() { + store.save(entry(state = EntryState.RUNNING, expiresAt = 90_000)) + assertEquals(WorkerOps.ParkResult.PARKED, ops.park("e1", 1, headerGeneration = 0)) + val e = store.load("e1")!! + assertEquals(EntryState.AWAITING_AUTH, e.state) + assertEquals(0, e.parkedGeneration) + assertEquals(listOf("state:e1:awaiting-auth"), events.log) + assertEquals(listOf("e1" to 90_000L), scheduler.wakes) + assertEquals(emptyList(), journal.unacknowledged()) + } + + @Test + fun `a 401 sent under older headers re-issues instead of parking`() { + store.save(entry(state = EntryState.RUNNING)) + controller.updateHeaders(mapOf("Authorization" to "Bearer new")) + assertEquals(WorkerOps.ParkResult.REISSUE, ops.park("e1", 1, headerGeneration = 0)) + assertEquals(EntryState.RUNNING, store.load("e1")!!.state) + } + + @Test + fun `updateHeaders after a park requeues it`() { + store.save(entry(state = EntryState.RUNNING)) + ops.park("e1", 1, 0) + controller.updateHeaders(mapOf("Authorization" to "Bearer new")) + assertEquals(EntryState.QUEUED, store.load("e1")!!.state) + assertEquals(listOf("e1"), scheduler.scheduled) + } + + @Test + fun `release queues the entry with its wake time and streak`() { + store.save(entry(state = EntryState.RUNNING)) + assertTrue(ops.release("e1", 1, nextAttemptAt = 80_000, streak = 7)) + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertEquals(80_000L, e.nextAttemptAt) + assertEquals(7, e.backoffStreak) + assertEquals(listOf("e1" to 80_000L), scheduler.wakes) + assertEquals(80_000.0, events.rows.single().toMap()["nextAttemptAt"]) + } + + @Test + fun `a system stop queues a running entry and leaves a paused one`() { + store.save(entry(state = EntryState.RUNNING)) + ops.stopped("e1", 1) + assertEquals(EntryState.QUEUED, store.load("e1")!!.state) + store.save(entry(state = EntryState.PAUSED)) + ops.stopped("e1", 1) + assertEquals(EntryState.PAUSED, store.load("e1")!!.state) + assertEquals(emptyList(), journal.unacknowledged()) + } + + @Test + fun `markAccepted persists the flag and the accepted bytes`() { + store.save(entry( + state = EntryState.RUNNING, + descriptor = desc(url = null, file = "/f", parts = listOf(part(0, 10), part(10, 25))), + body = StagedBody(StagedBody.CHUNKED, "blob", null, 25), + ).copy(backoffStreak = 3)) + ops.markAccepted("e1", 1, 1) + val e = store.load("e1")!! + assertTrue(e.descriptor!!.parts!![1].accepted) + assertEquals(15, e.bytesSent) + assertEquals(0, e.backoffStreak) + } + + // MARK: - header generation of an attempt + + @Test + fun `an attempt carries the header generation of the headers it sends`() { + store.save(entry(state = EntryState.RUNNING)) + controller.updateHeaders(mapOf("Authorization" to "Bearer new")) + val e = ops.recordAttempt("e1", 1, "r1") + assertEquals(1, e.headerGeneration) + assertEquals("Bearer new", e.descriptor!!.headers["Authorization"]) + } + + @Test + fun `a 401 between the settings bump and the entry patch parks, and the patch requeues it`() { + store.save(entry(state = EntryState.RUNNING)) + // updateHeaders() has bumped the settings but not yet patched the entry. + settings.update { it.copy(headerGeneration = it.headerGeneration + 1) } + val sent = ops.recordAttempt("e1", 1, "r1") + assertEquals(0, sent.headerGeneration) + assertFalse(ops.hasNewerHeaders("e1", 1, sent.headerGeneration)) // no re-issue with the same old headers + assertEquals(WorkerOps.ParkResult.PARKED, ops.park("e1", 1, sent.headerGeneration)) + // The rest of updateHeaders() finds it parked. + controller.updateHeaders(mapOf("Authorization" to "Bearer new")) + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertEquals("Bearer new", e.descriptor!!.headers["Authorization"]) + } + + @Test + fun `a re-issue sends the patched headers, and a 401 on them parks with them`() { + store.save(entry(state = EntryState.RUNNING)) + val first = ops.recordAttempt("e1", 1, "r1") + controller.updateHeaders(mapOf("Authorization" to "Bearer new")) + assertTrue(ops.hasNewerHeaders("e1", 1, first.headerGeneration)) + val second = ops.recordAttempt("e1", 1, "r2") + assertEquals("Bearer new", second.descriptor!!.headers["Authorization"]) + assertFalse(ops.hasNewerHeaders("e1", 1, second.headerGeneration)) + assertEquals(WorkerOps.ParkResult.PARKED, ops.park("e1", 1, second.headerGeneration)) + assertEquals(1, store.load("e1")!!.parkedGeneration) + } + + // MARK: - short backoff + + @Test + fun `a short backoff shows nextAttemptAt on the running row until the next attempt`() { + store.save(entry(state = EntryState.RUNNING)) + ops.backingOff("e1", 1, nextAttemptAt = 24_000) + val waiting = store.load("e1")!! + assertEquals(EntryState.RUNNING, waiting.state) + assertEquals(24_000L, waiting.nextAttemptAt) + assertEquals(24_000.0, events.rows.last().toMap()["nextAttemptAt"]) + ops.recordAttempt("e1", 1, "r2") + assertNull(store.load("e1")!!.nextAttemptAt) + assertEquals(listOf("state:e1:running", "state:e1:running"), events.log) + assertFalse(events.rows.last().toMap().containsKey("nextAttemptAt")) + } + + @Test + fun `an attempt with no pending backoff emits no state event`() { + store.save(entry(state = EntryState.RUNNING)) + ops.recordAttempt("e1", 1, "r1") + assertEquals(emptyList(), events.log) + } + + @Test + fun `a short backoff on an entry the run no longer owns changes nothing`() { + store.save(entry(state = EntryState.PAUSED)) + ops.backingOff("e1", 1, nextAttemptAt = 24_000) + assertNull(store.load("e1")!!.nextAttemptAt) + assertEquals(emptyList(), events.log) + } + + // MARK: - deliveries + + @Test + fun `JS that subscribes between the check and the append still gets the outcome live with deliveries 1`() { + // canDeliver() is false at the record build and true right after the append. + val answers = ArrayDeque(listOf(false, true)) + val flipping = object : QueueEvents by events { + override fun canDeliver() = answers.removeFirstOrNull() ?: true + } + val flipOps = WorkerOps(store, journal, settings, flipping, scheduler) { now } + store.save(entry(state = EntryState.RUNNING)) + assertTrue(flipOps.settle("e1", 1, Settlement.Completed(ok, "u", "POST"))) + assertEquals(1, journal.unacknowledged().single().deliveries) + assertEquals(1, events.records.single().deliveries) + } +} From 430cf230df08bbbb929744b96c0dacae47bdf4ab Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 16:23:31 -0400 Subject: [PATCH 11/22] Android: apply the native slices review Reliability: begin() applies an own-generation journal record instead of re-running, so a crash between the journal write and the store update cannot double-send. A failed journal write on settle holds the record in memory and retries; nothing is acked that was not written. cancel() stops the work in a finally block, journals before it saves, and applies an existing record of its generation rather than adding a second outcome. Listening state and the deliveries count are decided under one journal lock, and a live settle goes to the listener that drained, not the newest module instance. Contract: vars and data arrive as JSON text; "null" is a body; GET with a body rejects E_INVALID. Attempt events are completed or error only; pause, cancel, and supersede emit none. A settled entry's cancel forgets its unacked outcomes. attempts reset only on reopen. Under pause only an accepted response settles. A same-id enqueue over a legacy row adopts its v9 manifest. The response body cap applies while streaming. A prune never deletes an eventId a row or a new record names. Rows carry live bytesSent. Platform: a headless run stopped at JobScheduler's 10-minute limit takes the transient path with backoff. Header validation messages carry the name and offset, never the value. README documents the headless limit and the allowBackup requirement. Simplification: one EntryRun attempt path shared by simple and chunked transfers; classifyFailure is the only failure rule; dead code removed. Tests: 289 (was 217), with a TransferHost seam for the run loop. Co-Authored-By: Claude Fable 5.1 --- README.md | 15 + .../backgroundupload/AttemptEvent.kt | 15 +- .../ai/openspace/backgroundupload/BodyCap.kt | 67 +++ .../backgroundupload/ChunkedUploadWorker.kt | 103 ++-- .../backgroundupload/EnqueueRules.kt | 36 +- .../backgroundupload/EntryParsing.kt | 55 ++- .../ai/openspace/backgroundupload/EntryRun.kt | 325 ++++++++++++ .../backgroundupload/EntryTransitions.kt | 34 +- .../openspace/backgroundupload/EntryWorker.kt | 236 ++------- .../backgroundupload/EventJournal.kt | 206 ++++++-- .../backgroundupload/EventReporter.kt | 21 +- .../openspace/backgroundupload/JsonBridge.kt | 60 +-- .../backgroundupload/LegacyImport.kt | 9 +- .../backgroundupload/QueueController.kt | 112 +++-- .../openspace/backgroundupload/QueueStore.kt | 20 +- .../backgroundupload/RequestIndex.kt | 2 - .../backgroundupload/RetryClassifier.kt | 8 +- .../backgroundupload/TransferHost.kt | 34 ++ .../backgroundupload/UploadOutcome.kt | 12 - .../openspace/backgroundupload/UploadUtils.kt | 18 +- .../backgroundupload/UploadWorker.kt | 88 +--- .../backgroundupload/UploaderModule.kt | 33 +- .../openspace/backgroundupload/WorkerOps.kt | 168 ++++--- .../openspace/backgroundupload/BodyCapTest.kt | 70 +++ .../backgroundupload/ChunkedEngineTest.kt | 4 +- .../backgroundupload/EntryParsingTest.kt | 90 +++- .../backgroundupload/EntryRunTest.kt | 464 ++++++++++++++++++ .../backgroundupload/EntryTransitionsTest.kt | 60 ++- .../backgroundupload/EventJournalTest.kt | 148 +++++- .../backgroundupload/JsonBridgeTest.kt | 43 +- .../backgroundupload/LegacyImportTest.kt | 32 ++ .../backgroundupload/QueueControllerTest.kt | 174 ++++++- .../backgroundupload/QueueStoreTest.kt | 16 +- .../backgroundupload/RetryClassifierTest.kt | 8 +- .../backgroundupload/SmallPartsTest.kt | 39 +- .../openspace/backgroundupload/TestSupport.kt | 10 +- .../backgroundupload/UploadOutcomeTest.kt | 16 - .../backgroundupload/WorkerOpsTest.kt | 189 ++++++- 38 files changed, 2309 insertions(+), 731 deletions(-) create mode 100644 android/src/main/java/ai/openspace/backgroundupload/BodyCap.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/EntryRun.kt create mode 100644 android/src/main/java/ai/openspace/backgroundupload/TransferHost.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/BodyCapTest.kt create mode 100644 android/src/test/java/ai/openspace/backgroundupload/EntryRunTest.kt diff --git a/README.md b/README.md index b146c38c..c0189ddc 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,21 @@ notification. That notification is also the worker's foreground-service notification, so a silent request runs as an ordinary background worker and the OS may defer or restart it. Reserve it for small payloads. +### Android platform notes + +**Headless time limit.** On API 31 and later, a WorkManager run that starts +from the background usually cannot start its foreground service. The run +then has JobScheduler's limit of about 10 minutes. A single body (`data`, +`form`, `file`) that does not finish in that time starts again from byte 0 +at the next run, after a growing backoff. A body that needs more than +10 minutes headless cannot finish that way. Use `parts` for large bodies: +accepted parts are kept across runs. iOS has no equal limit. + +**Backups.** The queue store (`files/rnbgupload-chunked/`) and the journal +(`files/rnbgupload-settled/`) hold request headers, including auth tokens, +and staged bodies. Set `android:allowBackup="false"` in the host app, or +exclude those two directories in its backup rules. + # Reliable delivery 1. **Write-ahead.** Entry, descriptor, and staged body persist before any diff --git a/android/src/main/java/ai/openspace/backgroundupload/AttemptEvent.kt b/android/src/main/java/ai/openspace/backgroundupload/AttemptEvent.kt index 37a65f0a..1274c3d8 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/AttemptEvent.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/AttemptEvent.kt @@ -6,7 +6,8 @@ import com.facebook.react.bridge.WritableMap * One HTTP attempt, before the library interprets it. Live only: never * journaled. [outcome] is `completed` when the response is accepted and * `error` otherwise, so a 401 is `error` with httpCode 401 even though the - * entry parks. + * entry parks. A transport failure is `error` with its own errorKind. Pause, + * cancel, supersede, and a system stop emit no attempt event. */ data class AttemptEvent( val id: String, @@ -23,12 +24,9 @@ data class AttemptEvent( val responseHeaders: Map?, val errorKind: String?, val errorMessage: String?, - val cancelReason: String?, val at: Long, ) { companion object { - const val MAX_BODY_CHARS = 4 * 1024 - fun ofResponse( entry: QueueEntry, requestId: String, @@ -38,16 +36,16 @@ data class AttemptEvent( accepted: Boolean, at: Long, ): AttemptEvent { - val (body, truncated) = EventJournal.capBody(response.body, MAX_BODY_CHARS) + val (body, cut) = BodyCap.cap(response.body, BodyCap.ATTEMPT_MAX_BYTES) return AttemptEvent( id = entry.id, key = entry.key, requestId = requestId, attempt = entry.attempts, url = url, method = entry.descriptor?.method ?: "POST", partIndex = partIndex, outcome = if (accepted) "completed" else "error", - httpCode = response.code, responseBody = body, responseBodyTruncated = truncated, + httpCode = response.code, responseBody = body, responseBodyTruncated = cut || response.truncated, responseHeaders = response.headers, errorKind = if (accepted) null else "http", errorMessage = if (accepted) null else "HTTP ${response.code}", - cancelReason = null, at = at, + at = at, ) } @@ -64,7 +62,7 @@ data class AttemptEvent( url = url, method = entry.descriptor?.method ?: "POST", partIndex = partIndex, outcome = "error", httpCode = null, responseBody = null, responseBodyTruncated = null, responseHeaders = null, errorKind = errorKind, errorMessage = message, - cancelReason = null, at = at, + at = at, ) } @@ -83,7 +81,6 @@ data class AttemptEvent( responseHeaders?.let { put("responseHeaders", it) } errorKind?.let { put("errorKind", it) } errorMessage?.let { put("errorMessage", it) } - cancelReason?.let { put("cancelReason", it) } put("at", at.toDouble()) } diff --git a/android/src/main/java/ai/openspace/backgroundupload/BodyCap.kt b/android/src/main/java/ai/openspace/backgroundupload/BodyCap.kt new file mode 100644 index 00000000..ade8e7df --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/BodyCap.kt @@ -0,0 +1,67 @@ +package ai.openspace.backgroundupload + +import okio.Buffer +import okio.BufferedSource +import java.nio.charset.Charset + +/** + * Response body caps, in UTF-8 bytes. A cut never splits a character: it + * backs off to the last whole one. [read] applies the cap while the body + * streams in, so a huge error page never sits in memory whole. + */ +object BodyCap { + /** 1 MB, the RawResponse cap of a settled outcome. */ + const val SETTLED_MAX_BYTES = 1_048_576 + + /** 4 KB, the body cap of a live attempt event. */ + const val ATTEMPT_MAX_BYTES = 4 * 1024 + + /** A body as text and whether the cap cut it. */ + data class Capped(val text: String, val truncated: Boolean) + + /** + * Reads at most [maxBytes] of [source]. Bytes past the cap are not read. + * A UTF-8 body is cut on a character boundary; another charset is cut at + * the byte cap and decoded as it is. + */ + fun read(source: BufferedSource, maxBytes: Int, charset: Charset = Charsets.UTF_8): Capped { + val buffer = Buffer() + val limit = maxBytes.toLong() + 1 + while (buffer.size < limit) { + if (source.read(buffer, limit - buffer.size) == -1L) break + } + val truncated = buffer.size > maxBytes + val bytes = buffer.readByteArray() + val keep = if (!truncated) bytes.size + else if (charset == Charsets.UTF_8) utf8Boundary(bytes, maxBytes) + else maxBytes + return Capped(String(bytes, 0, keep, charset), truncated) + } + + /** [text] cut to at most [maxBytes] of UTF-8. Null stays null. */ + fun cap(text: String?, maxBytes: Int): Pair { + if (text == null) return null to false + val bytes = text.toByteArray(Charsets.UTF_8) + if (bytes.size <= maxBytes) return text to false + return String(bytes, 0, utf8Boundary(bytes, maxBytes), Charsets.UTF_8) to true + } + + /** + * The longest prefix length of [bytes], at most [max], that does not end + * inside a UTF-8 sequence. A continuation byte is 10xxxxxx. + */ + internal fun utf8Boundary(bytes: ByteArray, max: Int): Int { + if (bytes.size <= max) return bytes.size + var end = max + // bytes[end] is the first byte cut off. While it continues a sequence, + // the sequence started before the cut, so drop its start too. A UTF-8 + // character is at most 4 bytes; past 3 steps the bytes are not UTF-8, + // and the cut stays at max. + var steps = 0 + while (end > 0 && steps < 3 && (bytes[end].toInt() and 0xC0) == 0x80) { + end-- + steps++ + } + return if ((bytes[end].toInt() and 0xC0) == 0x80) max else end + } +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/ChunkedUploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/ChunkedUploadWorker.kt index 1b1fed34..d236ec9a 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/ChunkedUploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/ChunkedUploadWorker.kt @@ -2,10 +2,7 @@ package ai.openspace.backgroundupload import android.content.Context import androidx.work.WorkerParameters -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.sync.withPermit import java.io.File -import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong @@ -18,12 +15,13 @@ class ChunkedUploadWorker(context: Context, params: WorkerParameters) : EntryWor * one progress stream (byte-weighted) and one outcome: completed only when * every part is accepted. * + * Each part runs [EntryRun.attempt] until a verdict ends it. * Per part: accepted → persist the flag; auth → the whole entry parks (the * sibling parts stop); transient → a short backoff waits in the part while * the siblings go on, a long one releases the whole worker (accepted parts * are kept); terminal → the entry fails with that part's index. */ -internal class ChunkedTransfer(private val host: EntryWorker) { +internal class ChunkedTransfer(private val run: EntryRun) { /** A terminal part failure. Not a CancellationException, so it stops the sibling parts. */ private class PartFailed(val settlement: Settlement.Failed) : Exception(settlement.message) @@ -43,14 +41,13 @@ internal class ChunkedTransfer(private val host: EntryWorker) { val pending = ChunkedParts.pendingIndexes(parts) // A run over an all-accepted entry that has not settled yet. if (pending.isEmpty()) return Settlement.Completed(null, lastAcceptedUrl ?: d0.reportUrl, d0.method) - val blob = host.bodyFile(start) + val blob = run.store.bodyFile(start) if (blob == null || !blob.exists()) { return Settlement.Failed("file", "the chunked file is missing", null, null, d0.reportUrl, d0.method) } total = ChunkedParts.totalBytes(parts) acceptedBytes.set(ChunkedParts.acceptedBytes(parts)) - UploadProgress.add(host.entryId, total) - UploadProgress.set(host.entryId, acceptedBytes.get()) + run.progressStarted(total, acceptedBytes.get()) try { ChunkedEngine.run(pending) { index -> executePart(index, blob, start.backoffStreak) } @@ -62,17 +59,17 @@ internal class ChunkedTransfer(private val host: EntryWorker) { return Settlement.Completed(null, lastAcceptedUrl ?: d0.reportUrl, d0.method) } - private suspend fun executePart(index: Int, blob: File, initialStreak: Int) { + internal suspend fun executePart(index: Int, blob: File, initialStreak: Int) { var streak = initialStreak while (true) { if (index in acceptedHere) return - val latest = host.ops.latest(host.entryId, host.generation) + val latest = run.ops.latest(run.entryId, run.generation) val stored = latest.descriptor!!.parts!![index] if (stored.accepted) return - if (RetryClassifier.isExpired(host.now(), latest.expiresAt)) throw EntryWorker.ExpiredException() + if (RetryClassifier.isExpired(run.host.now(), latest.expiresAt)) throw EntryRun.ExpiredException() // A range past EOF can never be sent. length() is 0 for a missing file; - // that case falls through to the transfer, which classifies it as file. + // that case falls through to the attempt, which classifies it as file. val blobLength = runCatching { blob.length() }.getOrDefault(0L) if (blobLength > 0L && stored.end > blobLength) { throw PartFailed( @@ -83,75 +80,33 @@ internal class ChunkedTransfer(private val host: EntryWorker) { ), ) } - host.waitForNetwork() + run.waitForNetwork() - val requestId = UUID.randomUUID().toString() - val entry = host.ops.recordAttempt(host.entryId, host.generation, requestId) - // The generation of the headers this attempt sends: both come from the same entry. - val headerGeneration = entry.headerGeneration - val d = entry.descriptor!! - val part = d.parts!![index] - val policy = host.policy(entry) - - val response = try { - transferSemaphore.withPermit { - okhttpSend( - uploadHttpClient, - TransferRequest( - part.url, d.method, host.headersFor(d, part, requestId), - rangeRequestBody(blob, part.start, part.end), - ), - ) { sent -> onPartProgress(index, sent) } - } - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - onPartProgress(index, 0L) - val fileExists = runCatching { blob.exists() }.getOrDefault(true) - val message = error.message ?: error.javaClass.simpleName - EventReporter.attempt( - AttemptEvent.ofFailure( - entry, requestId, part.url, index, RetryClassifier.failureKind(error, fileExists), message, host.now(), - ), - ) - when (val verdict = RetryClassifier.classifyFailure(error, fileExists)) { - is RetryClassifier.Verdict.Terminal -> throw PartFailed( - Settlement.Failed(verdict.errorKind, verdict.message, null, index, part.url, d.method), - ) - else -> { - streak++ - host.backoffOrRelease(policy, streak, entry.expiresAt) - continue - } - } - } - - val verdict = RetryClassifier.classifyResponse(response.code, response.body, d.accept, policy.exempt) - EventReporter.attempt( - AttemptEvent.ofResponse( - entry, requestId, part.url, index, response, verdict == RetryClassifier.Verdict.Accepted, host.now(), - ), + val a = run.attempt( + partIndex = index, + body = { _, part -> rangeRequestBody(blob, part!!.start, part.end) }, + onProgress = { sent -> onPartProgress(index, sent) }, + fileExists = { blob.exists() }, ) - when (verdict) { - RetryClassifier.Verdict.Accepted -> { - markAccepted(index, part) + when (val r = a.result) { + is EntryRun.AttemptResult.Accepted -> { + markAccepted(index, a.entry.descriptor!!.parts!![index]) return } - RetryClassifier.Verdict.Auth -> { - if (host.ops.hasNewerHeaders(host.entryId, host.generation, headerGeneration)) { - streak = 0 - continue - } - throw EntryWorker.ParkException(headerGeneration) + is EntryRun.AttemptResult.Auth -> { + if (!r.reissue) throw EntryRun.ParkException(r.headerGeneration) + streak = 0 } - RetryClassifier.Verdict.Transient -> { + EntryRun.AttemptResult.Transient -> { onPartProgress(index, 0L) streak++ - host.backoffOrRelease(policy, streak, entry.expiresAt) + run.backoffOrRelease(a.policy, streak, a.entry.expiresAt) + } + is EntryRun.AttemptResult.Terminal -> { + onPartProgress(index, 0L) + val message = r.response?.let { "HTTP ${it.code} on part $index" } ?: r.message + throw PartFailed(Settlement.Failed(r.errorKind, message, r.response, index, a.url, a.method)) } - is RetryClassifier.Verdict.Terminal -> throw PartFailed( - Settlement.Failed("http", "HTTP ${response.code} on part $index", response, index, part.url, d.method), - ) } } } @@ -159,7 +114,7 @@ internal class ChunkedTransfer(private val host: EntryWorker) { private fun markAccepted(index: Int, part: Part) { // Remembered here too, so a lost flag write does not re-send the part in this run. acceptedHere += index - host.ops.markAccepted(host.entryId, host.generation, index) + run.ops.markAccepted(run.entryId, run.generation, index) acceptedBytes.addAndGet(part.size) lastAcceptedUrl = part.url partSent.remove(index) @@ -173,6 +128,6 @@ internal class ChunkedTransfer(private val host: EntryWorker) { private fun report() { val sent = (acceptedBytes.get() + partSent.values.sum()).coerceAtMost(total) - host.reportProgress(sent, total) + run.reportProgress(sent, total) } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/EnqueueRules.kt b/android/src/main/java/ai/openspace/backgroundupload/EnqueueRules.kt index 5b4fdf64..4331797e 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EnqueueRules.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EnqueueRules.kt @@ -7,10 +7,12 @@ package ai.openspace.backgroundupload * | Stored entry | Action | * | none, no v9 manifest | Create | * | none, v9 manifest | AdoptV9: keep the blob and accepted parts | - * | legacy row | Replace (generation + 1) | + * | legacy row, v9 manifest | AdoptV9 (generation + 1) | + * | legacy row, no manifest | Replace (generation + 1) | * | same body, completed, record present | ReEmit: deliveries + 1, no re-run | * | same body, completed, record gone | Replace (it was acked) | - * | same body, any other state | Resume (a settled one reopens: gen + 1) | + * | same body, any other state | Resume (a settled one reopens: gen + 1, | + * | | attempts 0) | * | different body, running | RejectRunning (E_RUNNING) | * | different body, otherwise | Replace (generation + 1, attempts 0) | */ @@ -18,7 +20,8 @@ object EnqueueRules { sealed class Action { object Create : Action() - data class AdoptV9(val manifest: LegacyManifest) : Action() + /** [generation] is 1, or the legacy row's + 1. */ + data class AdoptV9(val manifest: LegacyManifest, val generation: Int) : Action() data class ReEmit(val eventId: String) : Action() object Resume : Action() object Replace : Action() @@ -31,8 +34,10 @@ object EnqueueRules { incoming: Descriptor, hasRecord: (eventId: String) -> Boolean, ): Action { - if (existing == null) return if (v9 != null) Action.AdoptV9(v9) else Action.Create - if (existing.legacy) return Action.Replace + if (existing == null) return if (v9 != null) Action.AdoptV9(v9, 1) else Action.Create + // An imported v9 outcome row. Its v9 chunked manifest, when present, is + // the upload's progress: adopt it, as with no row at all. + if (existing.legacy) return if (v9 != null) Action.AdoptV9(v9, existing.generation + 1) else Action.Replace if (existing.sameBodyAs(incoming)) { if (existing.state == EntryState.COMPLETED) { val eventId = existing.settledEventId @@ -100,11 +105,27 @@ object EnqueueRules { ) } + /** AdoptV9: a new entry over the v9 blob, at [generation]; over a legacy row it keeps the row's createdAt. */ + fun adopted( + p: EntryParsing.Parsed, + staged: BodyStaging.Staged, + parts: List?, + legacyRow: QueueEntry?, + generation: Int, + paused: Boolean, + headerGeneration: Int, + now: Long, + ): QueueEntry = created(p, staged, parts, paused, headerGeneration, now).copy( + createdAt = legacyRow?.createdAt ?: now, + generation = generation, + ) + /** * Same body. New headers, expiresAt, vars, accept, retry, and notification * flag replace the stored ones; the body and accepted parts stay. A settled - * entry reopens with a fresh generation. A running one stays running (the - * worker reads the new headers before its next attempt). + * entry reopens with a fresh generation and attempts 0 (attempts count the + * current generation). A running one stays running (the worker reads the + * new headers before its next attempt). */ fun resumed( existing: QueueEntry, @@ -129,6 +150,7 @@ object EnqueueRules { noNotification = p.descriptor.noNotification, ), state = if (running) EntryState.RUNNING else initialState(paused), + attempts = if (reopen) 0 else existing.attempts, bytesSent = parts?.let { ChunkedParts.acceptedBytes(it) } ?: if (running) existing.bytesSent else 0L, expiresAt = p.expiresAt, updatedAt = now, diff --git a/android/src/main/java/ai/openspace/backgroundupload/EntryParsing.kt b/android/src/main/java/ai/openspace/backgroundupload/EntryParsing.kt index 81d06fb9..f3c77ac6 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EntryParsing.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EntryParsing.kt @@ -8,12 +8,14 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.net.URI /** - * Turns the EnqueueEntry `{ id, key, vars, descriptor }` into Kotlin values. - * JS has already validated the descriptor. Native checks only what it needs - * to run, and rejects anything else with E_INVALID. + * Turns the EnqueueEntry `{ id, key, varsJson, descriptor }` into Kotlin + * values. JS has already validated the descriptor. Native checks only what + * it needs to run, and rejects anything else with E_INVALID. * - * A null value is read as absent. The bridge turns a JS `undefined` into - * null, so native can not tell `data: null` from `data: undefined`. + * `varsJson` and `descriptor.dataJson` are JSON text. They cross as strings + * because React Native on iOS drops object keys whose value is null. Native + * keeps the text as it came: it is the body that goes out. A dataJson of + * "null" is the JSON body null, a real body. */ object EntryParsing { class InvalidEntryException(message: String) : IllegalArgumentException(message) @@ -31,7 +33,8 @@ object EntryParsing { fun parse(entry: ReadableMap): Parsed { val id = entry.string("id")?.takeIf { it.isNotEmpty() } ?: invalid("id is required") val key = entry.string("key")?.takeIf { it.isNotEmpty() } ?: invalid("key is required") - val varsJson = JsonBridge.toJson(JsonBridge.valueOf(entry, "vars")) + val varsJson = entry.string("varsJson") ?: invalid("varsJson is required") + if (!JsonBridge.isJson(varsJson)) invalid("varsJson is not JSON text") val d = entry.map("descriptor") ?: invalid("descriptor is required") val expiresAt = d.number("expiresAt")?.toLong() ?: invalid("descriptor.expiresAt is required") return Parsed(id, key, varsJson, descriptor(d), expiresAt) @@ -46,7 +49,13 @@ object EntryParsing { if (url == null && parts == null) invalid("url is required unless parts is set") url?.let { requireHttpUrl(it, "url") } - val dataJson = if (d.isSet("data")) JsonBridge.toJson(JsonBridge.valueOf(d, "data")) else null + // An old JS layer would send `data`. Ignoring it would send no body. + if (d.isSet("data")) invalid("data crosses as dataJson") + val dataJson = if (d.isSet("dataJson")) { + val text = d.string("dataJson") ?: invalid("dataJson must be a string") + if (!JsonBridge.isJson(text)) invalid("dataJson is not JSON text") + text + } else null val form = d.array("form")?.let { parseForm(it) } val file = d.string("file")?.let { stripFileScheme(it) } val kinds = listOfNotNull(dataJson?.let { "data" }, form?.let { "form" }, file?.let { "file" }) @@ -152,14 +161,30 @@ object EntryParsing { if (url.toHttpUrlOrNull() == null) invalid("$where is not an http(s) url: $url") } - // OkHttp throws on a header name or value it can not send. Check it here, - // so the error is an enqueue rejection and not a failure at attempt time. - private fun requireValidHeaders(headers: Map, where: String) { - try { - val builder = Headers.Builder() - headers.forEach { (k, v) -> builder.add(k, v) } - } catch (e: IllegalArgumentException) { - invalid("$where: ${e.message}") + /** + * OkHttp throws on a header name or value it can not send. Check it here, + * so the error is an enqueue rejection and not a failure at attempt time. + * The message names the header and the offset only: a value can be a + * credential, and OkHttp's own message would print it. + */ + internal fun requireValidHeaders(headers: Map, where: String) { + headers.forEach { (name, value) -> + // OkHttp's rules: a name is 1+ chars in 0x21..0x7e; a value is tab or 0x20..0x7e. + if (name.isEmpty()) invalid("$where: a header name is empty") + name.indexOfFirst { it !in '\u0021'..'\u007e' }.takeIf { it >= 0 }?.let { i -> + // Only the valid part before the offset: the rest could be a value + // pasted into the name. + invalid("$where: the header name that starts '${name.take(i)}' has an invalid character at offset $i") + } + value.indexOfFirst { it != '\t' && it !in '\u0020'..'\u007e' }.takeIf { it >= 0 }?.let { i -> + invalid("$where: the value of header '$name' has an invalid character at offset $i") + } + // A backstop for any rule OkHttp adds later. Its message is not used. + try { + Headers.Builder().add(name, value) + } catch (e: IllegalArgumentException) { + invalid("$where: the HTTP client does not accept header '$name'") + } } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/EntryRun.kt b/android/src/main/java/ai/openspace/backgroundupload/EntryRun.kt new file mode 100644 index 00000000..d9b5ffbe --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/EntryRun.kt @@ -0,0 +1,325 @@ +package ai.openspace.backgroundupload + +import kotlinx.coroutines.CancellationException +import okhttp3.RequestBody +import java.io.IOException +import java.util.UUID +import kotlin.math.max +import kotlin.math.min + +/** + * The run of one queue entry, and the one attempt step both transfers use. + * No Android types: [EntryWorker] is the shell that holds the per-id gate + * and is the real [TransferHost]. + * + * The run: take the entry (queued → running), run the transfer, then + * settle, park, or release. The body kind at run time picks the transfer, + * [SimpleTransfer] or [ChunkedTransfer], so a kind change under a queued run + * is safe. The entry is read from the store at start and again before every + * attempt, so fresh headers and a new expiresAt reach a running run. + */ +internal class EntryRun( + val entryId: String, + val store: QueueStore, + val ops: WorkerOps, + val host: TransferHost, +) { + /** A 401/403 under the headers of [headerGeneration] (the entry's value the attempt sent). */ + class ParkException(val headerGeneration: Int) : Exception("awaiting auth") + + /** A backoff too long to wait here. */ + class BackoffException(val nextAttemptAt: Long, val streak: Int) : Exception("released for backoff") + + class ExpiredException : Exception("expired before completion") + + /** The queue was paused between the module's pause and the work cancel reaching us. */ + class PausedException : Exception("queue paused") + + /** How one attempt ended, after the retry table. */ + sealed class AttemptResult { + data class Accepted(val response: UploadResponse) : AttemptResult() + + /** A 401/403. [reissue]: newer headers arrived while it was in flight, so send again now. */ + data class Auth(val headerGeneration: Int, val reissue: Boolean) : AttemptResult() + + object Transient : AttemptResult() + + data class Terminal(val errorKind: String, val message: String, val response: UploadResponse?) : AttemptResult() + } + + /** One attempt: the entry it ran under, where it went, the retry policy, and how it ended. */ + class Attempt( + val entry: QueueEntry, + val url: String, + val policy: RetryClassifier.Policy, + val result: AttemptResult, + ) { + val method: String get() = entry.descriptor!!.method + } + + companion object { + /** The poll while the network is unusable (offline, or waiting for wifi). */ + const val CONNECTIVITY_POLL_MS = 10_000L + + /** The poll while a short backoff remainder runs out before the run starts. */ + private const val WAIT_POLL_MS = 10_000L + + /** The descriptor's headers, the part's over them, and X-Request-Id over all. */ + fun headersFor(d: Descriptor, part: Part?, requestId: String): Map { + val withPart = if (part == null) d.headers else HeaderMap.merge(d.headers, part.headers) + return HeaderMap.merge(withPart, mapOf("X-Request-Id" to requestId)) + } + } + + var generation = 0 + private set + + /** The bytes of the current attempt, as last reported. A failed simple entry settles with them. */ + @Volatile + var liveBytes = 0L + private set + + /** + * Returns when the run is over. Throws a CancellationException after it + * handled a stop, or an IOException after a store write failed (the + * caller returns retry; nothing settled). + */ + suspend fun run() { + val initial = store.load(entryId) ?: return // forgotten while queued + if (initial.legacy) return + if (initial.state == EntryState.AWAITING_AUTH) { + // The expiry wake of a parked entry. No attempt ran here, so the + // stored bytes stand. + if (RetryClassifier.isExpired(host.now(), initial.expiresAt)) { + ops.settle(entryId, initial.generation, expired(initial).copy(bytesSent = null)) + } + return + } + if (initial.state != EntryState.QUEUED && initial.state != EntryState.RUNNING) return + if (ops.settings().paused) return + if (!waitUntilDue(initial)) return + val entry = ops.begin(entryId) ?: return + generation = entry.generation + + var current = entry + var first = true + while (true) { + try { + if (!first) current = ops.latest(entryId, generation) + first = false + host.foreground(current) + val settlement = transfer(current) + endProgress(completed = settlement is Settlement.Completed) + ops.settle(entryId, generation, settlement) + return + } catch (park: ParkException) { + endProgress(completed = false) + // REISSUE: updateHeaders() landed while this attempt was in flight. + if (ops.park(entryId, generation, park.headerGeneration) != WorkerOps.ParkResult.REISSUE) return + } catch (backoff: BackoffException) { + endProgress(completed = false) + ops.release(entryId, generation, backoff.nextAttemptAt, backoff.streak) + return + } catch (error: ExpiredException) { + endProgress(completed = false) + ops.settle(entryId, generation, expired(current)) + return + } catch (error: NotOwnedException) { + endProgress(completed = false) + return + } catch (error: PausedException) { + endProgress(completed = false) + return + } catch (error: CancellationException) { + // A system stop moves a running entry back to queued. A pause or a + // cancel already moved it; then this does nothing. + endProgress(completed = false) + if (host.stoppedByTimeout()) releaseAfterTimeout() else ops.stopped(entryId, generation) + throw error + } catch (error: IOException) { + // A store write failed (disk full, directory briefly unwritable). + // The attempt step classifies every network IOException itself, so + // one that lands here is storage: transient, no outcome. Back to + // queued; the caller returns retry. + endProgress(completed = false) + ops.stopped(entryId, generation) + throw error + } catch (error: Throwable) { + endProgress(completed = false) + val d = current.descriptor + ops.settle( + entryId, + generation, + Settlement.Failed( + errorKind = "unknown", + message = error.message ?: error.javaClass.simpleName, + response = null, + partIndex = null, + url = d?.reportUrl ?: "", + method = d?.method ?: "POST", + bytesSent = liveBytesOf(current), + ), + ) + return + } + } + } + + private suspend fun transfer(entry: QueueEntry): Settlement = + if (entry.body?.kind == StagedBody.CHUNKED) ChunkedTransfer(this).run(entry) + else SimpleTransfer(this).run(entry) + + private fun liveBytesOf(entry: QueueEntry): Long? = + if (entry.body?.kind == StagedBody.CHUNKED) null else liveBytes + + private fun expired(entry: QueueEntry) = Settlement.Failed( + errorKind = "expired", + message = "expired before completion", + response = null, + partIndex = null, + url = entry.descriptor?.reportUrl ?: "", + method = entry.descriptor?.method ?: "POST", + bytesSent = liveBytesOf(entry), + ) + + /** + * The system stopped the run at its time limit. That happens to a + * headless run whose foreground start was denied (API 31+), after about + * 10 minutes. Starting again at once would send the body from byte 0 on + * every run, so this is one more transient failure: the next backoff + * step, then a wake. + */ + private fun releaseAfterTimeout() { + runCatching { + val e = store.load(entryId) + if (!EntryTransitions.isOwnedRun(e, generation)) return + val streak = e!!.backoffStreak + 1 + val now = host.now() + val backoff = RetryClassifier.backoffMs(policy(e), streak) + ops.release(entryId, generation, RetryClassifier.nextAttemptAt(now, backoff, e.expiresAt), streak) + }.onFailure { Diag.error("could not release '$entryId' after a timeout stop", it) } + } + + /** + * Sleeps out a short backoff remainder. False when the wait is long (the + * wake run comes back for it) or the entry is no longer queued. + */ + private suspend fun waitUntilDue(initial: QueueEntry): Boolean { + var e = initial + while (true) { + val at = e.nextAttemptAt ?: return true + val remaining = at - host.now() + if (remaining <= 0) return true + if (remaining > RetryClassifier.IN_WORKER_BACKOFF_MAX_MS) return false + host.sleep(min(remaining, WAIT_POLL_MS)) + e = store.load(entryId) ?: return false + if (e.state != EntryState.QUEUED && e.state != EntryState.RUNNING) return false + } + } + + // MARK: - helpers for the transfers + + /** + * Waits until the network fits the queue's wifi-only setting. Re-reads + * the settings and the entry at every poll. + */ + suspend fun waitForNetwork() { + while (true) { + val s = ops.settings() + if (s.paused) throw PausedException() + val entry = ops.latest(entryId, generation) + if (RetryClassifier.isExpired(host.now(), entry.expiresAt)) throw ExpiredException() + if (host.connectivity(s.wifiOnly) == Connectivity.Ok) return + host.sleep(CONNECTIVITY_POLL_MS) + } + } + + fun policy(entry: QueueEntry) = + RetryClassifier.policy(ops.settings().retry, entry.descriptor?.retry) + + /** + * One HTTP attempt, the step both transfers share: write the attempt + * ahead (attempts + 1, its X-Request-Id), send, emit the attempt event, + * and classify. [partIndex] is null for a simple entry. [body] builds the + * request body from the entry the attempt runs under. [fileExists] tells a + * missing payload from a network failure. + */ + suspend fun attempt( + partIndex: Int?, + body: (Descriptor, Part?) -> RequestBody?, + onProgress: (Long) -> Unit, + fileExists: () -> Boolean, + ): Attempt { + val requestId = UUID.randomUUID().toString() + val entry = ops.recordAttempt(entryId, generation, requestId) + // The generation of the headers this attempt sends: both come from the same entry. + val headerGeneration = entry.headerGeneration + val d = entry.descriptor!! + val part = partIndex?.let { d.parts!![it] } + val url = part?.url ?: d.url!! + val policy = policy(entry) + + val response = try { + host.send(TransferRequest(url, d.method, headersFor(d, part, requestId), body(d, part)), onProgress) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + // A failed probe reads as present, so a flaky check is a retryable + // network error and not a terminal "file gone". + val verdict = RetryClassifier.classifyFailure(error, runCatching(fileExists).getOrDefault(true)) + host.attempt( + AttemptEvent.ofFailure( + entry, requestId, url, partIndex, RetryClassifier.failureKind(verdict), + error.message ?: error.javaClass.simpleName, host.now(), + ), + ) + val result = if (verdict is RetryClassifier.Verdict.Terminal) { + AttemptResult.Terminal(verdict.errorKind, verdict.message, null) + } else AttemptResult.Transient + return Attempt(entry, url, policy, result) + } + + val verdict = RetryClassifier.classifyResponse(response.code, response.body, d.accept, policy.exempt) + host.attempt( + AttemptEvent.ofResponse( + entry, requestId, url, partIndex, response, verdict == RetryClassifier.Verdict.Accepted, host.now(), + ), + ) + val result = when (verdict) { + RetryClassifier.Verdict.Accepted -> AttemptResult.Accepted(response) + RetryClassifier.Verdict.Auth -> + AttemptResult.Auth(headerGeneration, ops.hasNewerHeaders(entryId, generation, headerGeneration)) + RetryClassifier.Verdict.Transient -> AttemptResult.Transient + is RetryClassifier.Verdict.Terminal -> AttemptResult.Terminal("http", verdict.message, response) + } + return Attempt(entry, url, policy, result) + } + + /** + * A short backoff waits here, with the row still running and showing + * nextAttemptAt; a long one throws [BackoffException] to release the run. + */ + suspend fun backoffOrRelease(policy: RetryClassifier.Policy, streak: Int, expiresAt: Long) { + val backoff = RetryClassifier.backoffMs(policy, streak) + val now = host.now() + if (backoff <= RetryClassifier.IN_WORKER_BACKOFF_MAX_MS) { + val wait = min(backoff, max(0L, expiresAt - now)) + ops.backingOff(entryId, generation, now + wait) + host.sleep(wait) + return + } + throw BackoffException(RetryClassifier.nextAttemptAt(now, backoff, expiresAt), streak) + } + + fun progressStarted(total: Long, sent: Long) { + liveBytes = sent + host.progressStarted(entryId, total, sent) + } + + fun reportProgress(sent: Long, total: Long) { + liveBytes = sent + host.progress(entryId, sent, total) + } + + private fun endProgress(completed: Boolean) = host.progressEnded(entryId, completed) +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/EntryTransitions.kt b/android/src/main/java/ai/openspace/backgroundupload/EntryTransitions.kt index 94a297fa..d099d8df 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EntryTransitions.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EntryTransitions.kt @@ -76,9 +76,37 @@ object EntryTransitions { updatedAt = now, ) - /** Whether a worker of [generation] may still settle [e]. Not after a cancel, a replace, or a settle. */ - fun canSettle(e: QueueEntry?, generation: Int) = - e != null && e.generation == generation && e.isLive + /** + * Whether a worker of [generation] may still settle [e]. Not after a + * cancel, a replace, or a settle. Under pause only an [accepted] response + * settles: the server already took it. A failure waits for resume, which + * runs the entry again. + */ + fun canSettle(e: QueueEntry?, generation: Int, accepted: Boolean) = + e != null && e.generation == generation && e.isLive && (accepted || e.state != EntryState.PAUSED) + + /** The entry state a journal record puts its entry in. */ + fun stateOf(record: EventJournal.SettledRecord): EntryState = + EntryState.values().firstOrNull { it.wire == record.state } ?: EntryState.ERROR + + /** A settled entry, and the other records of its life to ack. */ + data class Journaled(val entry: QueueEntry, val extraEventIds: List) + + /** + * A live entry with a journal record of its own generation: the settle + * journaled, then its store write was lost (a process death, a failed + * save). Apply the newest record; do not run the request again. Null when + * there is no such record. The boot sweep and a worker's begin share it. + */ + fun journaledSettle(e: QueueEntry, records: List, now: Long): Journaled? { + if (!e.isLive || e.legacy) return null + val own = records.filter { it.id == e.id && it.generation == e.generation } + val latest = own.maxByOrNull { it.at } ?: return null + return Journaled( + toSettled(e, stateOf(latest), latest.eventId, latest.bytesSent, now), + own.filter { it !== latest }.map { it.eventId }, + ) + } /** Whether a worker of [generation] still owns the running entry. */ fun isOwnedRun(e: QueueEntry?, generation: Int) = diff --git a/android/src/main/java/ai/openspace/backgroundupload/EntryWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/EntryWorker.kt index 35f67957..d967862b 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EntryWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EntryWorker.kt @@ -4,57 +4,35 @@ import android.app.NotificationManager import android.content.Context import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo +import androidx.work.WorkInfo import androidx.work.WorkerParameters import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException -import kotlin.math.max -import kotlin.math.min /** - * Runs one queue entry. The input data holds only the entry id; the worker - * reads the entry from the store at start and again before every attempt, - * so fresh headers and a new expiresAt reach a running worker with no - * restart. + * The WorkManager shell of one entry's run. The input data holds only the + * entry id. The worker acquires the per-id gate, then [EntryRun] does the + * run; this class is its [TransferHost]: OkHttp, the clock, progress, and + * the notification. * - * The run: acquire the per-id gate, take the entry (queued → running), run - * the transfer, then settle, park, or release. The body kind picks the - * transfer: [SimpleTransfer] or [ChunkedTransfer]. [UploadWorker] and - * [ChunkedUploadWorker] are the two class names WorkManager knows; both run - * this same code, so a kind change under a queued run is safe. - * - * Every run returns success (see [WorkManagerScheduler] for why). A v9 row, - * which has no entry id, exits at once in silence. + * [UploadWorker] and [ChunkedUploadWorker] are the two class names + * WorkManager knows; both run this same code. Every run returns success + * (see [WorkManagerScheduler] for why), except a store failure before the + * run could settle, which returns retry. A v9 row, which has no entry id, + * exits at once in silence. */ open class EntryWorker(protected val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { companion object { private const val GATE_POLL_MS = 100L - /** The poll while the network is unusable (offline, or waiting for wifi). */ - const val CONNECTIVITY_POLL_MS = 10_000L - /** The poll while a short backoff remainder runs out before the run starts. */ - private const val WAIT_POLL_MS = 10_000L } - /** A 401/403 under the headers of [headerGeneration] (the entry's value the attempt sent). */ - class ParkException(val headerGeneration: Int) : Exception("awaiting auth") - - /** A backoff too long to wait here. */ - class BackoffException(val nextAttemptAt: Long, val streak: Int) : Exception("released for backoff") - - class ExpiredException : Exception("expired before completion") - - /** The queue was paused between the module's pause and the work cancel reaching us. */ - class PausedException : Exception("queue paused") - - internal lateinit var entryId: String - internal var generation = 0 - internal val store by lazy { QueueStore.get(context) } - internal val ops by lazy { + private val store by lazy { QueueStore.get(context) } + private val ops by lazy { WorkerOps( store, EventJournal.get(context), @@ -75,12 +53,11 @@ open class EntryWorker(protected val context: Context, params: WorkerParameters) final override suspend fun doWork(): Result = withContext(Dispatchers.IO) { val id = inputData.getString(WorkManagerScheduler.ENTRY_ID_KEY) ?: return@withContext Result.success() - entryId = id // Acquire before the first store read: a cancel-then-enqueue can start // this run while the old one still winds down. while (!WorkerGate.tryAcquire(id, this@EntryWorker)) delay(GATE_POLL_MS) try { - runEntry() + EntryRun(id, store, ops, host).run() Result.success() } catch (error: CancellationException) { throw error @@ -94,174 +71,45 @@ open class EntryWorker(protected val context: Context, params: WorkerParameters) } } - private suspend fun runEntry() { - val initial = store.load(entryId) ?: return // forgotten while queued - if (initial.legacy) return - if (initial.state == EntryState.AWAITING_AUTH) { - // The expiry wake of a parked entry. - if (RetryClassifier.isExpired(now(), initial.expiresAt)) { - ops.settle(entryId, initial.generation, expired(initial)) - } - return - } - if (initial.state != EntryState.QUEUED && initial.state != EntryState.RUNNING) return - if (ops.settings().paused) return - if (!waitUntilDue(initial)) return - val entry = ops.begin(entryId) ?: return - generation = entry.generation - showsNotification = entry.descriptor?.noNotification == false + private val host = object : TransferHost { + override fun now() = System.currentTimeMillis() - var current = entry - var first = true - while (true) { - try { - if (!first) current = ops.latest(entryId, generation) - first = false - startForeground() - val settlement = transfer(current) - endProgress(completed = settlement is Settlement.Completed) - ops.settle(entryId, generation, settlement) - return - } catch (park: ParkException) { - endProgress(completed = false) - // REISSUE: updateHeaders() landed while this attempt was in flight. - if (ops.park(entryId, generation, park.headerGeneration) != WorkerOps.ParkResult.REISSUE) return - } catch (backoff: BackoffException) { - endProgress(completed = false) - ops.release(entryId, generation, backoff.nextAttemptAt, backoff.streak) - return - } catch (error: ExpiredException) { - endProgress(completed = false) - ops.settle(entryId, generation, expired(current)) - return - } catch (error: NotOwnedException) { - endProgress(completed = false) - return - } catch (error: PausedException) { - endProgress(completed = false) - return - } catch (error: CancellationException) { - // A system stop moves a running entry back to queued. A pause or a - // cancel already moved it; then this does nothing. - endProgress(completed = false) - ops.stopped(entryId, generation) - throw error - } catch (error: IOException) { - // A store write failed (disk full, directory briefly unwritable). - // The transfers classify every network IOException themselves, so - // one that lands here is storage: transient, no outcome. Back to - // queued; doWork returns retry. - endProgress(completed = false) - ops.stopped(entryId, generation) - throw error - } catch (error: Throwable) { - endProgress(completed = false) - val d = current.descriptor - ops.settle( - entryId, - generation, - Settlement.Failed( - errorKind = "unknown", - message = error.message ?: error.javaClass.simpleName, - response = null, - partIndex = null, - url = d?.reportUrl ?: "", - method = d?.method ?: "POST", - ), - ) - return - } - } - } + override suspend fun sleep(ms: Long) = delay(ms) - private suspend fun transfer(entry: QueueEntry): Settlement = - if (entry.body?.kind == StagedBody.CHUNKED) ChunkedTransfer(this).run(entry) - else SimpleTransfer(this).run(entry) + override suspend fun send(request: TransferRequest, onProgress: (Long) -> Unit): UploadResponse = + transferSemaphore.withPermit { okhttpSend(uploadHttpClient, request, onProgress) } - private fun expired(entry: QueueEntry) = Settlement.Failed( - errorKind = "expired", - message = "expired before completion", - response = null, - partIndex = null, - url = entry.descriptor?.reportUrl ?: "", - method = entry.descriptor?.method ?: "POST", - ) - - /** - * Sleeps out a short backoff remainder. False when the wait is long (the - * wake run comes back for it) or the entry is no longer queued. - */ - private suspend fun waitUntilDue(initial: QueueEntry): Boolean { - var e = initial - while (true) { - val at = e.nextAttemptAt ?: return true - val remaining = at - now() - if (remaining <= 0) return true - if (remaining > RetryClassifier.IN_WORKER_BACKOFF_MAX_MS) return false - delay(min(remaining, WAIT_POLL_MS)) - e = store.load(entryId) ?: return false - if (e.state != EntryState.QUEUED && e.state != EntryState.RUNNING) return false + override fun connectivity(wifiOnly: Boolean): Connectivity { + connectivity = validateConnectivity(context, wifiOnly) + updateNotification() + return connectivity } - } - // MARK: - helpers for the transfers + override suspend fun foreground(entry: QueueEntry) { + showsNotification = entry.descriptor?.noNotification == false + startForeground() + } - internal fun now() = System.currentTimeMillis() + override fun progressStarted(id: String, total: Long, sent: Long) { + UploadProgress.add(id, total) + UploadProgress.set(id, sent) + } - /** - * Waits until the network fits the queue's wifi-only setting. Re-reads - * the settings and the entry at every poll. - */ - internal suspend fun waitForNetwork() { - while (true) { - val s = ops.settings() - if (s.paused) throw PausedException() - val entry = ops.latest(entryId, generation) - if (RetryClassifier.isExpired(now(), entry.expiresAt)) throw ExpiredException() - connectivity = validateConnectivity(context, s.wifiOnly) + override fun progress(id: String, sent: Long, total: Long) { + UploadProgress.set(id, sent) + EventReporter.progress(id, sent, total) updateNotification() - if (connectivity == Connectivity.Ok) return - delay(CONNECTIVITY_POLL_MS) } - } - - /** The descriptor's headers, the part's over them, and X-Request-Id over all. */ - internal fun headersFor(d: Descriptor, part: Part?, requestId: String): Map { - val withPart = if (part == null) d.headers else HeaderMap.merge(d.headers, part.headers) - return HeaderMap.merge(withPart, mapOf("X-Request-Id" to requestId)) - } - - internal fun policy(entry: QueueEntry) = - RetryClassifier.policy(ops.settings().retry, entry.descriptor?.retry) - /** - * A short backoff waits here, with the row still running and showing - * nextAttemptAt; a long one throws [BackoffException] to release the worker. - */ - internal suspend fun backoffOrRelease(policy: RetryClassifier.Policy, streak: Int, expiresAt: Long) { - val backoff = RetryClassifier.backoffMs(policy, streak) - val now = now() - if (backoff <= RetryClassifier.IN_WORKER_BACKOFF_MAX_MS) { - val wait = min(backoff, max(0L, expiresAt - now)) - ops.backingOff(entryId, generation, now + wait) - delay(wait) - return + override fun progressEnded(id: String, completed: Boolean) { + if (completed) UploadProgress.complete(id) else UploadProgress.remove(id) + EventReporter.flushProgress(id) + EventReporter.dropProgress(id) } - throw BackoffException(RetryClassifier.nextAttemptAt(now, backoff, expiresAt), streak) - } - - internal fun reportProgress(sent: Long, total: Long) { - UploadProgress.set(entryId, sent) - EventReporter.progress(entryId, sent, total) - updateNotification() - } - internal fun bodyFile(entry: QueueEntry): File? = store.bodyFile(entry) + override fun attempt(event: AttemptEvent) = EventReporter.attempt(event) - private fun endProgress(completed: Boolean) { - if (completed) UploadProgress.complete(entryId) else UploadProgress.remove(entryId) - EventReporter.flushProgress(entryId) - EventReporter.dropProgress(entryId) + override fun stoppedByTimeout() = stopReason == WorkInfo.STOP_REASON_TIMEOUT } // MARK: - notification @@ -269,7 +117,9 @@ open class EntryWorker(protected val context: Context, params: WorkerParameters) // v9 rules. A suppressed notification means no foreground mode. A denied // foreground start (API 31+, app in the background: the usual case for a // WorkManager relaunch) is not a failure; the transfer runs without - // foreground priority. Any other failure is logged and the run goes on. + // foreground priority, under JobScheduler's time limit (see + // [EntryRun.releaseAfterTimeout]). Any other failure is logged and the run + // goes on. private suspend fun startForeground() { if (!showsNotification) return try { diff --git a/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt index 03535d68..3f560947 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EventJournal.kt @@ -4,6 +4,8 @@ import android.content.Context import com.facebook.react.bridge.WritableMap import com.google.gson.Gson import java.io.File +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit /** * The durable record of settled outcomes (completed, error, cancelled). A @@ -13,9 +15,15 @@ import java.io.File * record. * * [maxEntries] is a runaway guard: if nothing ever acknowledges, the oldest - * records are dropped. It only fires in that broken case. + * records are dropped, except those a row still names. It only fires in + * that broken case. */ -class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTRIES) { +class EventJournal( + private val dir: File, + private val maxEntries: Int = MAX_ENTRIES, + /** Runs a task after a delay. Tests pass a manual one. */ + private val retryLater: (delayMs: Long, task: () -> Unit) -> Unit = ::onTimer, +) { /** RawResponse. [status] is null for a chunked completion. */ data class Response( @@ -33,8 +41,8 @@ class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTR companion object { fun of(response: UploadResponse): Response { - val (body, truncated) = capBody(response.body) - return Response(response.code, response.headers, body, truncated) + val (body, cut) = BodyCap.cap(response.body, BodyCap.SETTLED_MAX_BYTES) + return Response(response.code, response.headers, body, response.truncated || cut) } /** A chunked completion: N parts, no one response. */ @@ -52,8 +60,9 @@ class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTR val attempts: Int, val requestId: String?, /** - * How many times this outcome reached JS: 1 after a live emit, 0 when it - * was journaled with JS dead; +1 per later delivery (replay, re-emit). + * How many times this outcome reached a JS listener: 1 after a live + * emit, 0 when it was journaled with no listener; +1 per later delivery + * (replay, re-emit). [append] sets it. */ val deliveries: Int, /** The entry state this outcome puts it in. */ @@ -109,9 +118,11 @@ class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTR const val KIND_ERROR = "error" const val KIND_CANCELLED = "cancelled" - /** 1 MB, the RawResponse cap. */ - const val MAX_BODY_CHARS = 1_048_576 const val MAX_ENTRIES = 1000 + + /** The first wait before a held record is written again. It doubles up to [RETRY_MAX_MS]. */ + const val RETRY_MS = 5_000L + const val RETRY_MAX_MS = 600_000L private val gson = Gson() // Event ids are UUIDs that native mints. ackEvents takes ids from JS, and @@ -120,12 +131,15 @@ class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTR fun isValidEventId(id: String) = EVENT_ID.matches(id) - /** - * A char-count cap. It is not byte-exact: a cut on a byte boundary could - * split a surrogate pair. Returns the body and whether it was cut. - */ - fun capBody(body: String?, max: Int = MAX_BODY_CHARS): Pair = - if (body != null && body.length > max) body.substring(0, max) to true else body to false + private val timer by lazy { + Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "RNFileUploader.journal").apply { isDaemon = true } + } + } + + private fun onTimer(delayMs: Long, task: () -> Unit) { + timer.schedule(task, delayMs, TimeUnit.MILLISECONDS) + } @Volatile private var instance: EventJournal? = null @@ -141,53 +155,154 @@ class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTR dir.mkdirs() } + /** + * The JS listener, set by [drain] and cleared by [stopListening]. While it + * is set, a new record starts at 1 delivery and the caller emits it live. + * While it is null, a record starts at 0 and the next drain delivers it. + * Both decisions take this object's lock, so a record is either in the + * drain or emitted live, never both and never neither. + */ + private var listener: Any? = null + + /** + * Records whose file write failed, by eventId. They count as journaled in + * every read and ack, and a timer writes them once the disk allows. They + * live only in memory: a process death loses them. + */ + private val held = LinkedHashMap() + private var retryScheduled = false + private fun fileFor(eventId: String) = File(dir, "$eventId.json") /** - * Never throws. The worker calls this right after the server accepted the - * request. A thrown IOException would look like a transient failure and - * re-send the request. Losing one record is the lesser harm, so a failure - * returns false and the caller goes on. + * Writes [record] with deliveries 1 when a listener is set, else 0, and + * returns it as written. The caller emits it only when deliveries > 0. + * Throws IOException when the write failed; nothing is kept then. + * + * [keep] names the records the prune must not delete (every eventId a row + * names). It runs only when the journal is over its cap, under this lock. + * Callers hold the store lock (lock order: store, then journal). */ @Synchronized - fun append(record: SettledRecord): Boolean { - val bounded = record.response?.let { r -> - val (body, truncated) = capBody(r.body) - if (truncated) record.copy(response = r.copy(body = body, bodyTruncated = true)) else record - } ?: record - val written = try { - AtomicFiles.writeText(fileFor(record.eventId), gson.toJson(bounded)) - true + fun append(record: SettledRecord, keep: () -> Set = { emptySet() }): SettledRecord { + val stamped = stamp(record) + AtomicFiles.writeText(fileFor(stamped.eventId), gson.toJson(stamped)) + pruneToMax(keep) + return stamped + } + + /** + * As [append], but never throws. A failed write holds the record in + * memory and a timer tries it again. For a worker's settle: the request + * already ran, and a thrown error would send it again. + */ + @Synchronized + fun appendOrHold(record: SettledRecord, keep: () -> Set = { emptySet() }): SettledRecord = + try { + append(record, keep) } catch (t: Throwable) { - Diag.error("journal append failed for ${record.eventId}", t) - false + Diag.error("journal append failed for ${record.eventId}; held in memory", t) + val stamped = stamp(record) + held[stamped.eventId] = stamped + scheduleRetry(RETRY_MS) + stamped + } + + private fun stamp(record: SettledRecord): SettledRecord { + val response = record.response?.let { r -> + val (body, cut) = BodyCap.cap(r.body, BodyCap.SETTLED_MAX_BYTES) + if (cut) r.copy(body = body, bodyTruncated = true) else r + } + return record.copy(deliveries = if (listener != null) 1 else 0, response = response) + } + + /** Whether [eventId] is held in memory, not yet on disk. */ + @Synchronized + fun isHeld(eventId: String) = eventId in held + + /** Writes every held record. Returns true when none is left. */ + @Synchronized + fun writeHeld(): Boolean { + val written = held.values.filter { r -> + runCatching { AtomicFiles.writeText(fileFor(r.eventId), gson.toJson(r)) }.isSuccess } - pruneToMax() - return written + written.forEach { held.remove(it.eventId) } + return held.isEmpty() } - // Prunes by file time (no parsing). Guarded for the same reason as append. - private fun pruneToMax() { + private fun scheduleRetry(delayMs: Long) { + if (retryScheduled) return + retryScheduled = true + retryLater(delayMs) { + synchronized(this) { + retryScheduled = false + if (!writeHeld()) scheduleRetry(minOf(delayMs * 2, RETRY_MAX_MS)) + } + } + } + + // Prunes by file time (no parsing), sparing the records rows name. Never + // throws: it only runs in the broken case where nothing acknowledges. + private fun pruneToMax(keep: () -> Set) { try { dir.listFiles { f -> f.name.endsWith(AtomicFiles.TMP_SUFFIX) }?.forEach { it.delete() } val files = dir.listFiles { f -> f.extension == "json" } ?: return if (files.size <= maxEntries) return - files.sortedBy { it.lastModified() }.take(files.size - maxEntries).forEach { it.delete() } + val named = keep() + files.filter { it.nameWithoutExtension !in named } + .sortedBy { it.lastModified() } + .take(files.size - maxEntries) + .forEach { it.delete() } } catch (t: Throwable) { Diag.error("journal prune failed", t) } } - /** Every record, oldest first. Corrupt files are skipped. */ + /** + * getUnacknowledgedEvents(): sets [owner] as the listener and returns + * every record, oldest first, each counted as one more delivery. One lock + * spans both, see [listener]. + */ + @Synchronized + fun drain(owner: Any, isActive: () -> Boolean = { true }): List { + // [isActive] is read under this lock. A module torn down before its + // queued drain runs does not become the listener, and counts nothing. + if (!isActive()) return emptyList() + listener = owner + return unacknowledged().mapNotNull { incrementDeliveries(it.eventId) } + } + + /** Clears the listener, only when [owner] set it: a reload builds the next module before it tears down this one. */ + @Synchronized + fun stopListening(owner: Any) { + if (listener === owner) listener = null + } + + @Synchronized + fun isListening() = listener != null + + /** The listener a live settled event goes to: the module whose JS drained last. */ + @Synchronized + fun listener(): Any? = listener + + /** + * A re-emit of a journaled outcome (same-id rule 7): one more delivery, + * returned for a live emit. Null when no listener is set (the next drain + * delivers it) or the record is gone. + */ + @Synchronized + fun redeliver(eventId: String): SettledRecord? = + if (listener == null) null else incrementDeliveries(eventId) + + /** Every record, oldest first, the held ones included. Corrupt files are skipped. */ @Synchronized fun unacknowledged(): List = - (dir.listFiles { f -> f.extension == "json" } ?: emptyArray()) - .mapNotNull { read(it) } - .sortedBy { it.at } + ((dir.listFiles { f -> f.extension == "json" } ?: emptyArray()).mapNotNull { read(it) } + held.values) + .sortedWith(compareBy { it.at }.thenBy { it.eventId }) @Synchronized fun find(eventId: String): SettledRecord? = - if (isValidEventId(eventId)) read(fileFor(eventId)) else null + if (isValidEventId(eventId)) held[eventId] ?: read(fileFor(eventId)) else null @Synchronized fun forEntry(id: String): List = unacknowledged().filter { it.id == id } @@ -201,6 +316,10 @@ class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTR fun incrementDeliveries(eventId: String): SettledRecord? { val record = find(eventId) ?: return null val next = record.copy(deliveries = record.deliveries + 1) + if (eventId in held) { + held[eventId] = next + return next + } try { AtomicFiles.writeText(fileFor(eventId), gson.toJson(next)) } catch (t: Throwable) { @@ -212,7 +331,16 @@ class EventJournal(private val dir: File, private val maxEntries: Int = MAX_ENTR /** Idempotent. Unknown and malformed ids are ignored. */ @Synchronized fun ack(eventIds: List) { - eventIds.filter { isValidEventId(it) }.forEach { fileFor(it).delete() } + eventIds.filter { isValidEventId(it) }.forEach { + held.remove(it) + fileFor(it).delete() + } + } + + /** Acks every record of entry [id]: cancel() of a settled entry forgets its outcomes. */ + @Synchronized + fun ackEntry(id: String) { + ack(forEntry(id).map { it.eventId }) } @Suppress("SENSELESS_COMPARISON", "USELESS_ELVIS") diff --git a/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt index 68121854..ea3cc62a 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/EventReporter.kt @@ -6,16 +6,13 @@ import com.facebook.react.bridge.Arguments interface QueueEvents { fun state(row: RequestRow) - /** The caller journaled [record] first. */ - fun settled(record: EventJournal.SettledRecord) - /** - * Whether a live emit can reach a JS listener now. A record journaled - * while no listener is there (JS dead, or alive but not yet subscribed) - * starts at 0 deliveries, so its first real delivery (the replay) counts - * as 1. + * The caller journaled [record] first, and emits it only when its + * deliveries is above 0: [EventJournal] decides that under its lock. + * [listener] is [EventJournal.listener]: the module whose JS drained, so + * the delivery the journal counted goes to that JS. */ - fun canDeliver(): Boolean + fun settled(record: EventJournal.SettledRecord, listener: Any) } /** @@ -40,13 +37,13 @@ object EventReporter : QueueEvents { module.emitState(JsonBridge.toWritableMap(row.toMap())) } - override fun settled(record: EventJournal.SettledRecord) { - val module = UploaderModule.instance ?: return + // Not UploaderModule.instance: a reload sets that before the new JS + // subscribes, and the journal counted this delivery for the listener. + override fun settled(record: EventJournal.SettledRecord, listener: Any) { + val module = listener as? UploaderModule ?: return module.emitSettled(record.toWritableMap()) } - override fun canDeliver(): Boolean = UploaderModule.instance?.listening == true - /** Moves the row's bytesSent in memory and emits through the throttle. */ fun progress(id: String, sent: Long, total: Long) { RequestIndex.shared.setBytes(id, sent) diff --git a/android/src/main/java/ai/openspace/backgroundupload/JsonBridge.kt b/android/src/main/java/ai/openspace/backgroundupload/JsonBridge.kt index a84b8661..fe84fbe7 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/JsonBridge.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/JsonBridge.kt @@ -6,31 +6,29 @@ import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap -import com.google.gson.GsonBuilder -import com.google.gson.JsonArray +import com.google.gson.Gson import com.google.gson.JsonElement -import com.google.gson.JsonNull -import com.google.gson.JsonObject import com.google.gson.JsonParser import com.google.gson.JsonPrimitive +import com.google.gson.Strictness +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import java.io.StringReader import kotlin.math.abs import kotlin.math.floor /** - * Moves values between three forms: the bridge (ReadableMap, WritableMap), - * plain Kotlin values (Map, List, String, Double, Boolean, null), and JSON - * text. `vars` and `data` are stored as JSON text. + * Moves values between the bridge (ReadableMap, WritableMap), plain Kotlin + * values (Map, List, String, Double, Boolean, null), and JSON text. `vars` + * and `data` cross from JS as JSON text and are stored as it came; native + * parses them back only to hand objects to JS. * - * Numbers: RN gives every JS number to Kotlin as a Double. A Double with no - * fraction is written as an integer, so `{ n: 1 }` becomes `{"n":1}`, as - * JSON.stringify writes it, not `{"n":1.0}`. - * - * Key order: the bridge does not keep the JS key order. Object keys are - * written sorted, so the same object always gives the same text. The - * same-body check compares that text. + * Numbers: RN gives every JS number to Kotlin as a Double. [numberText] + * writes a Double with no fraction as an integer, so a header value 5 is + * "5", as JSON.stringify writes it, not "5.0". */ object JsonBridge { - private val gson = GsonBuilder().serializeNulls().disableHtmlEscaping().create() + private val gson = Gson() // 2^53. Above this a Double can not hold every integer, so it keeps the // Double form. @@ -71,12 +69,20 @@ object JsonBridge { } } - /** Plain values to JSON text. */ - fun toJson(value: Any?): String = gson.toJson(toElement(value)) - /** JSON text to plain values. Throws on malformed text. Numbers come back as Double. */ fun parse(json: String): Any? = fromElement(JsonParser.parseString(json)) + /** + * Whether [text] is one strict JSON value, as JSON.stringify writes it. + * Any top-level value counts, so "null" is valid. Lenient forms (single + * quotes, bare keys, trailing text) are not. + */ + fun isJson(text: String): Boolean = runCatching { + val reader = JsonReader(StringReader(text)).apply { setStrictness(Strictness.STRICT) } + gson.getAdapter(JsonElement::class.java).read(reader) + reader.peek() == JsonToken.END_DOCUMENT + }.getOrDefault(false) + /** A number as JSON would print it: an integer when it has no fraction. */ fun numberText(d: Double): String = gson.toJson(number(d)) @@ -84,24 +90,6 @@ object JsonBridge { if (d.isFinite() && d == floor(d) && abs(d) < MAX_SAFE_INTEGER) JsonPrimitive(d.toLong()) else JsonPrimitive(d) - private fun toElement(value: Any?): JsonElement = when (value) { - null -> JsonNull.INSTANCE - is Boolean -> JsonPrimitive(value) - is Double -> number(value) - is Float -> number(value.toDouble()) - is Int, is Long, is Short, is Byte -> JsonPrimitive((value as Number).toLong()) - is Number -> JsonPrimitive(value) - is String -> JsonPrimitive(value) - is Map<*, *> -> JsonObject().apply { - value.entries - .sortedBy { it.key.toString() } - .forEach { (k, v) -> add(k.toString(), toElement(v)) } - } - is Iterable<*> -> JsonArray().apply { value.forEach { add(toElement(it)) } } - is Array<*> -> JsonArray().apply { value.forEach { add(toElement(it)) } } - else -> JsonPrimitive(value.toString()) - } - private fun fromElement(element: JsonElement): Any? = when { element.isJsonNull -> null element.isJsonObject -> LinkedHashMap().apply { diff --git a/android/src/main/java/ai/openspace/backgroundupload/LegacyImport.kt b/android/src/main/java/ai/openspace/backgroundupload/LegacyImport.kt index d00e97c5..da5b19ab 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/LegacyImport.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/LegacyImport.kt @@ -33,10 +33,13 @@ object LegacyImport { private val gson = Gson() /** Returns true when the import ran at this launch (no marker yet). */ - fun runOnce(context: Context, store: QueueStore): Boolean { - val marker = File(QueueStore.rootDir(context), MARKER) + fun runOnce(context: Context, store: QueueStore): Boolean = + runOnce(File(QueueStore.rootDir(context), MARKER), File(context.filesDir, V9_JOURNAL_DIR), store) + + /** [runOnce] over plain files, for the JVM tests. */ + internal fun runOnce(marker: File, v9Dir: File, store: QueueStore): Boolean { if (marker.exists()) return false - val complete = import(File(context.filesDir, V9_JOURNAL_DIR), store) + val complete = import(v9Dir, store) if (complete) { runCatching { AtomicFiles.writeText(marker, "1") } .onFailure { Diag.error("could not write the v9 import marker", it) } diff --git a/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt b/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt index 810895d7..c72cb0fe 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt @@ -54,7 +54,7 @@ class QueueController( } finally { if (pre != null && !preUsed) discard(p.id, pre) } - result.reEmit?.let { events.settled(it) } + result.reEmit?.let { record -> journal.listener()?.let { events.settled(record, it) } } result.entry?.let { entry -> scheduleRun(entry) events.state(entry.toRow()) @@ -65,7 +65,7 @@ class QueueController( private fun preStage(p: EntryParsing.Parsed): PreStaged? { val generation = store.locked { val existing = store.load(p.id) - val v9 = if (existing == null) store.legacyManifest(p.id) else null + val v9 = v9ManifestFor(existing, p.id) val action = EnqueueRules.decide(existing, v9, p.descriptor) { journal.find(it) != null } EnqueueRules.preStageGeneration(existing, action, p.descriptor) } ?: return null @@ -84,7 +84,7 @@ class QueueController( /** Runs under the store lock. [preStaged] returns the body staged outside the lock for a generation, if any. */ private fun decideAndCommit(p: EntryParsing.Parsed, preStaged: (generation: Int) -> BodyStaging.Staged?): Enqueued { val existing = store.load(p.id) - val v9 = if (existing == null) store.legacyManifest(p.id) else null + val v9 = v9ManifestFor(existing, p.id) val s = settings.load() val now = clock() val dir = store.entryDir(p.id) @@ -93,7 +93,8 @@ class QueueController( QueueException.E_RUNNING, "entry '${p.id}' is running; a different body is accepted once it stops", ) - is EnqueueRules.Action.ReEmit -> Enqueued(null, journal.incrementDeliveries(action.eventId)) + // With no listener yet, the next drain delivers it. + is EnqueueRules.Action.ReEmit -> Enqueued(null, journal.redeliver(action.eventId)) EnqueueRules.Action.Resume -> { val next = EnqueueRules.resumed(existing!!, p, s.paused, s.headerGeneration, now) saveOrThrow(next) @@ -108,8 +109,8 @@ class QueueController( val parts = incoming?.let { EnqueueRules.adoptedParts(action.manifest, it) } // The same parts resume over the v9 blob, as a same-body enqueue does. val keepOwned = incoming != null && ChunkedParts.sameParts(action.manifest.parts, incoming) - val staged = stageOrThrow(p.descriptor.copy(parts = parts), dir, 1, store.blobFile(p.id), keepOwned) - commit(EnqueueRules.created(p, staged, parts, s.paused, s.headerGeneration, now)) + val staged = stageOrThrow(p.descriptor.copy(parts = parts), dir, action.generation, store.blobFile(p.id), keepOwned) + commit(EnqueueRules.adopted(p, staged, parts, existing, action.generation, s.paused, s.headerGeneration, now)) } EnqueueRules.Action.Replace -> { val old = existing!! @@ -122,6 +123,10 @@ class QueueController( } } + /** A v9 manifest counts only where no v10 entry owns the id, or the owner is its legacy row. */ + private fun v9ManifestFor(existing: QueueEntry?, id: String): LegacyManifest? = + if (existing == null || existing.legacy) store.legacyManifest(id) else null + private fun stageOrThrow( d: Descriptor, dir: java.io.File, @@ -183,27 +188,59 @@ class QueueController( /** * Live: journal a 'cancelled' (user) outcome, then forget after its ack. - * Settled: forget now, row and bytes. Unknown: no-op. Unacked records of a - * forgotten entry are kept, so a handler that has not run yet still runs. + * Settled (or legacy): forget now, row, bytes, and its unacknowledged + * outcomes. Unknown: no-op. + * + * A failed journal write rejects E_STORAGE and changes nothing: the entry + * goes on, and JS can call cancel() again. + * + * A failed entry save after the journal write also rejects E_STORAGE, but + * the cancel is durable: the work is stopped and the record is emitted. + * Its ack, the next cancel(), a worker's begin or settle, or the boot + * sweep applies it. A live entry that already has a record of its own + * generation gets that record applied, not a second outcome. */ fun cancel(id: String) { - val settled = store.locked { - val e = store.load(id) ?: return@locked null - if (!e.isLive || e.legacy) { - store.remove(id) - return@locked null + var stop = false + var journaled: EventJournal.SettledRecord? = null + var saved: QueueEntry? = null + try { + store.locked { + stop = true // unknown or settled: stop any stray work, as before + val e = store.load(id) ?: return@locked + if (!e.isLive || e.legacy) { + journal.ackEntry(id) + store.remove(id) + return@locked + } + stop = false // a live entry: only once an outcome is journaled + val now = clock() + EntryTransitions.journaledSettle(e, journal.forEntry(id), now)?.let { + stop = true + saveOrThrow(it.entry) + journal.ack(it.extraEventIds) + saved = it.entry + return@locked + } + val record = cancelledRecord(e, now) + journaled = try { + journal.append(record) { store.referencedEventIds() + record.eventId } + } catch (error: IOException) { + throw QueueException(QueueException.E_STORAGE, "could not journal the cancel: ${error.message}") + } + stop = true + val next = EntryTransitions.toSettled(e, EntryState.CANCELLED, record.eventId, e.bytesSent, now) + saveOrThrow(next) + saved = next } - val now = clock() - val record = cancelledRecord(e, now) - journal.append(record) - val next = EntryTransitions.toSettled(e, EntryState.CANCELLED, record.eventId, e.bytesSent, now) - saveOrThrow(next) - next to record - } - scheduler.cancel(id) - settled?.let { (entry, record) -> - if (record.deliveries > 0) events.settled(record) - events.state(entry.toRow()) + } finally { + // Once an outcome is journaled, the worker must stop even when the + // save failed: a running request would settle a second outcome. + if (stop) scheduler.cancel(id) + journaled?.let { record -> + if (record.deliveries > 0) journal.listener()?.let { events.settled(record, it) } + } + saved?.let { events.state(it.toRow()) } } } @@ -215,7 +252,7 @@ class QueueController( at = now, attempts = e.attempts, requestId = e.lastRequestId, - deliveries = if (events.canDeliver()) 1 else 0, + deliveries = 0, // the journal sets it state = EntryState.CANCELLED.wire, bytesSent = e.bytesSent, totalBytes = e.totalBytes, @@ -263,9 +300,12 @@ class QueueController( // MARK: - journal - /** Every unacknowledged outcome, each counted as one more delivery. */ - fun unacknowledged(): List = - journal.unacknowledged().mapNotNull { journal.incrementDeliveries(it.eventId) } + /** + * getUnacknowledgedEvents(): [listener] becomes the JS listener, and every + * unacknowledged outcome is returned, each counted as one more delivery. + */ + fun unacknowledged(listener: Any, isActive: () -> Boolean = { true }): List = + journal.drain(listener, isActive) /** * Removes the records. An acked completed or cancelled outcome of the @@ -286,7 +326,7 @@ class QueueController( val record = journal.find(eventId) ?: return@forEach var e = store.load(record.id) if (e != null && e.isLive && !e.legacy && e.generation == record.generation) { - val next = EntryTransitions.toSettled(e, stateOf(record), record.eventId, record.bytesSent, clock()) + val next = EntryTransitions.toSettled(e, EntryTransitions.stateOf(record), record.eventId, record.bytesSent, clock()) if (!trySave(next)) return@forEach repaired += next e = next @@ -303,9 +343,6 @@ class QueueController( repaired.filter { it.id !in forgotten }.forEach { events.state(it.toRow()) } } - private fun stateOf(record: EventJournal.SettledRecord): EntryState = - EntryState.values().firstOrNull { it.wire == record.state } ?: EntryState.ERROR - // MARK: - boot sweep /** @@ -336,12 +373,11 @@ class QueueController( if (e.legacy || isWorkerRunning(e.id)) continue val own = records[e.id].orEmpty().filter { it.generation == e.generation } if (e.isLive) { - val latest = own.maxByOrNull { it.at } - if (latest != null) { - val next = EntryTransitions.toSettled(e, stateOf(latest), latest.eventId, latest.bytesSent, now) - if (trySave(next)) { - journal.ack(own.filter { it !== latest }.map { it.eventId }) - changed += next + val journaled = EntryTransitions.journaledSettle(e, own, now) + if (journaled != null) { + if (trySave(journaled.entry)) { + journal.ack(journaled.extraEventIds) + changed += journaled.entry } continue } diff --git a/android/src/main/java/ai/openspace/backgroundupload/QueueStore.kt b/android/src/main/java/ai/openspace/backgroundupload/QueueStore.kt index 1beb0870..3db76532 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/QueueStore.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/QueueStore.kt @@ -122,10 +122,17 @@ class QueueStore(private val dir: File, private val index: RequestIndex = Reques (dir.listFiles { f -> f.isDirectory } ?: emptyArray()) .mapNotNull { d -> File(d, ENTRY_FILE).takeIf { it.exists() }?.let { read(it) } } - /** The v9 chunked manifest for [id], when the directory has no v10 entry. */ + /** Every eventId a row names. The journal prune spares them. */ + @Synchronized + fun referencedEventIds(): Set = all().mapNotNull { it.settledEventId }.toSet() + + /** + * The v9 chunked manifest in [id]'s directory. The caller asks only when + * there is no v10 entry or the entry is a legacy row: a v10 entry + * prunes the manifest when it adopts it. + */ @Synchronized fun legacyManifest(id: String): LegacyManifest? { - if (entryFile(id).exists()) return null val file = File(entryDir(id), V9_MANIFEST_FILE) if (!file.exists()) return null val parsed = runCatching { gson.fromJson(file.readText(), LegacyManifest::class.java) }.getOrNull() @@ -181,15 +188,14 @@ class QueueStore(private val dir: File, private val index: RequestIndex = Reques } } -/** A v9 chunked manifest. The field names are the v9 ones. */ +/** + * A v9 chunked manifest, only the fields v10 reads. The field names are the + * v9 ones; Gson skips the rest of the file. + */ data class LegacyManifest( val id: String, - val sourcePath: String, val parts: List, val accept: List, - val expiresAt: Long, - val noNotification: Boolean, - val createdAt: Long, ) { companion object { @Suppress("SENSELESS_COMPARISON") diff --git a/android/src/main/java/ai/openspace/backgroundupload/RequestIndex.kt b/android/src/main/java/ai/openspace/backgroundupload/RequestIndex.kt index a59708be..be3b7b0d 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/RequestIndex.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/RequestIndex.kt @@ -36,8 +36,6 @@ class RequestIndex { rows.remove(id) } - fun get(id: String): RequestRow? = rows[id] - /** Oldest first, then by id. */ fun snapshot(): List = rows.values.sortedWith(compareBy { it.createdAt }.thenBy { it.id }) diff --git a/android/src/main/java/ai/openspace/backgroundupload/RetryClassifier.kt b/android/src/main/java/ai/openspace/backgroundupload/RetryClassifier.kt index cfd01781..d570ec61 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/RetryClassifier.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/RetryClassifier.kt @@ -60,9 +60,11 @@ object RetryClassifier { else -> Verdict.Terminal("unknown", error.message ?: error.javaClass.simpleName) } - /** The live attempt's errorKind for a failure: network, file, or unknown. */ - fun failureKind(error: Throwable, fileExists: Boolean): String = - UploadOutcome.errorKind(error, fileExists) + /** + * The live attempt's errorKind for a transport failure, from its + * [classifyFailure] verdict: file, unknown, or network for a transient one. + */ + fun failureKind(verdict: Verdict): String = (verdict as? Verdict.Terminal)?.errorKind ?: "network" fun isExpired(now: Long, expiresAt: Long) = now >= expiresAt diff --git a/android/src/main/java/ai/openspace/backgroundupload/TransferHost.kt b/android/src/main/java/ai/openspace/backgroundupload/TransferHost.kt new file mode 100644 index 00000000..d7556a3d --- /dev/null +++ b/android/src/main/java/ai/openspace/backgroundupload/TransferHost.kt @@ -0,0 +1,34 @@ +package ai.openspace.backgroundupload + +/** + * What a run needs from the platform: time, the network, progress, the + * notification, and live attempt events. [EntryWorker] is the real one. The + * JVM tests pass a fake with a scripted [send] and a manual clock, so every + * branch of [EntryRun] runs with no device. + */ +internal interface TransferHost { + fun now(): Long + + suspend fun sleep(ms: Long) + + /** One request through the library-wide cap of 4. Throws on a transport failure. */ + suspend fun send(request: TransferRequest, onProgress: (Long) -> Unit): UploadResponse + + /** The network state for the queue's wifi-only setting. The notification shows it. */ + fun connectivity(wifiOnly: Boolean): Connectivity + + /** Foreground mode for an entry that shows the notification. Never throws. */ + suspend fun foreground(entry: QueueEntry) + + fun progressStarted(id: String, total: Long, sent: Long) + + fun progress(id: String, sent: Long, total: Long) + + /** The trailing progress event, then the progress state is dropped. */ + fun progressEnded(id: String, completed: Boolean) + + fun attempt(event: AttemptEvent) + + /** Whether the system stopped this run at its time limit (JobScheduler, about 10 minutes). */ + fun stoppedByTimeout(): Boolean +} diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt index 9c22321a..f1edc40a 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadOutcome.kt @@ -1,7 +1,5 @@ package ai.openspace.backgroundupload -import java.io.IOException - // Pure classification of terminal upload outcomes. Kept free of Android/React // types so it can be unit-tested on a plain JVM — this is the highest-consequence // logic in the uploader (it decides success vs failure), so it's covered directly. @@ -26,14 +24,4 @@ object UploadOutcome { rule.status == code && (rule.bodyIncludes == null || body?.contains(rule.bodyIncludes) == true) } - - // Classify a thrown error into a stable kind for the JS layer. `fileExists` - // is passed in (not read here) to keep this pure; callers should default it to - // true when the existence check itself fails, so a flaky file probe reads as a - // retryable network error rather than a terminal "file gone". - fun errorKind(error: Throwable, fileExists: Boolean): String = when { - error is IOException && !fileExists -> "file" - error is IOException -> "network" - else -> "unknown" - } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt index 6d84b4f0..154cac08 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadUtils.kt @@ -26,10 +26,12 @@ private const val PROGRESS_INTERVAL = 500 // milliseconds private const val RANGE_COPY_BUFFER = 64 * 1024 +/** [truncated] when the body passed [BodyCap.SETTLED_MAX_BYTES] and the rest was not read. */ data class UploadResponse( val code: Int, val body: String, - val headers: Map + val headers: Map, + val truncated: Boolean = false, ) /** One request as the worker sends it. [body] is null only for GET and DELETE with no body. */ @@ -104,13 +106,17 @@ private suspend fun awaitResponse(client: OkHttpClient, request: Request): Uploa override fun onResponse(call: Call, response: Response) { val result = try { response.use { res -> // close the response asap + // The body unchanged: an empty body stays empty. A substituted + // reason phrase would make accept `bodyIncludes` rules match text + // the server never sent. The cap applies while it streams in. + val body = res.body?.let { + BodyCap.read(it.source(), BodyCap.SETTLED_MAX_BYTES, it.contentType()?.charset() ?: Charsets.UTF_8) + } UploadResponse( res.code, - // The body unchanged: an empty body stays empty. A substituted - // reason phrase would make accept `bodyIncludes` rules match text - // the server never sent. - res.body?.string().orEmpty(), - res.headers.toMultimap().mapValues { it.value.joinToString(", ") } + body?.text.orEmpty(), + res.headers.toMultimap().mapValues { it.value.joinToString(", ") }, + body?.truncated ?: false, ) } } catch (e: IOException) { diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt index ceb1ee1c..a49f7548 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploadWorker.kt @@ -2,99 +2,55 @@ package ai.openspace.backgroundupload import android.content.Context import androidx.work.WorkerParameters -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.sync.withPermit import okhttp3.RequestBody import java.io.File -import java.util.UUID /** The WorkManager class for a single-body entry. The run is [EntryWorker]'s. */ class UploadWorker(context: Context, params: WorkerParameters) : EntryWorker(context, params) /** * One request with one body: none, JSON, multipart, or a copied file. One - * attempt at a time, until a verdict ends it: + * attempt at a time ([EntryRun.attempt]), until a verdict ends it: * accepted → Completed; auth → re-issue (newer headers) or park; * transient → back off (short: here; long: release); terminal → Failed. */ -internal class SimpleTransfer(private val host: EntryWorker) { +internal class SimpleTransfer(private val run: EntryRun) { suspend fun run(start: QueueEntry): Settlement { val d0 = start.descriptor!! - val file = host.bodyFile(start) + val file = run.store.bodyFile(start) // The payload probe: a staged body that is gone can never be sent. if (file != null && !file.exists()) { return Settlement.Failed("file", "the staged request body is missing", null, null, d0.reportUrl, d0.method) } val total = start.body?.totalBytes ?: 0L - UploadProgress.add(host.entryId, total) + run.progressStarted(total, 0L) var streak = start.backoffStreak while (true) { - val latest = host.ops.latest(host.entryId, host.generation) - if (RetryClassifier.isExpired(host.now(), latest.expiresAt)) throw EntryWorker.ExpiredException() - host.waitForNetwork() + val latest = run.ops.latest(run.entryId, run.generation) + if (RetryClassifier.isExpired(run.host.now(), latest.expiresAt)) throw EntryRun.ExpiredException() + run.waitForNetwork() - val requestId = UUID.randomUUID().toString() - val entry = host.ops.recordAttempt(host.entryId, host.generation, requestId) - // The generation of the headers this attempt sends: both come from the same entry. - val headerGeneration = entry.headerGeneration - val d = entry.descriptor!! - val url = d.url!! - val policy = host.policy(entry) - - val response = try { - transferSemaphore.withPermit { - okhttpSend( - uploadHttpClient, - TransferRequest(url, d.method, host.headersFor(d, null, requestId), requestBody(file, d.method)), - ) { sent -> host.reportProgress(sent, total) } - } - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - host.reportProgress(0L, total) - val fileExists = file == null || runCatching { file.exists() }.getOrDefault(true) - val message = error.message ?: error.javaClass.simpleName - EventReporter.attempt( - AttemptEvent.ofFailure( - entry, requestId, url, null, RetryClassifier.failureKind(error, fileExists), message, host.now(), - ), - ) - when (val verdict = RetryClassifier.classifyFailure(error, fileExists)) { - is RetryClassifier.Verdict.Terminal -> - return Settlement.Failed(verdict.errorKind, verdict.message, null, null, url, d.method) - else -> { - streak++ - host.backoffOrRelease(policy, streak, entry.expiresAt) - continue - } - } - } - - val verdict = RetryClassifier.classifyResponse(response.code, response.body, d.accept, policy.exempt) - EventReporter.attempt( - AttemptEvent.ofResponse( - entry, requestId, url, null, response, verdict == RetryClassifier.Verdict.Accepted, host.now(), - ), + val a = run.attempt( + partIndex = null, + body = { d, _ -> requestBody(file, d.method) }, + onProgress = { sent -> run.reportProgress(sent, total) }, + fileExists = { file == null || file.exists() }, ) - when (verdict) { - RetryClassifier.Verdict.Accepted -> return Settlement.Completed(response, url, d.method) - RetryClassifier.Verdict.Auth -> { - // updateHeaders() landed while this attempt was in flight: re-issue now. - if (host.ops.hasNewerHeaders(host.entryId, host.generation, headerGeneration)) { - streak = 0 - continue - } - throw EntryWorker.ParkException(headerGeneration) + when (val r = a.result) { + is EntryRun.AttemptResult.Accepted -> return Settlement.Completed(r.response, a.url, a.method) + is EntryRun.AttemptResult.Auth -> { + if (!r.reissue) throw EntryRun.ParkException(r.headerGeneration) + streak = 0 // updateHeaders() landed while this attempt was in flight: re-issue now. } - RetryClassifier.Verdict.Transient -> { - host.reportProgress(0L, total) + EntryRun.AttemptResult.Transient -> { + run.reportProgress(0L, total) streak++ - host.backoffOrRelease(policy, streak, entry.expiresAt) + run.backoffOrRelease(a.policy, streak, a.entry.expiresAt) } - is RetryClassifier.Verdict.Terminal -> - return Settlement.Failed("http", verdict.message, response, null, url, d.method) + is EntryRun.AttemptResult.Terminal -> + return Settlement.Failed(r.errorKind, r.message, r.response, null, a.url, a.method, bytesSent = run.liveBytes) } } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index 34f6fd2c..83ad4505 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -53,16 +53,6 @@ class UploaderModule(context: ReactApplicationContext) : private val scheduler = WorkManagerScheduler(context) private val controller = QueueController(store, journal, settings, EventReporter, scheduler) - /** - * True once JS subscribed to onSettled. JS subscribes, then calls - * getUnacknowledgedEvents() at once, so that first call is the signal. - * Until then a settled emit reaches no listener, so it must not count as - * a delivery. Cleared on teardown. - */ - @Volatile - var listening = false - private set - // The v9 import, then the v9 work cancel, then the boot sweep. // getRequests() waits for the import only (file reads and row saves), so // the first call after an upgrade already shows the legacy rows. The @@ -84,10 +74,17 @@ class UploaderModule(context: ReactApplicationContext) : } } + // Set by invalidate(). A drain still queued on the executor then does not + // make this dead module the journal's listener. + @Volatile + private var invalidated = false + override fun invalidate() { // A reload constructs the replacement before tearing this one down, so - // only clear the pointer when it still refers to us. - listening = false + // only clear the pointer (and the listener) when it still refers to us. + // The flag goes first: the drain reads it under the journal lock. + invalidated = true + journal.stopListening(this) if (instance === this) instance = null super.invalidate() } @@ -195,16 +192,16 @@ class UploaderModule(context: ReactApplicationContext) : } /** - * Every unacknowledged outcome, each counted as one more delivery. Sets - * [listening] first: an outcome settled from here on is emitted live with - * deliveries 1; one settled before it was journaled with 0, and this - * drain makes it 1. + * Every unacknowledged outcome, each counted as one more delivery. JS + * subscribes to onSettled, then calls this at once, so this call makes + * the module the journal's listener. The flip and the scan share the + * journal lock: an outcome settled before it is in this drain (journaled + * at 0, returned at 1); one settled after is emitted live at 1. */ override fun getUnacknowledgedEvents(promise: Promise) { - listening = true onQueue(promise) { val out = Arguments.createArray() - controller.unacknowledged().forEach { out.pushMap(it.toWritableMap()) } + controller.unacknowledged(this) { !invalidated }.forEach { out.pushMap(it.toWritableMap()) } out } } diff --git a/android/src/main/java/ai/openspace/backgroundupload/WorkerOps.kt b/android/src/main/java/ai/openspace/backgroundupload/WorkerOps.kt index 4f1fe2ef..0420fada 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/WorkerOps.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/WorkerOps.kt @@ -15,6 +15,7 @@ sealed class Settlement { override val method: String, ) : Settlement() + /** [bytesSent] is the live bytes of a simple entry's last attempt; null keeps the stored value. */ data class Failed( val errorKind: String, val message: String, @@ -22,6 +23,7 @@ sealed class Settlement { val partIndex: Int?, override val url: String, override val method: String, + val bytesSent: Long? = null, ) : Settlement() } @@ -49,19 +51,36 @@ class WorkerOps( ) { enum class ParkResult { PARKED, REISSUE, NOT_OWNED } - /** Takes a queued entry. Null when there is nothing to run. */ + /** + * Takes a queued entry. Null when there is nothing to run. + * + * A journal record of the entry's own generation means a settle was + * journaled and its store write was lost (a process death between the + * two, or a failed save). WorkManager can run the entry again before any + * boot sweep. Then begin applies the record, as the sweep does, and does + * not send the request again. + */ fun begin(id: String): QueueEntry? { val now = clock() - var changed = false - val entry = store.compute(id) { e -> - if (e != null && !e.legacy && (e.state == EntryState.QUEUED || e.state == EntryState.RUNNING)) { - changed = true - EntryTransitions.toRunning(e, now) - } else e + var row: QueueEntry? = null + var taken: QueueEntry? = null + store.locked { + val e = store.load(id) ?: return@locked + if (e.legacy || (e.state != EntryState.QUEUED && e.state != EntryState.RUNNING)) return@locked + val journaled = EntryTransitions.journaledSettle(e, journal.forEntry(id), now) + if (journaled != null) { + store.save(journaled.entry) + journal.ack(journaled.extraEventIds) + row = journaled.entry + return@locked + } + val next = EntryTransitions.toRunning(e, now) + store.save(next) + row = next + taken = next } - if (entry == null || entry.state != EntryState.RUNNING) return null - if (changed) events.state(entry.toRow()) - return entry + row?.let { events.state(it.toRow()) } + return taken } /** The stored entry, while this run still owns it. */ @@ -126,73 +145,88 @@ class WorkerOps( } /** - * Journal, then transition, then emit. When a cancel or a replace landed - * first, the record is an orphan: it is acked at once and nothing is - * emitted. Returns whether this run's outcome stands. + * Journal, then transition, then emit, all under the store lock, so a + * cancel, pause, or replace lands either before (this run's outcome is + * dropped) or after. Returns whether this run's outcome stands. + * + * A failed journal write holds the record in memory ([EventJournal.appendOrHold]): + * the request already ran, and a retry would send it twice. A failed + * store write leaves the record for the ack, the next [begin], or the + * boot sweep to apply. + * + * A record of this generation already in the journal (a cancel whose + * entry save failed) wins: it is applied, as [begin] does, and this run's + * outcome is dropped. One life has one outcome. */ fun settle(id: String, generation: Int, s: Settlement): Boolean { - val e = store.load(id) - if (!EntryTransitions.canSettle(e, generation)) return false - e!! val now = clock() val completed = s is Settlement.Completed - val state = if (completed) EntryState.COMPLETED else EntryState.ERROR - val bytesSent = if (completed) e.totalBytes else e.bytesSent val failed = s as? Settlement.Failed - val record = EventJournal.SettledRecord( - eventId = UUID.randomUUID().toString(), - id = e.id, - key = e.key, - varsJson = e.varsJson, - at = now, - attempts = e.attempts, - requestId = e.lastRequestId, - deliveries = if (events.canDeliver()) 1 else 0, - state = state.wire, - bytesSent = bytesSent, - totalBytes = e.totalBytes, - url = s.url, - method = s.method, - partIndex = failed?.partIndex, - kind = if (completed) EventJournal.KIND_COMPLETED else EventJournal.KIND_ERROR, - response = when (s) { - is Settlement.Completed -> s.response?.let { EventJournal.Response.of(it) } ?: EventJournal.Response.NONE - is Settlement.Failed -> s.response?.let { EventJournal.Response.of(it) } - }, - errorKind = failed?.errorKind, - message = failed?.message, - cancelReason = null, - generation = generation, - ) - // 1. The durable outcome. It never throws. - journal.append(record) - // JS can subscribe between the check above and the append, and its first - // drain can miss this record. Count it as a live delivery then. - val live = if (record.deliveries == 0 && events.canDeliver()) { - journal.incrementDeliveries(record.eventId) ?: record - } else record - // 2. The transition, atomic against cancel(). - var applied = false - val next = try { - store.compute(id) { cur -> - if (EntryTransitions.canSettle(cur, generation)) { - applied = true - EntryTransitions.toSettled(cur!!, state, record.eventId, bytesSent, now) - } else cur + var delivered: EventJournal.SettledRecord? = null + var settled: QueueEntry? = null + store.locked { + val e = store.load(id) + if (!EntryTransitions.canSettle(e, generation, completed)) return@locked + e!! + val journaled = EntryTransitions.journaledSettle(e, journal.forEntry(id), now) + if (journaled != null) { + try { + store.save(journaled.entry) + journal.ack(journaled.extraEventIds) + settled = journaled.entry + } catch (error: IOException) { + Diag.error("settle could not apply the journaled outcome of '$id'; its ack or the boot sweep applies it", error) + } + return@locked + } + val state = if (completed) EntryState.COMPLETED else EntryState.ERROR + // A failed simple entry keeps the live bytes of its last attempt; a + // chunked one keeps its accepted bytes (the stored value). + val bytesSent = if (completed) e.totalBytes else failed?.bytesSent ?: e.bytesSent + val record = EventJournal.SettledRecord( + eventId = UUID.randomUUID().toString(), + id = e.id, + key = e.key, + varsJson = e.varsJson, + at = now, + attempts = e.attempts, + requestId = e.lastRequestId, + deliveries = 0, // the journal sets it + state = state.wire, + bytesSent = bytesSent, + totalBytes = e.totalBytes, + url = s.url, + method = s.method, + partIndex = failed?.partIndex, + kind = if (completed) EventJournal.KIND_COMPLETED else EventJournal.KIND_ERROR, + response = when (s) { + is Settlement.Completed -> s.response?.let { EventJournal.Response.of(it) } ?: EventJournal.Response.NONE + is Settlement.Failed -> s.response?.let { EventJournal.Response.of(it) } + }, + errorKind = failed?.errorKind, + message = failed?.message, + cancelReason = null, + generation = generation, + ) + // 1. The durable outcome. It never throws. + delivered = journal.appendOrHold(record) { store.referencedEventIds() + record.eventId } + // 2. The transition. + val next = EntryTransitions.toSettled(e, state, record.eventId, bytesSent, now) + try { + store.save(next) + settled = next + } catch (error: IOException) { + Diag.error("settle could not save '$id'; its ack, the next run, or the boot sweep applies the record", error) } - } catch (error: IOException) { - // The record is durable; the boot sweep applies it to the entry. - Diag.error("settle could not save '$id'; its ack or the boot sweep repairs it", error) - if (live.deliveries > 0) events.settled(live) - return true } - if (!applied || next == null) { - journal.ack(listOf(record.eventId)) + val record = delivered + if (record == null) { + settled?.let { events.state(it.toRow()) } return false } // 3 and 4. Best effort. - if (live.deliveries > 0) events.settled(live) - events.state(next.toRow()) + if (record.deliveries > 0) journal.listener()?.let { events.settled(record, it) } + settled?.let { events.state(it.toRow()) } return true } diff --git a/android/src/test/java/ai/openspace/backgroundupload/BodyCapTest.kt b/android/src/test/java/ai/openspace/backgroundupload/BodyCapTest.kt new file mode 100644 index 00000000..53a75385 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/BodyCapTest.kt @@ -0,0 +1,70 @@ +package ai.openspace.backgroundupload + +import okio.Buffer +import okio.BufferedSource +import okio.buffer +import okio.ForwardingSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class BodyCapTest { + /** A source that counts the bytes read from it. */ + private class Counting(bytes: ByteArray) : ForwardingSource(Buffer().write(bytes)) { + var read = 0L + override fun read(sink: Buffer, byteCount: Long): Long = + super.read(sink, byteCount).also { if (it > 0) read += it } + } + + private fun source(bytes: ByteArray): Pair { + val c = Counting(bytes) + return c to c.buffer() + } + + @Test + fun `a body under the cap is read whole`() { + val (_, s) = source("hello".toByteArray()) + assertEquals(BodyCap.Capped("hello", false), BodyCap.read(s, 10)) + } + + @Test + fun `a body at exactly the cap is not truncated`() { + val (_, s) = source("0123456789".toByteArray()) + assertEquals(BodyCap.Capped("0123456789", false), BodyCap.read(s, 10)) + } + + @Test + fun `a huge body stops streaming just past the cap`() { + val (counting, s) = source(ByteArray(5_000_000) { 'x'.code.toByte() }) + val capped = BodyCap.read(s, 1_000) + assertTrue(capped.truncated) + assertEquals(1_000, capped.text.length) + // okio reads in 8 KB segments; nowhere near the 5 MB body. + assertTrue("read ${counting.read}", counting.read < 64 * 1024) + } + + @Test + fun `a cut inside a UTF-8 character backs off to the last whole one`() { + val euros = "€".repeat(4).toByteArray(Charsets.UTF_8) // 12 bytes + val (_, s) = source(euros) + val capped = BodyCap.read(s, 10) + assertEquals("€".repeat(3), capped.text) + assertTrue(capped.truncated) + } + + @Test + fun `cap measures UTF-8 bytes, not characters`() { + assertEquals("ab" to false, BodyCap.cap("ab", 2)) + assertEquals("é" to true, BodyCap.cap("éé", 3)) + assertEquals(null to false, BodyCap.cap(null, 3)) + // A 4-byte character (an emoji) is never split. + assertEquals("a" to true, BodyCap.cap("a😀", 4)) + } + + @Test + fun `bytes that are not UTF-8 are cut at the cap`() { + val bad = ByteArray(8) { 0x80.toByte() } // continuation bytes only + assertEquals(5, BodyCap.utf8Boundary(bad, 5)) + assertEquals(3, BodyCap.utf8Boundary("abc".toByteArray(), 5)) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/ChunkedEngineTest.kt b/android/src/test/java/ai/openspace/backgroundupload/ChunkedEngineTest.kt index 453bab91..c93f1dff 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/ChunkedEngineTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/ChunkedEngineTest.kt @@ -73,11 +73,11 @@ class ChunkedEngineTest { @Test fun `a park from one part stops the siblings with the park itself`() { // The worker needs the ParkException back, not a CancellationException. - val thrown = assertThrows(EntryWorker.ParkException::class.java) { + val thrown = assertThrows(EntryRun.ParkException::class.java) { runBlocking { ChunkedEngine.run((0 until 6).toList()) { index -> yield() - if (index == 1) throw EntryWorker.ParkException(4) + if (index == 1) throw EntryRun.ParkException(4) yield() } } diff --git a/android/src/test/java/ai/openspace/backgroundupload/EntryParsingTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EntryParsingTest.kt index 1d0fc10c..5ed1c534 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/EntryParsingTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/EntryParsingTest.kt @@ -3,38 +3,66 @@ package ai.openspace.backgroundupload import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Test class EntryParsingTest { - private fun entryMap(descriptor: JavaOnlyMap, vars: Any? = JavaOnlyMap.of("n", 1.0)) = - JavaOnlyMap.of("id", "e1", "key", "note", "vars", vars, "descriptor", descriptor) + private fun entryMap(descriptor: JavaOnlyMap, varsJson: Any? = """{"n":1}""") = + JavaOnlyMap.of("id", "e1", "key", "note", "varsJson", varsJson, "descriptor", descriptor) private fun base(vararg extra: Any?) = JavaOnlyMap.of("url", "https://example.com/items", "expiresAt", 9_000.0, *extra) @Test - fun `a JSON POST parses with defaults`() { - val p = EntryParsing.parse(entryMap(base("data", JavaOnlyMap.of("n", 1.0, "text", "hi")))) + fun `a JSON POST parses with defaults and keeps the JSON text as JS wrote it`() { + val text = """{"text":"hi","n":1,"status":null}""" + val p = EntryParsing.parse(entryMap(base("dataJson", text), varsJson = """{"b":2,"a":null}""")) assertEquals("e1", p.id) assertEquals("note", p.key) - assertEquals("""{"n":1}""", p.varsJson) + assertEquals("""{"b":2,"a":null}""", p.varsJson) // key order and null values survive assertEquals(9_000L, p.expiresAt) assertEquals("POST", p.descriptor.method) - assertEquals("""{"n":1,"text":"hi"}""", p.descriptor.dataJson) + assertEquals(text, p.descriptor.dataJson) assertEquals(StagedBody.JSON, p.descriptor.bodyKind) } @Test - fun `null vars store as the text null`() { - assertEquals("null", EntryParsing.parse(entryMap(base(), vars = null)).varsJson) + fun `null vars cross as the text null`() { + assertEquals("null", EntryParsing.parse(entryMap(base(), varsJson = "null")).varsJson) } @Test - fun `a null data is no body, because the bridge turns undefined into null`() { - assertNull(EntryParsing.parse(entryMap(base("data", null))).descriptor.dataJson) + fun `a dataJson of null is a real JSON body`() { + val d = EntryParsing.parse(entryMap(base("dataJson", "null"))).descriptor + assertEquals("null", d.dataJson) + assertEquals(StagedBody.JSON, d.bodyKind) + } + + @Test + fun `no dataJson is no body`() { + val d = EntryParsing.parse(entryMap(base())).descriptor + assertNull(d.dataJson) + assertEquals(StagedBody.NONE, d.bodyKind) + } + + @Test + fun `a missing or malformed varsJson or dataJson is rejected`() { + val cases = listOf( + entryMap(base(), varsJson = null), + entryMap(base(), varsJson = JavaOnlyMap.of("n", 1.0)), // the old object form + entryMap(base(), varsJson = "{n:1}"), // lenient JSON + entryMap(base(), varsJson = """{"n":1} trailing"""), + entryMap(base("dataJson", "")), + entryMap(base("dataJson", "{'a':1}")), + entryMap(base("dataJson", JavaOnlyMap.of("a", 1.0))), + entryMap(base("data", JavaOnlyMap.of("a", 1.0))), // the old data form would send no body + ) + cases.forEach { m -> + assertThrows("$m", EntryParsing.InvalidEntryException::class.java) { EntryParsing.parse(m) } + } } @Test @@ -93,8 +121,10 @@ class EntryParsingTest { val cases = listOf( JavaOnlyMap.of("url", "https://example.com"), // no expiresAt JavaOnlyMap.of("expiresAt", 1.0), // no url and no parts - base("data", 1.0, "file", "/a"), // two body kinds - base("method", "GET", "data", 1.0), // GET with a body + base("dataJson", "1", "file", "/a"), // two body kinds + base("method", "GET", "dataJson", "1"), // GET with a body + base("method", "GET", "dataJson", "null"), // GET with the JSON body null + base("method", "GET", "file", "/a"), base("method", "TRACE"), JavaOnlyMap.of("url", "not a url", "expiresAt", 1.0), base("headers", JavaOnlyMap.of("Bad\nName", "v")), @@ -108,12 +138,46 @@ class EntryParsingTest { } } + @Test + fun `a GET with no body parses`() { + assertEquals("GET", EntryParsing.parse(entryMap(base("method", "GET"))).descriptor.method) + } + + @Test + fun `a bad header value is rejected with its name and offset, never its value`() { + val secret = "Bearer s3cr3t-token" + val e = assertThrows(EntryParsing.InvalidEntryException::class.java) { + EntryParsing.parse(entryMap(base("headers", JavaOnlyMap.of("Authorization", "$secret\n")))) + } + assertEquals("headers: the value of header 'Authorization' has an invalid character at offset ${secret.length}", e.message) + assertFalse(e.message!!.contains("s3cr3t")) + } + + @Test + fun `a bad header name is rejected with the valid part before the offset only`() { + val e = assertThrows(EntryParsing.InvalidEntryException::class.java) { + EntryParsing.requireValidHeaders(mapOf("Authorization: Bearer s3cr3t" to "v"), "headers") + } + assertEquals("headers: the header name that starts 'Authorization:' has an invalid character at offset 14", e.message) + assertFalse(e.message!!.contains("s3cr3t")) + assertThrows(EntryParsing.InvalidEntryException::class.java) { + EntryParsing.requireValidHeaders(mapOf("" to "v"), "headers") + } + } + + @Test + fun `the header check accepts what OkHttp sends`() { + EntryParsing.requireValidHeaders(mapOf("X-Tab" to "a\tb", "X-Tilde" to "~!#", "Content-Range" to "bytes 0-9/10"), "headers") + okhttp3.Headers.Builder().add("X-Tab", "a\tb").add("X-Tilde", "~!#") + } + @Test fun `an updateHeaders patch is checked like descriptor headers`() { assertEquals(mapOf("Authorization" to "Bearer new"), EntryParsing.headerPatch(JavaOnlyMap.of("Authorization", "Bearer new"))) - assertThrows(EntryParsing.InvalidEntryException::class.java) { + val e = assertThrows(EntryParsing.InvalidEntryException::class.java) { EntryParsing.headerPatch(JavaOnlyMap.of("Authorization", "Bearer\nnew")) } + assertFalse(e.message!!.contains("Bearer")) } @Test diff --git a/android/src/test/java/ai/openspace/backgroundupload/EntryRunTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EntryRunTest.kt new file mode 100644 index 00000000..1e7e0511 --- /dev/null +++ b/android/src/test/java/ai/openspace/backgroundupload/EntryRunTest.kt @@ -0,0 +1,464 @@ +package ai.openspace.backgroundupload + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException + +/** A scripted [TransferHost]: a manual clock, a send handler, and a log of what the run did. */ +internal class FakeHost(var clock: Long = 10_000L) : TransferHost { + val requests = mutableListOf() + val sleeps = mutableListOf() + val attempts = mutableListOf() + val progress = mutableListOf() + var handler: suspend (TransferRequest, (Long) -> Unit) -> UploadResponse = { _, _ -> UploadResponse(200, "ok", mapOf()) } + var onSleep: () -> Unit = {} + var network = ArrayDeque() + var timeout = false + var foregroundError: Throwable? = null + + override fun now() = clock + + override suspend fun sleep(ms: Long) { + sleeps += ms + clock += ms + onSleep() + } + + override suspend fun send(request: TransferRequest, onProgress: (Long) -> Unit): UploadResponse { + requests += request + return handler(request, onProgress) + } + + override fun connectivity(wifiOnly: Boolean) = network.removeFirstOrNull() ?: Connectivity.Ok + + override suspend fun foreground(entry: QueueEntry) { + foregroundError?.let { throw it } + } + + override fun progressStarted(id: String, total: Long, sent: Long) { + progress += "start:$total:$sent" + } + + override fun progress(id: String, sent: Long, total: Long) { + progress += "$sent" + } + + override fun progressEnded(id: String, completed: Boolean) { + progress += "end:$completed" + } + + override fun attempt(event: AttemptEvent) { + attempts += event + } + + override fun stoppedByTimeout() = timeout +} + +class EntryRunTest { + @get:Rule + val tmp = TemporaryFolder() + + private lateinit var store: QueueStore + private lateinit var journal: EventJournal + private lateinit var settings: QueueSettingsStore + private val events = RecordingEvents() + private val scheduler = FakeScheduler() + private val host = FakeHost() + private lateinit var ops: WorkerOps + private lateinit var controller: QueueController + + @Before + fun setUp() { + val root = tmp.newFolder("queue") + store = QueueStore(root, RequestIndex()) + journal = EventJournal(tmp.newFolder("journal"), retryLater = { _, _ -> }) + settings = QueueSettingsStore(File(root, "settings.json")) + ops = WorkerOps(store, journal, settings, events, scheduler) { host.clock } + controller = QueueController(store, journal, settings, events, scheduler, { false }, { host.clock }) + controller.configureRetry(RetryDefaults(baseMs = 1_000, jitter = 0.0)) + journal.drain(Any()) + } + + private fun run(id: String = "e1") = runBlocking { EntryRun(id, store, ops, host).run() } + + private fun respond(vararg codes: Int) { + val queue = ArrayDeque(codes.toList()) + host.handler = { _, onProgress -> + onProgress(7) + UploadResponse(queue.removeFirst(), "body", mapOf()) + } + } + + private fun outcome() = journal.unacknowledged().single() + + // MARK: - simple + + @Test + fun `an accepted response settles completed after one write-ahead attempt`() { + controller.enqueue(parsed()) + respond(200) + run() + val e = store.load("e1")!! + assertEquals(EntryState.COMPLETED, e.state) + assertEquals(1, e.attempts) + val request = host.requests.single() + assertEquals(e.lastRequestId, request.headers["X-Request-Id"]) + assertEquals("Bearer old", request.headers["Authorization"]) + assertEquals("application/json", request.headers["Content-Type"]) + assertEquals(7L, request.body!!.contentLength()) + assertEquals(listOf("completed"), host.attempts.map { it.outcome }) + assertEquals(EventJournal.KIND_COMPLETED, outcome().kind) + assertEquals(200, outcome().response!!.status) + assertEquals(listOf("start:7:0", "7", "end:true"), host.progress) + } + + @Test + fun `a transient response waits a short backoff in place, with nextAttemptAt on the running row`() { + controller.enqueue(parsed()) + respond(503, 200) + run() + assertEquals(listOf(1_000L), host.sleeps) + assertEquals(listOf("error", "completed"), host.attempts.map { it.outcome }) + assertEquals("http", host.attempts[0].errorKind) + assertEquals(503, host.attempts[0].httpCode) + val waiting = events.rows.first { it.toMap().containsKey("nextAttemptAt") } + assertEquals("running", waiting.state) + assertEquals(11_000.0, waiting.toMap()["nextAttemptAt"]) + assertEquals(2, store.load("e1")!!.attempts) + assertEquals(listOf("start:7:0", "7", "0", "7", "end:true"), host.progress) // bytes reset between attempts + } + + @Test + fun `a backoff longer than 30 s releases the run back to queued with a wake`() { + controller.configureRetry(RetryDefaults(baseMs = 60_000, jitter = 0.0)) + controller.enqueue(parsed()) + respond(503) + run() + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertEquals(host.clock + 60_000, e.nextAttemptAt) + assertEquals(1, e.backoffStreak) + assertEquals(listOf("e1" to host.clock + 60_000), scheduler.wakes) + assertEquals(emptyList(), host.sleeps) + assertEquals("end:false", host.progress.last()) + assertEquals(emptyList(), journal.unacknowledged()) + } + + @Test + fun `a transport failure is a network attempt and retries`() { + controller.enqueue(parsed()) + var calls = 0 + host.handler = { _, _ -> if (calls++ == 0) throw IOException("reset") else UploadResponse(200, "", mapOf()) } + run() + assertEquals(listOf("network", null), host.attempts.map { it.errorKind }) + assertEquals("reset", host.attempts[0].errorMessage) + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + } + + @Test + fun `a terminal response settles error http with the response and the live bytes`() { + controller.enqueue(parsed()) + respond(400) + run() + val r = outcome() + assertEquals(EventJournal.KIND_ERROR, r.kind) + assertEquals("http", r.errorKind) + assertEquals(400, r.response!!.status) + assertEquals(7, r.bytesSent) + assertEquals(7, store.load("e1")!!.bytesSent) + assertEquals("end:false", host.progress.last()) + } + + @Test + fun `a staged body gone before the run settles error file with no request`() { + controller.enqueue(parsed()) + store.bodyFile(store.load("e1")!!)!!.delete() + run() + assertEquals("file", outcome().errorKind) + assertEquals(emptyList(), host.requests) + } + + @Test + fun `a body that vanishes mid-send settles error file, and the attempt says file`() { + controller.enqueue(parsed()) + host.handler = { _, _ -> + store.bodyFile(store.load("e1")!!)!!.delete() + throw IOException("ENOENT") + } + run() + assertEquals("file", outcome().errorKind) + assertEquals(listOf("file"), host.attempts.map { it.errorKind }) + } + + @Test + fun `a 401 parks the entry and wakes it at expiry`() { + controller.enqueue(parsed(expiresAt = 90_000)) + respond(401) + run() + assertEquals(EntryState.AWAITING_AUTH, store.load("e1")!!.state) + assertEquals(listOf("e1" to 90_000L), scheduler.wakes) + assertEquals(listOf("error"), host.attempts.map { it.outcome }) + assertEquals(emptyList(), journal.unacknowledged()) + } + + @Test + fun `a 401 with newer headers from updateHeaders mid-flight re-issues at once with them`() { + controller.enqueue(parsed()) + var calls = 0 + host.handler = { _, _ -> + if (calls++ == 0) { + controller.updateHeaders(mapOf("Authorization" to "Bearer new")) + UploadResponse(401, "", mapOf()) + } else UploadResponse(200, "", mapOf()) + } + run() + assertEquals(listOf("Bearer old", "Bearer new"), host.requests.map { it.headers["Authorization"] }) + assertEquals(emptyList(), host.sleeps) + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + } + + @Test + fun `the expiry wake of a parked entry settles expired and keeps the stored bytes`() { + controller.enqueue(parsed(expiresAt = 20_000)) + store.save(store.load("e1")!!.copy(state = EntryState.AWAITING_AUTH, bytesSent = 3)) + run() + assertEquals(EntryState.AWAITING_AUTH, store.load("e1")!!.state) // not yet expired + host.clock = 20_000 + run() + assertEquals("expired", outcome().errorKind) + assertEquals(3, outcome().bytesSent) + assertEquals(emptyList(), host.requests) + } + + @Test + fun `an entry past expiresAt settles error expired`() { + controller.enqueue(parsed(expiresAt = 5_000)) + run() + assertEquals("expired", outcome().errorKind) + assertEquals(emptyList(), host.requests) + } + + @Test + fun `a paused entry past expiresAt stays paused, and settles expired at resume`() { + controller.enqueue(parsed(expiresAt = 20_000)) + controller.pause() + host.clock = 30_000 + run() // a wake that fires during the pause + assertEquals(EntryState.PAUSED, store.load("e1")!!.state) + assertEquals(emptyList(), journal.unacknowledged()) + controller.resume() + assertEquals(EntryState.QUEUED, store.load("e1")!!.state) + run() + assertEquals("expired", outcome().errorKind) + assertEquals(EntryState.ERROR, store.load("e1")!!.state) + } + + @Test + fun `a failure that lands after pause is not an outcome`() { + controller.enqueue(parsed()) + host.handler = { _, _ -> + controller.pause() + UploadResponse(400, "", mapOf()) + } + run() + assertEquals(EntryState.PAUSED, store.load("e1")!!.state) + assertEquals(emptyList(), journal.unacknowledged()) + } + + @Test + fun `an accepted response that lands after pause settles completed`() { + controller.enqueue(parsed()) + host.handler = { _, _ -> + controller.pause() + UploadResponse(200, "", mapOf()) + } + run() + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + } + + @Test + fun `a transient response after cancel stops the run with only the cancel outcome`() { + controller.enqueue(parsed()) + host.handler = { _, _ -> + controller.cancel("e1") + UploadResponse(503, "", mapOf()) + } + run() + assertEquals(EventJournal.KIND_CANCELLED, outcome().kind) + assertEquals("end:false", host.progress.last()) + } + + @Test + fun `a system stop moves the entry back to queued with no outcome and no attempt event`() { + controller.enqueue(parsed()) + host.handler = { _, _ -> throw CancellationException("stopped") } + assertThrows(CancellationException::class.java) { run() } + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertNull(e.nextAttemptAt) + assertEquals(emptyList(), host.attempts) + assertEquals(emptyList(), journal.unacknowledged()) + assertEquals("end:false", host.progress.last()) + } + + @Test + fun `a timeout stop takes one more backoff step instead of restarting at once`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(backoffStreak = 2)) + host.timeout = true + host.handler = { _, _ -> throw CancellationException("timeout") } + assertThrows(CancellationException::class.java) { run() } + val e = store.load("e1")!! + assertEquals(EntryState.QUEUED, e.state) + assertEquals(3, e.backoffStreak) + assertEquals(host.clock + 4_000, e.nextAttemptAt) // base 1 s * 2^(3-1) + assertEquals(listOf("e1" to host.clock + 4_000), scheduler.wakes) + } + + @Test + fun `a store write that fails mid-run throws with no outcome`() { + controller.enqueue(parsed()) + respond(503, 200) + val dir = store.entryDir("e1") + host.onSleep = { dir.setWritable(false) } // the next attempt's write-ahead fails + try { + assertThrows(IOException::class.java) { run() } + } finally { + dir.setWritable(true) + } + assertEquals(emptyList(), journal.unacknowledged()) + assertEquals(1, host.requests.size) + } + + @Test + fun `an unexpected error settles error unknown`() { + controller.enqueue(parsed()) + host.foregroundError = IllegalStateException("boom") + run() + assertEquals("unknown", outcome().errorKind) + assertEquals("boom", outcome().message) + assertEquals(listOf("end:false"), host.progress) + } + + @Test + fun `a re-run after a lost settle write applies the record and sends nothing`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(state = EntryState.RUNNING)) + journal.append(record("00000000-0000-0000-0000-000000000041")) + run() + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + assertEquals(emptyList(), host.requests) + } + + @Test + fun `a short remaining backoff is slept out before the run, a long one is left to the wake`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(nextAttemptAt = host.clock + 5_000)) + run() + assertEquals(listOf(5_000L), host.sleeps) + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + + controller.enqueue(parsed(id = "later")) + store.save(store.load("later")!!.copy(nextAttemptAt = host.clock + 60_000)) + run("later") + assertEquals(EntryState.QUEUED, store.load("later")!!.state) + assertEquals(1, host.requests.size) + } + + @Test + fun `no usable network polls until it returns`() { + controller.enqueue(parsed()) + host.network = ArrayDeque(listOf(Connectivity.NoWifi, Connectivity.NoInternet)) + run() + assertEquals(listOf(EntryRun.CONNECTIVITY_POLL_MS, EntryRun.CONNECTIVITY_POLL_MS), host.sleeps) + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + } + + // MARK: - chunked + + private fun chunked(size: Int = 30) { + val src = File(tmp.newFolder(), "video.bin").apply { writeBytes(ByteArray(size) { it.toByte() }) } + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = src.path, + parts = listOf(part(0, 10), part(10, 20), part(20, 30))))) + } + + @Test + fun `every part accepted settles completed with no status, one attempt per part`() { + chunked() + run() + val e = store.load("e1")!! + assertEquals(EntryState.COMPLETED, e.state) + assertEquals(3, e.attempts) + assertTrue(e.descriptor!!.parts!!.all { it.accepted }) + assertNull(outcome().response!!.status) + val byUrl = host.requests.associateBy { it.url } + assertEquals("20-29", byUrl["https://example.com/part?start=20"]!!.headers["Content-Range"]) + assertEquals(10L, byUrl["https://example.com/part?start=20"]!!.body!!.contentLength()) + assertEquals(listOf(0, 1, 2), host.attempts.map { it.partIndex }.sortedBy { it }) + } + + @Test + fun `a part that fails terminally settles error with its index, and the other parts stop`() { + chunked() + host.handler = { r, _ -> + UploadResponse(if (r.url.endsWith("start=10")) 400 else 200, "", mapOf()) + } + run() + val r = outcome() + assertEquals("http", r.errorKind) + assertEquals(1, r.partIndex) + assertEquals("HTTP 400 on part 1", r.message) + assertEquals("https://example.com/part?start=10", r.url) + val accepted = store.load("e1")!!.descriptor!!.parts!!.map { it.accepted } + assertFalse(accepted[1]) + } + + @Test + fun `a part 503 backs off in that part while the others go on`() { + chunked() + var failed = false + host.handler = { r, _ -> + if (r.url.endsWith("start=0") && !failed) { + failed = true + UploadResponse(503, "", mapOf()) + } else UploadResponse(200, "", mapOf()) + } + run() + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + assertEquals(4, host.requests.size) + assertEquals(listOf(1_000L), host.sleeps) + } + + @Test + fun `a part 401 parks the whole entry and keeps the accepted parts`() { + chunked() + host.handler = { r, _ -> + UploadResponse(if (r.url.endsWith("start=20")) 401 else 200, "", mapOf()) + } + run() + val e = store.load("e1")!! + assertEquals(EntryState.AWAITING_AUTH, e.state) + assertEquals(listOf(true, true, false), e.descriptor!!.parts!!.map { it.accepted }) + } + + @Test + fun `a failed chunked settle keeps the accepted bytes, not the in-flight ones`() { + chunked() + host.handler = { r, onProgress -> + onProgress(5) + UploadResponse(if (r.url.endsWith("start=20")) 400 else 200, "", mapOf()) + } + run() + assertEquals(20, outcome().bytesSent) + } +} diff --git a/android/src/test/java/ai/openspace/backgroundupload/EntryTransitionsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EntryTransitionsTest.kt index e5a37f73..a40a156f 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/EntryTransitionsTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/EntryTransitionsTest.kt @@ -10,12 +10,35 @@ class EntryTransitionsTest { @Test fun `a settle on a cancelled entry or an older generation is not allowed`() { - assertTrue(EntryTransitions.canSettle(entry(state = EntryState.RUNNING), 1)) - assertTrue(EntryTransitions.canSettle(entry(state = EntryState.PAUSED), 1)) // an in-flight response under pause - assertFalse(EntryTransitions.canSettle(entry(state = EntryState.CANCELLED), 1)) - assertFalse(EntryTransitions.canSettle(entry(state = EntryState.COMPLETED), 1)) - assertFalse(EntryTransitions.canSettle(entry(state = EntryState.RUNNING, generation = 2), 1)) - assertFalse(EntryTransitions.canSettle(null, 1)) + for (accepted in listOf(true, false)) { + assertTrue(EntryTransitions.canSettle(entry(state = EntryState.RUNNING), 1, accepted)) + assertTrue(EntryTransitions.canSettle(entry(state = EntryState.AWAITING_AUTH), 1, accepted)) // the expiry wake + assertFalse(EntryTransitions.canSettle(entry(state = EntryState.CANCELLED), 1, accepted)) + assertFalse(EntryTransitions.canSettle(entry(state = EntryState.COMPLETED), 1, accepted)) + assertFalse(EntryTransitions.canSettle(entry(state = EntryState.RUNNING, generation = 2), 1, accepted)) + assertFalse(EntryTransitions.canSettle(null, 1, accepted)) + } + } + + @Test + fun `under pause only an accepted response settles`() { + assertTrue(EntryTransitions.canSettle(entry(state = EntryState.PAUSED), 1, accepted = true)) + assertFalse(EntryTransitions.canSettle(entry(state = EntryState.PAUSED), 1, accepted = false)) + } + + @Test + fun `a live entry with a record of its own generation settles from the newest one`() { + val live = entry(state = EntryState.RUNNING, generation = 2) + val older = record("00000000-0000-0000-0000-0000000000a1", generation = 2, at = 1) + val newest = record("00000000-0000-0000-0000-0000000000a2", kind = EventJournal.KIND_ERROR, generation = 2, at = 2) + val otherLife = record("00000000-0000-0000-0000-0000000000a3", generation = 1, at = 3) + val otherId = record("00000000-0000-0000-0000-0000000000a4", id = "x", generation = 2, at = 4) + val j = EntryTransitions.journaledSettle(live, listOf(older, newest, otherLife, otherId), now = 9)!! + assertEquals(EntryState.ERROR, j.entry.state) + assertEquals(newest.eventId, j.entry.settledEventId) + assertEquals(listOf(older.eventId), j.extraEventIds) + assertNull(EntryTransitions.journaledSettle(live, listOf(otherLife, otherId), 9)) + assertNull(EntryTransitions.journaledSettle(entry(state = EntryState.ERROR, generation = 2), listOf(newest), 9)) } @Test @@ -84,9 +107,12 @@ class EnqueueRulesTest { @Test fun `the same-id table`() { assertEquals(EnqueueRules.Action.Create, decide(null)) - val v9 = LegacyManifest("e1", "/b", listOf(part(0, 10)), emptyList(), 1, false, 1) - assertEquals(EnqueueRules.Action.AdoptV9(v9), decide(null, v9 = v9)) - assertEquals(EnqueueRules.Action.Replace, decide(entry(legacy = true, descriptor = null, body = null))) + val v9 = LegacyManifest("e1", listOf(part(0, 10)), emptyList()) + assertEquals(EnqueueRules.Action.AdoptV9(v9, 1), decide(null, v9 = v9)) + val legacy = entry(legacy = true, descriptor = null, body = null) + assertEquals(EnqueueRules.Action.Replace, decide(legacy)) + // A legacy row over a v9 manifest: adopt it, one generation up. + assertEquals(EnqueueRules.Action.AdoptV9(v9, 2), decide(legacy, v9 = v9)) assertEquals(EnqueueRules.Action.ReEmit("ev"), decide(entry(state = EntryState.COMPLETED, settledEventId = "ev"))) assertEquals(EnqueueRules.Action.Replace, decide(entry(state = EntryState.COMPLETED, settledEventId = "ev"), hasRecord = false)) EntryState.values().filter { it != EntryState.COMPLETED }.forEach { state -> @@ -128,13 +154,23 @@ class EnqueueRulesTest { } @Test - fun `resume of a settled entry reopens it with a fresh generation`() { - val next = EnqueueRules.resumed(entry(state = EntryState.ERROR, settledEventId = "ev", generation = 2), parsed(), false, 0, 9) + fun `resume of a settled entry reopens it with a fresh generation and attempts 0`() { + val settled = entry(state = EntryState.ERROR, settledEventId = "ev", generation = 2, attempts = 3) + val next = EnqueueRules.resumed(settled, parsed(), false, 0, 9) assertEquals(EntryState.QUEUED, next.state) assertEquals(3, next.generation) + assertEquals(0, next.attempts) assertNull(next.settledEventId) } + @Test + fun `resume clears a pending backoff so the entry runs now`() { + val waiting = entry(state = EntryState.QUEUED, nextAttemptAt = 99_000).copy(backoffStreak = 6) + val next = EnqueueRules.resumed(waiting, parsed(), false, 0, 9) + assertNull(next.nextAttemptAt) + assertEquals(0, next.backoffStreak) + } + @Test fun `resume of a running entry stays running, and under pause becomes paused`() { assertEquals(EntryState.RUNNING, EnqueueRules.resumed(entry(state = EntryState.RUNNING), parsed(), true, 0, 9).state) @@ -149,7 +185,7 @@ class EnqueueRulesTest { @Test fun `adopting v9 parts carries the flags only for the same parts`() { - val v9 = LegacyManifest("e1", "/b", listOf(part(0, 10, accepted = true), part(10, 20)), emptyList(), 1, false, 1) + val v9 = LegacyManifest("e1", listOf(part(0, 10, accepted = true), part(10, 20)), emptyList()) assertTrue(EnqueueRules.adoptedParts(v9, listOf(part(0, 10), part(10, 20)))[0].accepted) assertFalse(EnqueueRules.adoptedParts(v9, listOf(part(0, 20)))[0].accepted) } diff --git a/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt index b095c4c7..474c570c 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/EventJournalTest.kt @@ -3,11 +3,13 @@ package ai.openspace.backgroundupload import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.io.IOException class EventJournalTest { @get:Rule @@ -16,12 +18,66 @@ class EventJournalTest { private val id1 = "00000000-0000-0000-0000-000000000001" private val id2 = "00000000-0000-0000-0000-000000000002" + private val listener = Any() + @Test fun `append then read returns the record`() { val journal = EventJournal(tmp.newFolder()) - assertTrue(journal.append(record(id1))) - val events = journal.unacknowledged() - assertEquals(listOf(record(id1)), events) + journal.drain(listener) + assertEquals(record(id1), journal.append(record(id1))) + assertEquals(listOf(record(id1)), journal.unacknowledged()) + } + + @Test + fun `append starts at 1 delivery with a listener and at 0 without one`() { + val journal = EventJournal(tmp.newFolder()) + assertFalse(journal.isListening()) + assertEquals(0, journal.append(record(id1)).deliveries) + assertEquals(listOf(1), journal.drain(listener).map { it.deliveries }) // the drain delivers it + assertTrue(journal.isListening()) + assertEquals(1, journal.append(record(id2)).deliveries) + } + + @Test + fun `a drain for a torn-down module sets no listener and counts nothing`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(record(id1)) + assertEquals(emptyList(), journal.drain(listener) { false }) + assertFalse(journal.isListening()) + assertEquals(0, journal.find(id1)!!.deliveries) + } + + @Test + fun `listener is the owner of the last drain`() { + val journal = EventJournal(tmp.newFolder()) + assertNull(journal.listener()) + journal.drain(listener) + assertTrue(journal.listener() === listener) + val next = Any() + journal.drain(next) + assertTrue(journal.listener() === next) + } + + @Test + fun `stopListening clears only its own listener`() { + val journal = EventJournal(tmp.newFolder()) + val next = Any() + journal.drain(listener) + journal.drain(next) // a reload: the next module drains before the old one is torn down + journal.stopListening(listener) + assertTrue(journal.isListening()) + journal.stopListening(next) + assertFalse(journal.isListening()) + } + + @Test + fun `redeliver counts a delivery only with a listener`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(record(id1)) + assertNull(journal.redeliver(id1)) + assertEquals(0, journal.find(id1)!!.deliveries) + journal.drain(listener) // 1 + assertEquals(2, journal.redeliver(id1)!!.deliveries) } @Test @@ -49,13 +105,23 @@ class EventJournalTest { } @Test - fun `a body over 1 MB is cut and flagged`() { + fun `a body over 1 MB of UTF-8 is cut on a character and flagged`() { val journal = EventJournal(tmp.newFolder()) - val big = "x".repeat(EventJournal.MAX_BODY_CHARS + 100) + // 2-byte characters, so a char cap would keep 2 MB. + val big = "\u00e9".repeat(BodyCap.SETTLED_MAX_BYTES) journal.append(record(id1).copy(response = EventJournal.Response(200, null, big, false))) val read = journal.unacknowledged()[0].response!! assertTrue(read.bodyTruncated) - assertEquals(EventJournal.MAX_BODY_CHARS, read.body!!.length) + assertEquals(BodyCap.SETTLED_MAX_BYTES, read.body!!.toByteArray(Charsets.UTF_8).size) + assertEquals(BodyCap.SETTLED_MAX_BYTES / 2, read.body!!.length) + } + + @Test + fun `a response the stream cap cut stays flagged`() { + val r = EventJournal.Response.of(UploadResponse(500, "partial", mapOf(), truncated = true)) + assertEquals("partial", r.body) + assertTrue(r.bodyTruncated) + assertFalse(EventJournal.Response.of(UploadResponse(500, "whole", mapOf())).bodyTruncated) } @Test @@ -77,12 +143,63 @@ class EventJournalTest { } @Test - fun `append never throws, and says whether it wrote`() { + fun `append throws when it could not write and keeps nothing`() { val journal = EventJournal(tmp.newFile()) // a file where the directory should be - assertFalse(journal.append(record(id1))) + assertThrows(IOException::class.java) { journal.append(record(id1)) } assertEquals(emptyList(), journal.unacknowledged()) } + @Test + fun `appendOrHold holds a record it could not write, and every read and ack sees it`() { + val tasks = mutableListOf Unit>>() + val dir = tmp.newFolder() + val journal = EventJournal(dir, retryLater = { delay, task -> tasks += delay to task }) + dir.setWritable(false) + try { + val held = journal.appendOrHold(record(id1)) + assertTrue(journal.isHeld(id1)) + assertEquals(listOf(held), journal.unacknowledged()) + assertEquals(held, journal.find(id1)) + assertEquals(listOf(id1), journal.forEntry("e1").map { it.eventId }) + assertEquals(1, journal.drain(listener).single().deliveries) + // The retry fails while the disk is full, and waits twice as long. + tasks.removeAt(0).also { (delay, task) -> assertEquals(EventJournal.RETRY_MS, delay); task() } + assertEquals(EventJournal.RETRY_MS * 2, tasks.single().first) + } finally { + dir.setWritable(true) + } + tasks.removeAt(0).second() + assertFalse(journal.isHeld(id1)) + assertTrue(File(dir, "$id1.json").exists()) + assertEquals(1, EventJournal(dir).find(id1)!!.deliveries) + assertEquals(emptyList Unit>>(), tasks) + } + + @Test + fun `an ack removes a held record`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir, retryLater = { _, _ -> }) + dir.setWritable(false) + try { + journal.appendOrHold(record(id1)) + } finally { + dir.setWritable(true) + } + journal.ack(listOf(id1)) + assertFalse(journal.isHeld(id1)) + assertEquals(emptyList(), journal.unacknowledged()) + } + + @Test + fun `ackEntry removes every record of one entry`() { + val journal = EventJournal(tmp.newFolder()) + journal.append(record(id1, id = "a")) + journal.append(record(id2, id = "a", generation = 2)) + journal.append(record("00000000-0000-0000-0000-000000000003", id = "b")) + journal.ackEntry("a") + assertEquals(listOf("b"), journal.unacknowledged().map { it.id }) + } + @Test fun `prunes the oldest records beyond the cap`() { val dir = tmp.newFolder() @@ -98,10 +215,25 @@ class EventJournalTest { assertFalse(left.contains(ids[0])) } + @Test + fun `the prune never deletes a record a row names`() { + val dir = tmp.newFolder() + val journal = EventJournal(dir, maxEntries = 3) + val ids = (1..4).map { "00000000-0000-0000-0000-00000000000$it" } + ids.take(3).forEachIndexed { i, id -> + journal.append(record(id)) + File(dir, "$id.json").setLastModified(1_000L * (i + 1)) + } + // The oldest is named by a row; the next oldest goes instead. + journal.append(record(ids[3])) { setOf(ids[0]) } + assertEquals(setOf(ids[0], ids[2], ids[3]), journal.unacknowledged().map { it.eventId }.toSet()) + } + @Test fun `incrementDeliveries persists`() { val dir = tmp.newFolder() val journal = EventJournal(dir) + journal.drain(listener) journal.append(record(id1)) assertEquals(2, journal.incrementDeliveries(id1)!!.deliveries) assertEquals(3, journal.incrementDeliveries(id1)!!.deliveries) diff --git a/android/src/test/java/ai/openspace/backgroundupload/JsonBridgeTest.kt b/android/src/test/java/ai/openspace/backgroundupload/JsonBridgeTest.kt index bb420821..928540e7 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/JsonBridgeTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/JsonBridgeTest.kt @@ -11,36 +11,25 @@ class JsonBridgeTest { @Test fun `integral doubles print as integers, as JSON stringify does`() { - assertEquals("""{"n":1,"neg":-3,"zero":0}""", JsonBridge.toJson(mapOf("n" to 1.0, "neg" to -3.0, "zero" to -0.0))) - assertEquals("12345678901", JsonBridge.toJson(12_345_678_901.0)) + assertEquals("1", JsonBridge.numberText(1.0)) + assertEquals("-3", JsonBridge.numberText(-3.0)) + assertEquals("0", JsonBridge.numberText(-0.0)) + assertEquals("12345678901", JsonBridge.numberText(12_345_678_901.0)) } @Test fun `fractions and very large magnitudes keep a decimal form`() { - assertEquals("1.5", JsonBridge.toJson(1.5)) - assertEquals("0.1", JsonBridge.toJson(0.1)) + assertEquals("1.5", JsonBridge.numberText(1.5)) + assertEquals("0.1", JsonBridge.numberText(0.1)) // Above 2^53 a double can not hold every integer, so it stays a double. - assertEquals(1e20, (JsonBridge.parse(JsonBridge.toJson(1e20)) as Double), 0.0) + assertEquals(1e20, (JsonBridge.parse(JsonBridge.numberText(1e20)) as Double), 0.0) } @Test - fun `nested maps and lists round trip`() { + fun `JSON text parses to plain values`() { val value = mapOf("a" to listOf(1.0, "x", true, null, mapOf("b" to 2.5)), "c" to mapOf()) - val text = JsonBridge.toJson(value) - assertEquals("""{"a":[1,"x",true,null,{"b":2.5}],"c":{}}""", text) - assertEquals(value, JsonBridge.parse(text)) - } - - @Test - fun `keys are sorted so the same object always gives the same text`() { - assertEquals(JsonBridge.toJson(mapOf("b" to 1.0, "a" to 2.0)), JsonBridge.toJson(mapOf("a" to 2.0, "b" to 1.0))) - } - - @Test - fun `null is the text null, and HTML characters are not escaped`() { - assertEquals("null", JsonBridge.toJson(null)) + assertEquals(value, JsonBridge.parse("""{"a":[1,"x",true,null,{"b":2.5}],"c":{}}""")) assertNull(JsonBridge.parse("null")) - assertEquals("\"\"", JsonBridge.toJson("")) } @Test @@ -78,4 +67,18 @@ class JsonBridgeTest { assertEquals(true, out.getMap("nested")!!.getBoolean("k")) assertEquals(true, out.isNull("none")) } + + @Test + fun `isJson accepts one strict JSON value of any kind`() { + listOf("null", "1", "-0.5e3", "\"s\"", "true", "[]", "{}", """{"a":[1,null,{"b":"c"}]}""", " {\"a\":1} ").forEach { + assertEquals(it, true, JsonBridge.isJson(it)) + } + } + + @Test + fun `isJson rejects lenient and malformed text`() { + listOf("", " ", "{a:1}", "{'a':1}", "[1,]", "{\"a\":1} x", "undefined", "NaN", "{\"a\":1}{}").forEach { + assertEquals(it, false, JsonBridge.isJson(it)) + } + } } diff --git a/android/src/test/java/ai/openspace/backgroundupload/LegacyImportTest.kt b/android/src/test/java/ai/openspace/backgroundupload/LegacyImportTest.kt index 9f7be8ef..1386c2cb 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/LegacyImportTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/LegacyImportTest.kt @@ -1,6 +1,7 @@ package ai.openspace.backgroundupload import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule @@ -74,6 +75,37 @@ class LegacyImportTest { assertTrue(File(v9Dir, "a.json").exists()) } + @Test + fun `runOnce imports, then writes the marker, and a second launch does nothing`() { + val v9Dir = tmp.newFolder() + val marker = File(tmp.newFolder(), LegacyImport.MARKER) + val store = QueueStore(tmp.newFolder(), RequestIndex()) + v9(v9Dir, "a", "up-1", "completed", 100) + assertTrue(LegacyImport.runOnce(marker, v9Dir, store)) + assertTrue(marker.exists()) + assertEquals(listOf("up-1"), store.all().map { it.id }) + // A v9 file that shows up later is not imported: the marker says done. + v9(v9Dir, "b", "up-2", "error", 200) + store.remove("up-1") + assertFalse(LegacyImport.runOnce(marker, v9Dir, store)) + assertEquals(emptyList(), store.all()) + assertTrue(File(v9Dir, "b.json").exists()) + } + + @Test + fun `runOnce with a failed row save writes no marker, so the next launch tries again`() { + val v9Dir = tmp.newFolder() + val marker = File(tmp.newFolder(), LegacyImport.MARKER) + v9(v9Dir, "a", "up-1", "error", 100) + val broken = QueueStore(tmp.newFile(), RequestIndex()) // a file where the directory should be + assertTrue(LegacyImport.runOnce(marker, v9Dir, broken)) // it ran, so v9 work is cancelled + assertFalse(marker.exists()) + val store = QueueStore(tmp.newFolder(), RequestIndex()) + assertTrue(LegacyImport.runOnce(marker, v9Dir, store)) + assertTrue(marker.exists()) + assertEquals(listOf("up-1"), store.all().map { it.id }) + } + @Test fun `an unknown type makes no row`() { assertNull(LegacyImport.legacyRow(LegacyImport.V9Entry("a", "up", "progress", 1))) diff --git a/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt b/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt index 92663412..6cfc408f 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt @@ -26,14 +26,16 @@ class QueueControllerTest { private val running = mutableSetOf() private var now = 10_000L private lateinit var controller: QueueController + private val listener = Any() @Before fun setUp() { root = tmp.newFolder("queue") store = QueueStore(root, RequestIndex()) - journal = EventJournal(tmp.newFolder("journal")) + journal = EventJournal(tmp.newFolder("journal"), retryLater = { _, _ -> }) settings = QueueSettingsStore(File(root, "settings.json")) controller = QueueController(store, journal, settings, events, scheduler, { it in running }, { now }) + journal.drain(listener) // JS is subscribed } private fun source(name: String, size: Int) = File(tmp.newFolder(), name).apply { writeBytes(ByteArray(size) { it.toByte() }) } @@ -165,14 +167,50 @@ class QueueControllerTest { } @Test - fun `same body on an error entry reopens it and keeps attempts`() { + fun `same body on an error entry reopens it with attempts 0`() { controller.enqueue(parsed()) store.save(store.load("e1")!!.copy(state = EntryState.ERROR, attempts = 3, settledEventId = "ev")) controller.enqueue(parsed(expiresAt = FAR_FUTURE + 1)) val e = store.load("e1")!! assertEquals(EntryState.QUEUED, e.state) assertEquals(2, e.generation) - assertEquals(3, e.attempts) + assertEquals(0, e.attempts) // attempts count the current generation + } + + @Test + fun `same body on a live entry keeps its attempts`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(attempts = 3)) + controller.enqueue(parsed(expiresAt = FAR_FUTURE + 1)) + assertEquals(3, store.load("e1")!!.attempts) + } + + @Test + fun `a same-id resume of a queued entry waiting out a backoff clears it and runs now`() { + controller.enqueue(parsed()) + store.save(store.load("e1")!!.copy(nextAttemptAt = now + 3_600_000, backoffStreak = 9)) + scheduler.scheduled.clear() + controller.enqueue(parsed()) + val e = store.load("e1")!! + assertNull(e.nextAttemptAt) + assertEquals(0, e.backoffStreak) + assertEquals(listOf("e1"), scheduler.scheduled) + assertEquals(emptyList>(), scheduler.wakes) + assertFalse(events.rows.last().toMap().containsKey("nextAttemptAt")) + } + + @Test + fun `a completed unacked re-emit with no listener yet waits for the drain`() { + controller.enqueue(parsed()) + val eventId = "00000000-0000-0000-0000-00000000001a" + journal.append(record(eventId)) + store.save(store.load("e1")!!.copy(state = EntryState.COMPLETED, settledEventId = eventId)) + journal.stopListening(listener) + events.log.clear() + controller.enqueue(parsed()) + assertEquals(emptyList(), events.log) + assertEquals(1, journal.find(eventId)!!.deliveries) + assertEquals(2, controller.unacknowledged(listener).single().deliveries) } @Test @@ -185,6 +223,34 @@ class QueueControllerTest { assertEquals(EntryState.QUEUED, e.state) } + // MARK: - legacy row over a v9 manifest + + @Test + fun `a same-id enqueue over a legacy row with a v9 manifest adopts it one generation up`() { + v9Dir("e1", v9Parts, 20) + store.save(LegacyImport.legacyRow(LegacyImport.V9Entry("x", "e1", "error", 5))!!) + val legacy = store.load("e1")!! + assertEquals(0L to 0L, legacy.bytesSent to legacy.totalBytes) // legacy rows report 0/0 bytes + controller.enqueue(parsed(descriptor = desc(url = null, method = "PUT", file = "/gone.bin", parts = listOf(part(0, 10), part(10, 20))))) + val e = store.load("e1")!! + assertFalse(e.legacy) + assertEquals(2, e.generation) + assertEquals(5, e.createdAt) + assertTrue(e.descriptor!!.parts!![0].accepted) // the v9 progress is kept + assertFalse(e.descriptor!!.parts!![1].accepted) + assertEquals(10, e.bytesSent) + assertEquals(setOf("entry.json", "blob"), dirFiles()) // the manifest is pruned + } + + @Test + fun `a same-id enqueue over a legacy row with no manifest replaces it`() { + store.save(LegacyImport.legacyRow(LegacyImport.V9Entry("x", "e1", "error", 5))!!) + controller.enqueue(parsed()) + val e = store.load("e1")!! + assertEquals(2, e.generation) + assertEquals(0, e.bytesSent) + } + // MARK: - chunked replace (a present file wins over the old blob) private fun chunkedErrorEntry(): File { @@ -360,23 +426,99 @@ class QueueControllerTest { assertEquals(record.eventId, e.settledEventId) assertEquals(listOf("e1"), scheduler.cancelled) assertEquals(listOf("settled:e1:cancelled", "state:e1:cancelled"), events.log) + assertTrue(events.listeners.single() === listener) controller.ack(listOf(record.eventId)) assertNull(store.load("e1")) assertFalse(store.entryDir("e1").exists()) } @Test - fun `cancel of a settled entry forgets it now and keeps its unacked record`() { + fun `cancel of a settled entry forgets it now with its unacked outcomes`() { controller.enqueue(parsed()) val eventId = "00000000-0000-0000-0000-00000000000b" + val older = "00000000-0000-0000-0000-00000000001b" + journal.append(record(older, generation = 0)) journal.append(record(eventId, kind = EventJournal.KIND_ERROR)) + journal.append(record("00000000-0000-0000-0000-00000000002b", id = "other")) store.save(store.load("e1")!!.copy(state = EntryState.ERROR, settledEventId = eventId)) events.log.clear() controller.cancel("e1") assertNull(store.load("e1")) assertFalse(store.entryDir("e1").exists()) assertEquals(emptyList(), events.log) - assertNotNull(journal.find(eventId)) + assertEquals(listOf("other"), journal.unacknowledged().map { it.id }) + } + + @Test + fun `cancel of a live entry whose journal can not write rejects E_STORAGE and changes nothing`() { + controller.enqueue(parsed()) + val before = store.load("e1")!! + events.log.clear() + scheduler.cancelled.clear() + val dir = tmp.root.resolve("journal") + dir.setWritable(false) + val e = try { + assertThrows(QueueException::class.java) { controller.cancel("e1") } + } finally { + dir.setWritable(true) + } + assertEquals(QueueException.E_STORAGE, e.code) + assertEquals(before, store.load("e1")) + assertEquals(emptyList(), journal.unacknowledged()) + assertEquals(emptyList(), events.log) + assertEquals(emptyList(), scheduler.cancelled) + } + + @Test + fun `cancel whose entry save fails stops the work, emits, rejects, and a retry adds no second outcome`() { + controller.enqueue(parsed()) + events.log.clear() + scheduler.cancelled.clear() + val dir = store.entryDir("e1") + dir.setWritable(false) + val error = try { + assertThrows(QueueException::class.java) { controller.cancel("e1") } + } finally { + dir.setWritable(true) + } + assertEquals(QueueException.E_STORAGE, error.code) + val record = journal.unacknowledged().single() + assertEquals(EventJournal.KIND_CANCELLED, record.kind) + assertEquals(EntryState.QUEUED, store.load("e1")!!.state) // the save was lost + assertEquals(listOf("e1"), scheduler.cancelled) // a running request can not settle again + assertEquals(listOf("settled:e1:cancelled"), events.log) + // JS calls cancel() again: the journaled cancel is applied, not a second one. + events.log.clear() + controller.cancel("e1") + val e = store.load("e1")!! + assertEquals(EntryState.CANCELLED, e.state) + assertEquals(record.eventId, e.settledEventId) + assertEquals(listOf(record.eventId), journal.unacknowledged().map { it.eventId }) + assertEquals(listOf("state:e1:cancelled"), events.log) + } + + @Test + fun `cancel over the journal cap keeps its own record`() { + val small = EventJournal(tmp.newFolder("small"), maxEntries = 1) + val smallController = QueueController(store, small, settings, events, scheduler, { false }, { now }) + val named = "00000000-0000-0000-0000-000000000042" + small.append(record(named, id = "done")) + store.save(entry(id = "done", state = EntryState.ERROR, settledEventId = named)) + smallController.enqueue(parsed()) + smallController.cancel("e1") + val own = store.load("e1")!!.settledEventId!! + assertNotNull(small.find(own)) // without it, the sweep forgets the row with no outcome + assertNotNull(small.find(named)) + } + + @Test + fun `cancel with no listener journals at 0 and does not emit the outcome`() { + controller.enqueue(parsed()) + journal.stopListening(listener) + events.log.clear() + controller.cancel("e1") + assertEquals(listOf("state:e1:cancelled"), events.log) + assertEquals(0, journal.unacknowledged().single().deliveries) } @Test @@ -525,8 +667,8 @@ class QueueControllerTest { @Test fun `each replay counts one more delivery`() { journal.append(record("00000000-0000-0000-0000-00000000000f")) - assertEquals(2, controller.unacknowledged().single().deliveries) - assertEquals(3, controller.unacknowledged().single().deliveries) + assertEquals(2, controller.unacknowledged(listener).single().deliveries) + assertEquals(3, controller.unacknowledged(listener).single().deliveries) } // MARK: - boot sweep @@ -588,6 +730,24 @@ class QueueControllerTest { assertNotNull(store.load("c")) } + @Test + fun `sweep keeps a completed entry whose record is held in memory`() { + val eventId = "00000000-0000-0000-0000-000000000015" + val dir = tmp.root.resolve("journal") + dir.setWritable(false) + try { + journal.appendOrHold(record(eventId)) + } finally { + dir.setWritable(true) + } + store.save(entry(state = EntryState.COMPLETED, settledEventId = eventId)) + controller.sweep() + assertNotNull(store.load("e1")) + // Its ack still forgets it. + controller.ack(listOf(eventId)) + assertNull(store.load("e1")) + } + @Test fun `sweep leaves paused entries alone`() { controller.pause() diff --git a/android/src/test/java/ai/openspace/backgroundupload/QueueStoreTest.kt b/android/src/test/java/ai/openspace/backgroundupload/QueueStoreTest.kt index a4ae7232..98d107ba 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/QueueStoreTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/QueueStoreTest.kt @@ -103,14 +103,15 @@ class QueueStoreTest { fun `compute holds the lock across load, transform, and save`() { // A module transition and a worker's update race. If the lock did not // span all three steps, the update could land between load and save and - // be erased. Serialized, both effects survive. + // be erased. The update must block while the transform runs. val s = store() s.save(entry(descriptor = desc(url = null, file = "/f", parts = listOf(part(0, 10), part(10, 20))))) val inTransform = CountDownLatch(1) + val finish = CountDownLatch(1) val computing = Thread { s.compute("e1") { e -> inTransform.countDown() - Thread.sleep(300) + finish.await(5, TimeUnit.SECONDS) e!!.copy(expiresAt = 99_000) } }.apply { start() } @@ -118,6 +119,13 @@ class QueueStoreTest { val updating = Thread { s.update("e1") { e -> e.copy(descriptor = e.descriptor!!.copy(parts = ChunkedParts.withAccepted(e.descriptor.parts!!, 0))) } }.apply { start() } + // Without the lock the update finishes (TERMINATED) while the transform waits. + val deadline = System.currentTimeMillis() + 5_000 + while (updating.state != Thread.State.BLOCKED && updating.state != Thread.State.TERMINATED && + System.currentTimeMillis() < deadline + ) Thread.sleep(5) + assertEquals(Thread.State.BLOCKED, updating.state) + finish.countDown() computing.join() updating.join() val final = s.load("e1")!! @@ -155,7 +163,7 @@ class QueueStoreTest { s.remove("e1") assertNull(s.load("e1")) assertFalse(s.entryDir("e1").exists()) - assertNull(index.get("e1")) + assertEquals(emptyList(), index.snapshot()) } @Test @@ -171,7 +179,7 @@ class QueueStoreTest { // A process relaunch: a fresh index loaded from disk. val fresh = RequestIndex() QueueStore(dir, fresh).loadIndex() - assertEquals("error", fresh.get("b")!!.state) + assertEquals(listOf("error"), fresh.snapshot().map { it.state }) } @Test diff --git a/android/src/test/java/ai/openspace/backgroundupload/RetryClassifierTest.kt b/android/src/test/java/ai/openspace/backgroundupload/RetryClassifierTest.kt index cb21bf72..189bf8be 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/RetryClassifierTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/RetryClassifierTest.kt @@ -50,7 +50,13 @@ class RetryClassifierTest { assertTrue(file is Verdict.Terminal && file.errorKind == "file") val other = RetryClassifier.classifyFailure(IllegalArgumentException("bad url"), fileExists = true) assertEquals(Verdict.Terminal("unknown", "bad url"), other) - assertEquals("network", RetryClassifier.failureKind(IOException(), true)) + } + + @Test + fun `the attempt errorKind of a failure comes from its verdict`() { + assertEquals("network", RetryClassifier.failureKind(RetryClassifier.classifyFailure(IOException(), true))) + assertEquals("file", RetryClassifier.failureKind(RetryClassifier.classifyFailure(IOException(), false))) + assertEquals("unknown", RetryClassifier.failureKind(RetryClassifier.classifyFailure(RuntimeException("boom"), true))) } private val policy = RetryClassifier.Policy(baseMs = 1_000, maxMs = 7_200_000, jitter = 0.0, exempt = defaultExempt) diff --git a/android/src/test/java/ai/openspace/backgroundupload/SmallPartsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/SmallPartsTest.kt index f133bc9e..a5bfda74 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/SmallPartsTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/SmallPartsTest.kt @@ -6,11 +6,7 @@ import androidx.work.WorkInfo.State.ENQUEUED import androidx.work.WorkInfo.State.FAILED import androidx.work.WorkInfo.State.RUNNING import androidx.work.WorkInfo.State.SUCCEEDED -import kotlinx.coroutines.async -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withTimeoutOrNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -21,11 +17,21 @@ class AttemptEventTest { private val response = UploadResponse(401, "x".repeat(5_000), mapOf("a" to "b")) @Test - fun `the body is cut at 4 KB and flagged`() { + fun `the body is cut at 4 KB of UTF-8 and flagged`() { val e = AttemptEvent.ofResponse(entry(attempts = 2), "r1", "https://x", null, response, accepted = false, at = 7) - assertEquals(AttemptEvent.MAX_BODY_CHARS, e.responseBody!!.length) + assertEquals(BodyCap.ATTEMPT_MAX_BYTES, e.responseBody!!.length) assertEquals(true, e.responseBodyTruncated) assertEquals(2, e.attempt) + // 3-byte characters: the cut backs off to a whole character. + val wide = AttemptEvent.ofResponse(entry(), "r1", "https://x", null, response.copy(body = "\u20ac".repeat(2_000)), false, 7) + assertEquals(BodyCap.ATTEMPT_MAX_BYTES / 3, wide.responseBody!!.length) + } + + @Test + fun `a response the stream cap cut is flagged even under 4 KB`() { + val e = AttemptEvent.ofResponse(entry(), "r1", "https://x", null, response.copy(body = "short", truncated = true), false, 7) + assertEquals("short", e.responseBody) + assertEquals(true, e.responseBodyTruncated) } @Test @@ -114,7 +120,7 @@ class RequestIndexTest { fun `setBytes on a missing id is a no-op`() { val index = RequestIndex() index.setBytes("nope", 5) - assertNull(index.get("nope")) + assertEquals(emptyList(), index.snapshot()) } @Test @@ -124,9 +130,9 @@ class RequestIndexTest { index.put(running.toRow()) index.setBytes("e1", 60) index.put(running.copy(attempts = 2).toRow()) - assertEquals(60, index.get("e1")!!.bytesSent) + assertEquals(60, index.snapshot().single().bytesSent) index.put(running.copy(state = EntryState.QUEUED).toRow()) - assertEquals(0, index.get("e1")!!.bytesSent) + assertEquals(0, index.snapshot().single().bytesSent) } } @@ -157,11 +163,14 @@ class TransferSemaphoreTest { @Test fun `the global cap is 4 and a fifth request waits`() = runBlocking { assertEquals(4, MAX_TRANSFER_CONCURRENCY) - val holders = (1..4).map { async { transferSemaphore.withPermit { delay(200) } } } - delay(20) - val fifth = withTimeoutOrNull(50) { transferSemaphore.withPermit { } } - assertNull(fifth) - holders.forEach { it.await() } - assertEquals(Unit, withTimeoutOrNull(500) { transferSemaphore.withPermit { } }) + repeat(4) { transferSemaphore.acquire() } + try { + assertFalse(transferSemaphore.tryAcquire()) // no fifth permit + transferSemaphore.release() + assertTrue(transferSemaphore.tryAcquire()) // one freed, one taken + } finally { + repeat(4) { transferSemaphore.release() } + } + assertEquals(4, transferSemaphore.availablePermits) } } diff --git a/android/src/test/java/ai/openspace/backgroundupload/TestSupport.kt b/android/src/test/java/ai/openspace/backgroundupload/TestSupport.kt index f8e405f5..1effda61 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/TestSupport.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/TestSupport.kt @@ -86,22 +86,24 @@ internal fun record( ) /** Records every event in order, as "state::" and "settled::". */ -internal class RecordingEvents(var live: Boolean = true) : QueueEvents { +internal class RecordingEvents : QueueEvents { val log = mutableListOf() val rows = mutableListOf() val records = mutableListOf() + /** The listener each settled event went to, in order. */ + val listeners = mutableListOf() + override fun state(row: RequestRow) { rows += row log += "state:${row.id}:${row.state}" } - override fun settled(record: EventJournal.SettledRecord) { + override fun settled(record: EventJournal.SettledRecord, listener: Any) { records += record + listeners += listener log += "settled:${record.id}:${record.kind}" } - - override fun canDeliver() = live } internal class FakeScheduler : WorkScheduler { diff --git a/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt b/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt index 187349ed..9976f240 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/UploadOutcomeTest.kt @@ -5,7 +5,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import java.io.IOException class UploadOutcomeTest { @@ -57,19 +56,4 @@ class UploadOutcomeTest { assertTrue(UploadOutcome.isAccepted(409, "already completed", rules)) assertFalse(UploadOutcome.isAccepted(410, "already completed", rules)) } - - @Test - fun `IOException with a missing file is a file error`() { - assertEquals("file", UploadOutcome.errorKind(IOException("gone"), fileExists = false)) - } - - @Test - fun `IOException with the file present is a network error`() { - assertEquals("network", UploadOutcome.errorKind(IOException("reset"), fileExists = true)) - } - - @Test - fun `a non-IO error is unknown`() { - assertEquals("unknown", UploadOutcome.errorKind(RuntimeException("boom"), fileExists = true)) - } } diff --git a/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt index 0e7e1df9..5c28fff6 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt @@ -2,6 +2,7 @@ package ai.openspace.backgroundupload import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue @@ -10,6 +11,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import java.io.File +import java.util.concurrent.CyclicBarrier class WorkerOpsTest { @get:Rule @@ -23,15 +25,28 @@ class WorkerOpsTest { private var now = 20_000L private lateinit var ops: WorkerOps private lateinit var controller: QueueController + private val listener = Any() + private lateinit var journalDir: File @Before fun setUp() { val root = tmp.newFolder("queue") store = QueueStore(root, RequestIndex()) - journal = EventJournal(tmp.newFolder("journal")) + journalDir = tmp.newFolder("journal") + journal = EventJournal(journalDir, retryLater = { _, _ -> }) settings = QueueSettingsStore(File(root, "settings.json")) ops = WorkerOps(store, journal, settings, events, scheduler) { now } controller = QueueController(store, journal, settings, events, scheduler, { false }, { now }) + journal.drain(listener) // JS is subscribed + } + + private fun withJournalReadOnly(block: () -> T): T { + journalDir.setWritable(false) + try { + return block() + } finally { + journalDir.setWritable(true) + } } private val ok = UploadResponse(200, """{"id":7}""", mapOf("x" to "y")) @@ -45,6 +60,30 @@ class WorkerOpsTest { assertEquals(listOf("state:e1:running"), events.log) } + @Test + fun `begin applies a record of the entry's own generation and does not run it again`() { + // The process died between the journal write and the store transition, + // and WorkManager runs the entry again before any boot sweep. + val own = "00000000-0000-0000-0000-000000000021" + val older = "00000000-0000-0000-0000-000000000022" + store.save(entry(state = EntryState.RUNNING, generation = 2)) + journal.append(record(older, generation = 2, at = 1)) + journal.append(record(own, generation = 2, at = 2)) + assertNull(ops.begin("e1")) + val e = store.load("e1")!! + assertEquals(EntryState.COMPLETED, e.state) + assertEquals(own, e.settledEventId) + assertEquals(listOf(own), journal.unacknowledged().map { it.eventId }) // the extra one is acked + assertEquals(listOf("state:e1:completed"), events.log) + } + + @Test + fun `begin runs an entry whose only record is of an older generation`() { + store.save(entry(state = EntryState.QUEUED, generation = 2)) + journal.append(record("00000000-0000-0000-0000-000000000023", generation = 1)) + assertEquals(EntryState.RUNNING, ops.begin("e1")!!.state) + } + @Test fun `begin does nothing for a paused, settled, or legacy entry`() { store.save(entry(id = "p", state = EntryState.PAUSED)) @@ -90,13 +129,94 @@ class WorkerOpsTest { } @Test - fun `a settle with JS dead starts at 0 deliveries, so the first replay is 1`() { - events.live = false + fun `a settle with no listener starts at 0 deliveries, so the first replay is 1`() { + journal.stopListening(listener) store.save(entry(state = EntryState.RUNNING)) assertTrue(ops.settle("e1", 1, Settlement.Completed(ok, "u", "POST"))) assertEquals(0, journal.unacknowledged().single().deliveries) assertEquals(listOf("state:e1:completed"), events.log) // nothing to emit to - assertEquals(1, controller.unacknowledged().single().deliveries) + assertEquals(1, controller.unacknowledged(listener).single().deliveries) + } + + @Test + fun `a settle whose journal can not write holds the record, settles, and emits`() { + store.save(entry(state = EntryState.RUNNING)) + assertTrue(withJournalReadOnly { ops.settle("e1", 1, Settlement.Completed(ok, "u", "POST")) }) + val e = store.load("e1")!! + assertEquals(EntryState.COMPLETED, e.state) + assertTrue(journal.isHeld(e.settledEventId!!)) + assertEquals(listOf("settled:e1:completed", "state:e1:completed"), events.log) + // The sweep does not forget a row whose record is held, and the ack does. + controller.sweep() + assertNotNull(store.load("e1")) + controller.ack(listOf(e.settledEventId!!)) + assertNull(store.load("e1")) + } + + @Test + fun `a settle whose store write fails leaves the record for the next run to apply`() { + store.save(entry(state = EntryState.RUNNING)) + val dir = store.entryDir("e1") + dir.setWritable(false) + val stood = try { + ops.settle("e1", 1, Settlement.Completed(ok, "u", "POST")) + } finally { + dir.setWritable(true) + } + assertTrue(stood) + assertEquals(EntryState.RUNNING, store.load("e1")!!.state) + val record = journal.unacknowledged().single() + assertEquals(listOf("settled:e1:completed"), events.log) // no state event: the row did not change + // WorkManager runs it again: begin applies the record, no second send. + assertNull(ops.begin("e1")) + assertEquals(record.eventId, store.load("e1")!!.settledEventId) + } + + @Test + fun `a settle after a cancel whose save failed applies the cancel, not a second outcome`() { + // cancel() journaled its record, then its entry save failed: the entry + // is still running, and its request comes back. + val cancelId = "00000000-0000-0000-0000-000000000041" + store.save(entry(state = EntryState.RUNNING)) + journal.append(record(cancelId, kind = EventJournal.KIND_CANCELLED)) + assertFalse(ops.settle("e1", 1, Settlement.Completed(ok, "u", "POST"))) + val e = store.load("e1")!! + assertEquals(EntryState.CANCELLED, e.state) + assertEquals(cancelId, e.settledEventId) + assertEquals(listOf(cancelId), journal.unacknowledged().map { it.eventId }) + assertEquals(listOf("state:e1:cancelled"), events.log) // no second outcome + } + + @Test + fun `a live settle goes to the listener that drained, not the newest module`() { + store.save(entry(id = "a", state = EntryState.RUNNING)) + ops.settle("a", 1, Settlement.Completed(ok, "u", "POST")) + // A reload: the next module's JS drains and takes over. + val next = Any() + controller.unacknowledged(next) + store.save(entry(id = "b", state = EntryState.RUNNING)) + ops.settle("b", 1, Settlement.Completed(ok, "u", "POST")) + assertEquals(2, events.listeners.size) + assertTrue(events.listeners[0] === listener) + assertTrue(events.listeners[1] === next) + } + + @Test + fun `a settle over the journal cap spares every record a row names`() { + val small = EventJournal(tmp.newFolder("small"), maxEntries = 2) + val smallOps = WorkerOps(store, small, settings, events, scheduler) { now } + val named = "00000000-0000-0000-0000-000000000031" + small.append(record(named, id = "done")) + store.save(entry(id = "done", state = EntryState.ERROR, settledEventId = named)) + File(tmp.root, "small/$named.json").setLastModified(1_000) + small.append(record("00000000-0000-0000-0000-000000000032", id = "orphan")) + File(tmp.root, "small/00000000-0000-0000-0000-000000000032.json").setLastModified(2_000) + store.save(entry(state = EntryState.RUNNING)) + smallOps.settle("e1", 1, Settlement.Completed(ok, "u", "POST")) + val left = small.unacknowledged().map { it.eventId } + assertTrue(left.contains(named)) + assertTrue(left.contains(store.load("e1")!!.settledEventId)) + assertEquals(2, left.size) } @Test @@ -120,12 +240,30 @@ class WorkerOpsTest { } @Test - fun `a response that lands during pause still settles`() { + fun `an accepted response that lands during pause still settles`() { store.save(entry(state = EntryState.PAUSED)) - assertTrue(ops.settle("e1", 1, Settlement.Failed("http", "HTTP 400", ok.copy(code = 400), null, "u", "POST"))) - val e = store.load("e1")!! - assertEquals(EntryState.ERROR, e.state) - assertEquals("http", journal.unacknowledged().single().errorKind) + assertTrue(ops.settle("e1", 1, Settlement.Completed(ok, "u", "POST"))) + assertEquals(EntryState.COMPLETED, store.load("e1")!!.state) + } + + @Test + fun `a failure that lands during pause does not settle`() { + store.save(entry(state = EntryState.PAUSED)) + assertFalse(ops.settle("e1", 1, Settlement.Failed("http", "HTTP 400", ok.copy(code = 400), null, "u", "POST"))) + assertEquals(EntryState.PAUSED, store.load("e1")!!.state) + assertEquals(emptyList(), journal.unacknowledged()) + assertEquals(emptyList(), events.log) + } + + @Test + fun `a failed simple settle keeps the live bytes, and a chunked one keeps its accepted bytes`() { + store.save(entry(state = EntryState.RUNNING)) + ops.settle("e1", 1, Settlement.Failed("http", "HTTP 400", ok.copy(code = 400), null, "u", "POST", bytesSent = 5)) + assertEquals(5, store.load("e1")!!.bytesSent) + assertEquals(5, journal.unacknowledged().single().bytesSent) + store.save(entry(id = "c", state = EntryState.RUNNING).copy(bytesSent = 10)) + ops.settle("c", 1, Settlement.Failed("file", "gone", null, 1, "u", "PUT")) + assertEquals(10, store.load("c")!!.bytesSent) } @Test @@ -268,16 +406,29 @@ class WorkerOpsTest { // MARK: - deliveries @Test - fun `JS that subscribes between the check and the append still gets the outcome live with deliveries 1`() { - // canDeliver() is false at the record build and true right after the append. - val answers = ArrayDeque(listOf(false, true)) - val flipping = object : QueueEvents by events { - override fun canDeliver() = answers.removeFirstOrNull() ?: true + fun `a settle racing the first drain reaches JS exactly once with deliveries 1`() { + // The drain sets the listener and scans under one journal lock, and the + // append decides 0 or 1 under it. Either the drain returns the record, + // or the settle emits it live; never both, never neither. + repeat(200) { i -> + val id = "race-$i" + val owner = Any() + journal.stopListening(listener) + journal.stopListening(owner) + store.save(entry(id = id, state = EntryState.RUNNING)) + events.records.clear() + val start = CyclicBarrier(2) + var drained: List = emptyList() + val drain = Thread { start.await(); drained = controller.unacknowledged(owner).filter { it.id == id } } + drain.start() + start.await() + ops.settle(id, 1, Settlement.Completed(ok, "u", "POST")) + drain.join() + val live = events.records.filter { it.id == id } + val seen = drained + live + assertEquals("iteration $i", 1, seen.size) + assertEquals("iteration $i", 1, seen.single().deliveries) + journal.ack(listOf(seen.single().eventId)) } - val flipOps = WorkerOps(store, journal, settings, flipping, scheduler) { now } - store.save(entry(state = EntryState.RUNNING)) - assertTrue(flipOps.settle("e1", 1, Settlement.Completed(ok, "u", "POST"))) - assertEquals(1, journal.unacknowledged().single().deliveries) - assertEquals(1, events.records.single().deliveries) } } From 6baf15a67288481d454858743182e8452206d26a Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Mon, 28 Sep 2026 13:11:04 -0400 Subject: [PATCH 12/22] Android test: clear the stale listener in the settle-vs-drain race test The test cleared the listener with stopListening(listener), but from the second iteration on the listener was the previous iteration's owner, so nothing was cleared. When the settle won the race, the journal stamped one live delivery and the drain counted a second one. CI failed about one run in five. Now each iteration clears whatever listener is set and asserts that none is set before the race starts. Co-Authored-By: Claude Fable 5.1 --- .../java/ai/openspace/backgroundupload/WorkerOpsTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt b/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt index 5c28fff6..4ffb7fac 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/WorkerOpsTest.kt @@ -413,8 +413,10 @@ class WorkerOpsTest { repeat(200) { i -> val id = "race-$i" val owner = Any() - journal.stopListening(listener) - journal.stopListening(owner) + // The previous iteration's drain left its owner as the listener. Clear + // it, so the race starts with no listener every time. + journal.listener()?.let { journal.stopListening(it) } + assertFalse(journal.isListening()) store.save(entry(id = id, state = EntryState.RUNNING)) events.records.clear() val start = CyclicBarrier(2) From b194b3d34ab2d80f3e3e337a6e2dc1362001f2ca Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Tue, 6 Oct 2026 13:36:16 -0400 Subject: [PATCH 13/22] Android: cancel resolves once its outcome is journaled When the cancel record was journaled but the entry save then failed, cancel() rejected with E_STORAGE. The cancel had already happened: the worker stopped, the cancelled outcome went to JS, and the ack, the next cancel(), or the boot sweep applies the record. A rejection told JS that nothing changed. Now cancel() resolves in that case, as iOS does. It rejects only when the journal write fails, and then nothing changes. Co-Authored-By: Claude Opus 5.5 --- .../ai/openspace/backgroundupload/QueueController.kt | 12 ++++++++++-- .../backgroundupload/QueueControllerTest.kt | 7 +++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt b/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt index c72cb0fe..67917e6d 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/QueueController.kt @@ -229,9 +229,17 @@ class QueueController( throw QueueException(QueueException.E_STORAGE, "could not journal the cancel: ${error.message}") } stop = true + // The journaled record is the cancel. A failed save does not undo + // it: the ack, the next cancel(), or the boot sweep applies the + // record. So cancel resolves, as on iOS; it rejects only when + // nothing changed. val next = EntryTransitions.toSettled(e, EntryState.CANCELLED, record.eventId, e.bytesSent, now) - saveOrThrow(next) - saved = next + try { + store.save(next) + saved = next + } catch (error: IOException) { + Diag.error("cancel journaled '$id' but could not save it; the record is applied later", error) + } } } finally { // Once an outcome is journaled, the worker must stop even when the diff --git a/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt b/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt index 6cfc408f..a6af671a 100644 --- a/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt +++ b/android/src/test/java/ai/openspace/backgroundupload/QueueControllerTest.kt @@ -470,18 +470,17 @@ class QueueControllerTest { } @Test - fun `cancel whose entry save fails stops the work, emits, rejects, and a retry adds no second outcome`() { + fun `cancel whose entry save fails still cancels and resolves, and a retry adds no second outcome`() { controller.enqueue(parsed()) events.log.clear() scheduler.cancelled.clear() val dir = store.entryDir("e1") dir.setWritable(false) - val error = try { - assertThrows(QueueException::class.java) { controller.cancel("e1") } + try { + controller.cancel("e1") // resolves: the journaled record is the cancel } finally { dir.setWritable(true) } - assertEquals(QueueException.E_STORAGE, error.code) val record = journal.unacknowledged().single() assertEquals(EventJournal.KIND_CANCELLED, record.kind) assertEquals(EntryState.QUEUED, store.load("e1")!!.state) // the save was lost From 6dcaa6f133b9b46544171bc9f78fded27bec21db Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 14:30:39 -0400 Subject: [PATCH 14/22] v10 slice 3: iOS queue store, executor, and events iOS implements the same contract over a background URLSession. QueueStore generalizes the v9 chunked manifest into one durable entry per mutate(), written with fsync before any task is created. BodyStaging writes JSON and multipart bodies to files, copies single-file bodies, and moves chunked sources, so every upload comes from a file as the background session requires. QueueCoordinator owns the entries: enqueue with the same-id rules and E_RUNNING/E_FILE_MISSING/E_STORAGE codes, one task per attempt keyed by (id, generation, attempt), TaskMap reconciliation at relaunch and through handleEventsForBackgroundURLSession, the retry table with delayed tasks for backoff, 401/403 parking with header generations, expiry, pause, cancel, and forget-after-ack. The journal writes before onSettled emits; onState, onProgress (throttled), and onAttempt follow the TypeScript payload types. RequestIndex backs the synchronous getRequests. First launch imports v9 journal entries as legacy rows. The pure half of the module is a SwiftPM package (ios/Package.swift) so `cd ios && swift test` runs 138 host-side tests, including crash-mid-write. The podspec excludes the package files and test sources from the pod. The example app builds for the simulator. Co-Authored-By: Claude Fable 5.1 --- ios/.gitignore | 2 + ios/BodyStaging.swift | 209 ++++++ ios/ChunkedCoordinator.swift | 922 +++++++----------------- ios/ChunkedEngine.swift | 105 ++- ios/ChunkedManifest.swift | 404 ----------- ios/ChunkedManifestV9.swift | 40 + ios/EnqueueParser.swift | 189 +++++ ios/EventJournal.swift | 311 +++++--- ios/Events.swift | 55 ++ ios/FileIO.swift | 65 ++ ios/JSONText.swift | 32 + ios/LegacyImport.swift | 49 ++ ios/Package.swift | 44 ++ ios/ProgressThrottle.swift | 39 + ios/QueueCoordinator+Enqueue.swift | 181 +++++ ios/QueueCoordinator+Outcomes.swift | 165 +++++ ios/QueueCoordinator+Reconcile.swift | 137 ++++ ios/QueueCoordinator+Simple.swift | 256 +++++++ ios/QueueCoordinator.swift | 387 ++++++++++ ios/QueueEntry.swift | 214 ++++++ ios/QueueSettings.swift | 76 ++ ios/QueueStore.swift | 261 +++++++ ios/RNBackgroundUpload.swift | 843 ++++++---------------- ios/RNFileUploader.mm | 52 +- ios/RequestIndex.swift | 76 ++ ios/RetryClassifier.swift | 86 +++ ios/TaskMap.swift | 156 ++-- ios/Tests/BodyStagingTests.swift | 120 +++ ios/Tests/CoordinatorChunkedTests.swift | 498 +++++++++++++ ios/Tests/CoordinatorSimpleTests.swift | 519 +++++++++++++ ios/Tests/QueueEntryTests.swift | 231 ++++++ ios/Tests/QueueStoreTests.swift | 153 ++++ ios/Tests/RetryClassifierTests.swift | 79 ++ ios/Tests/SupportComponentTests.swift | 309 ++++++++ ios/Tests/TestSupport.swift | 246 +++++++ ios/Transport.swift | 102 +++ react-native-background-upload.podspec | 3 + 37 files changed, 5716 insertions(+), 1900 deletions(-) create mode 100644 ios/.gitignore create mode 100644 ios/BodyStaging.swift delete mode 100644 ios/ChunkedManifest.swift create mode 100644 ios/ChunkedManifestV9.swift create mode 100644 ios/EnqueueParser.swift create mode 100644 ios/Events.swift create mode 100644 ios/FileIO.swift create mode 100644 ios/JSONText.swift create mode 100644 ios/LegacyImport.swift create mode 100644 ios/Package.swift create mode 100644 ios/ProgressThrottle.swift create mode 100644 ios/QueueCoordinator+Enqueue.swift create mode 100644 ios/QueueCoordinator+Outcomes.swift create mode 100644 ios/QueueCoordinator+Reconcile.swift create mode 100644 ios/QueueCoordinator+Simple.swift create mode 100644 ios/QueueCoordinator.swift create mode 100644 ios/QueueEntry.swift create mode 100644 ios/QueueSettings.swift create mode 100644 ios/QueueStore.swift create mode 100644 ios/RequestIndex.swift create mode 100644 ios/RetryClassifier.swift create mode 100644 ios/Tests/BodyStagingTests.swift create mode 100644 ios/Tests/CoordinatorChunkedTests.swift create mode 100644 ios/Tests/CoordinatorSimpleTests.swift create mode 100644 ios/Tests/QueueEntryTests.swift create mode 100644 ios/Tests/QueueStoreTests.swift create mode 100644 ios/Tests/RetryClassifierTests.swift create mode 100644 ios/Tests/SupportComponentTests.swift create mode 100644 ios/Tests/TestSupport.swift create mode 100644 ios/Transport.swift diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 00000000..2d9f16e2 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/ios/BodyStaging.swift b/ios/BodyStaging.swift new file mode 100644 index 00000000..d800e97a --- /dev/null +++ b/ios/BodyStaging.swift @@ -0,0 +1,209 @@ +import Foundation + +/// The staged body of one entry: a file inside the entry directory. A +/// background URLSession uploads from a file only, so every kind gets one, +/// a bodiless request included (0 bytes). +struct StagedBody: Equatable { + let kind: QueueEntry.BodyKind + let relativePath: String + let contentType: String? + let forceContentType: Bool + let totalBytes: Int64 + /// true when the file already existed (an adopted blob). A failed enqueue + /// must not delete it. + let adopted: Bool +} + +enum StagingError: Error, Equatable { + /// A source file is gone. Rejects E_FILE_MISSING. + case fileMissing(String) + /// The parts do not tile the file. Rejects E_STORAGE; JS validates, so this + /// is a size mismatch between the plan and the real file. + case invalid(String) + case io(String) +} + +enum BodyStaging { + static let bodyPrefix = "body-" + static let blobPrefix = "blob-" + + /// Stages `body` into `dir` under a fresh name, tmp + fsync + rename. Every + /// check that can reject runs before the caller's file is touched. + /// + /// `fallbackBlob` is an existing blob in `dir` that a chunked body may keep + /// when its source file is gone: the bytes a crash left between the move + /// and the entry save, a v9 blob, or the current entry's blob on a replace. + static func stage(_ body: ParsedEnqueue.Body, parts: [QueueEntry.Part], into dir: URL, + fallbackBlob: String?, fm: FileManager = .default) throws -> StagedBody { + do { + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + throw StagingError.io("cannot create the entry directory: \(error.localizedDescription)") + } + switch body { + case .none: + let name = uniqueName(bodyPrefix) + try write(Data(), dir.appendingPathComponent(name)) + return StagedBody(kind: .none, relativePath: name, contentType: nil, + forceContentType: false, totalBytes: 0, adopted: false) + + case .data(let json): + let name = uniqueName(bodyPrefix) + let data = Data(json.utf8) + try write(data, dir.appendingPathComponent(name)) + return StagedBody(kind: .data, relativePath: name, contentType: "application/json", + forceContentType: false, totalBytes: Int64(data.count), adopted: false) + + case .form(let fields): + for f in fields { + if let path = f.path, !fm.fileExists(atPath: fileURL(path).path) { + throw StagingError.fileMissing(path) + } + } + let name = uniqueName(bodyPrefix) + let boundary = "rnbgu-" + UUID().uuidString + let dest = dir.appendingPathComponent(name) + let tmp = FileIO.tmpURL(for: dest) + do { + let size = try writeMultipart(fields, boundary: boundary, to: tmp, fm: fm) + try FileIO.rename(tmp, onto: dest) + return StagedBody(kind: .form, relativePath: name, + contentType: "multipart/form-data; boundary=\(boundary)", + forceContentType: true, totalBytes: size, adopted: false) + } catch let e as StagingError { + try? fm.removeItem(at: tmp) + throw e + } catch { + try? fm.removeItem(at: tmp) + throw StagingError.io("cannot write the form body: \(error.localizedDescription)") + } + + case .file(let path): + let src = fileURL(path) + guard fm.fileExists(atPath: src.path) else { throw StagingError.fileMissing(path) } + let name = uniqueName(bodyPrefix) + let dest = dir.appendingPathComponent(name) + let tmp = FileIO.tmpURL(for: dest) + do { + try? fm.removeItem(at: tmp) + try fm.copyItem(at: src, to: tmp) + try FileHandle(forUpdating: tmp).synchronizeAndClose() + try FileIO.rename(tmp, onto: dest) + } catch { + try? fm.removeItem(at: tmp) + // The source vanished between the check and the copy. + if !fm.fileExists(atPath: src.path) { throw StagingError.fileMissing(path) } + throw StagingError.io("cannot copy the file body: \(error.localizedDescription)") + } + return StagedBody(kind: .file, relativePath: name, contentType: nil, + forceContentType: false, totalBytes: FileIO.size(dest) ?? 0, adopted: false) + + case .parts(let path): + let src = fileURL(path) + if fm.fileExists(atPath: src.path) { + let size = FileIO.size(src) ?? 0 + try requireTiling(parts, size: size) + let name = uniqueName(blobPrefix) + do { + // An O(1) rename on the same volume. Across volumes FileManager copies. + try fm.moveItem(at: src, to: dir.appendingPathComponent(name)) + } catch { + throw StagingError.io("cannot move the chunked file: \(error.localizedDescription)") + } + return StagedBody(kind: .parts, relativePath: name, contentType: nil, + forceContentType: false, totalBytes: size, adopted: false) + } + if let fallbackBlob, let size = FileIO.size(dir.appendingPathComponent(fallbackBlob)) { + try requireTiling(parts, size: size) + return StagedBody(kind: .parts, relativePath: fallbackBlob, contentType: nil, + forceContentType: false, totalBytes: size, adopted: true) + } + throw StagingError.fileMissing(path) + } + } + + /// Writes a multipart/form-data body. String fields are written as they + /// are; file fields are streamed in 1 MB reads. Returns the byte count. + static func writeMultipart(_ fields: [ParsedEnqueue.FormField], boundary: String, to url: URL, + fm: FileManager = .default) throws -> Int64 { + try? fm.removeItem(at: url) + guard fm.createFile(atPath: url.path, contents: nil) else { + throw StagingError.io("cannot create the form body") + } + let out = try FileHandle(forWritingTo: url) + defer { try? out.close() } + var total: Int64 = 0 + func put(_ data: Data) throws { + try out.write(contentsOf: data) + total += Int64(data.count) + } + for f in fields { + var head = "--\(boundary)\r\nContent-Disposition: form-data; name=\"\(quote(f.name))\"" + if let path = f.path { + let fileName = f.fileName ?? fileURL(path).lastPathComponent + head += "; filename=\"\(quote(fileName))\"" + } + head += "\r\nContent-Type: \(f.contentType)\r\n\r\n" + try put(Data(head.utf8)) + if let s = f.string { + try put(Data(s.utf8)) + } else if let path = f.path { + let src = fileURL(path) + guard let reader = try? FileHandle(forReadingFrom: src) else { + throw StagingError.fileMissing(path) + } + defer { try? reader.close() } + while let chunk = try reader.read(upToCount: 1 << 20), !chunk.isEmpty { + try put(chunk) + } + } + try put(Data("\r\n".utf8)) + } + try put(Data("--\(boundary)--\r\n".utf8)) + try out.synchronize() + return total + } + + /// Accepts a `file://` URL and a plain path. The JS layer forwards the + /// path as the caller wrote it. + static func fileURL(_ pathOrURL: String) -> URL { + if pathOrURL.hasPrefix("file://") { + if let u = URL(string: pathOrURL), u.isFileURL { return u } + return URL(fileURLWithPath: String(pathOrURL.dropFirst("file://".count))) + } + return URL(fileURLWithPath: pathOrURL) + } + + static func uniqueName(_ prefix: String) -> String { + prefix + UUID().uuidString.lowercased() + } + + static func requireTiling(_ parts: [QueueEntry.Part], size: Int64) throws { + guard QueueEntry.tilesExactly(parts, size: size) else { + throw StagingError.invalid("parts must tile exactly [0, \(size)), the size of the file") + } + } + + // Quotes and line breaks in a field name or file name would end the header. + // Percent-encode them, as browsers do. + private static func quote(_ s: String) -> String { + s.replacingOccurrences(of: "\"", with: "%22") + .replacingOccurrences(of: "\r", with: "%0D") + .replacingOccurrences(of: "\n", with: "%0A") + } + + private static func write(_ data: Data, _ url: URL) throws { + do { + try FileIO.writeAtomically(data, to: url) + } catch { + throw StagingError.io("cannot write the body: \(error.localizedDescription)") + } + } +} + +private extension FileHandle { + func synchronizeAndClose() throws { + defer { try? close() } + try synchronize() + } +} diff --git a/ios/ChunkedCoordinator.swift b/ios/ChunkedCoordinator.swift index 08173fa4..07472c04 100644 --- a/ios/ChunkedCoordinator.swift +++ b/ios/ChunkedCoordinator.swift @@ -1,729 +1,329 @@ import Foundation -/// Runs chunked uploads against the background sessions. It keeps the sliding -/// window of part tasks enqueued with the daemon. It evaluates the outcome of -/// each part. After a relaunch, it reconciles the durable [ChunkedManifest] -/// with the tasks that the daemon still holds. +/// Runs chunked entries against the background sessions: keeps the sliding +/// window of part tasks enqueued with the daemon, evaluates each part's +/// outcome, and after a relaunch rebuilds the window from the tasks the +/// daemon still holds. /// -/// Every state transition occurs on one serial queue. The queue enforces the -/// invariants that the design marks binding: at most [ChunkedEngine.window] -/// part tasks are enqueued per upload, and never two for the same part index. -/// `inFlight` maps each enqueued part to the task key that owns it. Only -/// refill, on this queue, creates tasks. +/// Every call runs on the QueueCoordinator's serial queue, which enforces the +/// binding invariants: at most ChunkedEngine.window part tasks per entry, and +/// never two for one part index. `inFlight` maps each enqueued part to the +/// key of the one task that owns it. Only enqueuePart creates part tasks. +/// +/// The entry (QueueEntry) is the durable truth: parts, accepted flags, +/// incarnation. Terminals go through QueueCoordinator.settle. final class ChunkedCoordinator { + struct LiveTask { + let task: UploadTask + let part: Int + let incarnation: String? + } - // The singleton that owns the background sessions. It outlives this object. - // Both live for the whole process. - private unowned let uploader: RNBackgroundUpload - - private let queue = DispatchQueue(label: "ai.openspace.rnbgupload.chunked") - - // partIndex -> the TaskMap key of the one task that may be in flight for it. - // The key lets us tell a superseded task's late completion (possible around - // a relaunch reconcile) apart from the live task's completion. + private unowned let q: QueueCoordinator + // id -> part index -> the task key that owns it. private var inFlight: [String: [Int: String]] = [:] - // The uploads whose in-flight set we rebuild from the daemon now. Refill is - // blocked until the rebuild lands. Thus a stale snapshot can never - // double-enqueue. The token makes overlapping reconciles safe: only the - // latest reconcile may apply its snapshot. An earlier snapshot could miss - // tasks enqueued after it was taken. To apply it would re-enqueue their - // part indexes. - private var reconcileToken: [String: UUID] = [:] - private var cooldownUntil: [String: [Int: Double]] = [:] // epoch ms - private var transientAttempts: [String: [Int: Int]] = [:] - private var expiryArmed: Set = [] - // The in-flight bytes per part index. They feed the byte-weighted - // aggregate progress. + // id -> part index -> bytes sent by its live task. Feeds the byte-weighted + // progress. private var partSent: [String: [Int: Int64]] = [:] - // A cache of the stored manifests, refreshed on every load. The progress - // path reads it. Thus didSendBodyData never touches the disk. - private var manifests: [String: ChunkedManifest] = [:] + // id -> part index -> when its delayed task begins (epoch ms), until it + // begins. When every part in the window waits, the row shows the earliest + // as nextAttemptAt. + private var partBeginAt: [String: [Int: Double]] = [:] - private static let progressThrottle: TimeInterval = 0.5 // seconds, per upload - private let progressLock = NSLock() - private var lastProgressAt: [String: TimeInterval] = [:] - - init(uploader: RNBackgroundUpload) { - self.uploader = uploader + init(_ coordinator: QueueCoordinator) { + q = coordinator } - private func nowMs() -> Double { Date().timeIntervalSince1970 * 1000 } - - // MARK: - Entry points (module methods) + // MARK: - Called by QueueCoordinator - /// Starts, or resumes, a chunked upload. The durable manifest makes the call - /// idempotent. A first call takes ownership of the source file (an O(1) - /// rename into the library's directory) and saves the manifest BEFORE any - /// task is enqueued. A new call with the same id reconciles instead. The - /// same parts resume: the stored headers are replaced, and accepted parts - /// are skipped. Different parts recreate the upload, per the design's rule - /// (see ChunkedManifest.reconciled). Crash recovery, resume after a stop, - /// and resume with fresh auth are all this same call. - /// - /// Every rejection-type validation runs BEFORE the source is consumed. The - /// parse throws first, and a reconcile never touches the source (`path` is - /// ignored once a manifest exists). One rejection is possible after the - /// move: the manifest save can fail. That leaves the blob adoptable. A - /// retry with the same id finds the blob at the blob path and proceeds (see - /// takeOwnership). - func startUpload(_ options: [String: Any], - resolve: @escaping (String) -> Void, - reject: @escaping (String) -> Void) { - queue.async { - do { - let incoming = try ChunkedManifest.parse(options, createdAt: self.nowMs()) - let id = incoming.id - let manifest: ChunkedManifest - if let existing = ChunkedStore.load(id) { - // "Running" per the design's recreate rule: not stalled (no - // journaled terminal error or cancel that awaits this resume) and - // not past its deadline. Everything else rejects a different parts - // array. That includes part tasks live with the daemon, and - // finished-but-unacked. - let running = !existing.stalled && !existing.isExpired(self.nowMs()) - manifest = try existing.reconciled( - with: incoming, running: running, blobSize: ChunkedStore.blobSize(id)) - if manifest.incarnation != existing.incarnation { - // This is a recreate. The in-flight byte counts belong to the - // replaced parts. reconcileLocked below cancels the old - // incarnation's tasks, and does not adopt them. enqueuePart - // sweeps its temp files. - self.partSent[id] = nil - } - } else { - guard let path = options["path"] as? String else { - throw ChunkedManifest.ParseError(message: "Missing 'path'") - } - try self.takeOwnership(path: path, id: id) - // The same rule as recreate, and as Android's validatedForCreate: - // the parts must tile [0, blob size) exactly. A partial or - // overlapping cover would silently upload wrong bytes. This throws - // BEFORE the manifest is saved and before anything is enqueued. - // Thus the moved blob stays adoptable by a corrected retry with the - // same id (see takeOwnership). - let blobSize = ChunkedStore.blobSize(id) - guard ChunkedManifest.tilesExactly(incoming.parts, size: blobSize) else { - throw ChunkedManifest.ParseError( - message: "chunked upload '\(id)' parts must tile exactly [0, \(blobSize))") - } - manifest = incoming - } - try ChunkedStore.save(manifest) - self.manifests[id] = manifest - // A fresh call gets a fresh retry budget. The persisted per-part - // rejection counts reset in the parts rebuild above (reconciled or - // parse). - self.transientAttempts[id] = nil - self.cooldownUntil[id] = nil - self.reconcileLocked(id, resumedByStart: true) - resolve(id) - } catch { - reject(error.localizedDescription) - } + /// queued -> running, then fill the window. + func start(_ id: String) { + guard var e = q.index.entry(id), e.isChunked, e.state == .queued || e.state == .running, + !q.settings.paused else { return } + if e.state == .queued { + e.state = .running + e.nextAttemptAt = nil + q.commit(e) } + refill(id) } - /// Rebuilds every stored upload's in-flight set from the daemon, and then - /// refills. Called when the sessions are created or recreated: an app - /// relaunch, a JS reload, or the background-wake path through - /// `RNBackgroundUpload.shared`. - func reconcileAll() { - // Claim the system's background completion handlers BEFORE the reconcile - // is queued. After a relaunch, the replayed didCompleteWithError callbacks - // run unowned and never refill. Thus this chain is the only refill. - // Nothing else stops urlSessionDidFinishEvents from handing the system - // its handler, and the app its suspension, before one new part task is - // enqueued. The risk is largest exactly when every enqueued part finished - // while the app was dead: zero daemon tasks left, no future wake, and a - // silent stall. The claim provably precedes any drain: a relaunch reaches - // this point inside the init of `shared`, and the AppDelegate hook - // finishes that init before it stores the handler. - RNBackgroundUpload.deferBackgroundCompletionHandlers() - queue.async { - let group = DispatchGroup() - for manifest in ChunkedStore.all() { - self.manifests[manifest.id] = manifest - group.enter() - self.reconcileLocked(manifest.id, resumedByStart: false) { group.leave() } - } - group.notify(queue: self.queue) { - // Every upload's post-reconcile refill has resumed its tasks. The - // handlers can drain now. - RNBackgroundUpload.releaseBackgroundCompletionHandlers() - } - } + /// Forgets the window. The caller cancels the tasks. + func stop(_ id: String) { + inFlight[id] = nil + partSent[id] = nil + partBeginAt[id] = nil } - /// Cancels a chunked upload. It journals one 'cancelled' (user) terminal - /// and stalls the upload. The manifest and the bytes are kept. Thus the - /// next startUpload resumes. Completion receives nil when the id has no - /// manifest (not a chunked upload). It receives false when nothing runs - /// (the upload is already terminal). - func cancel(_ id: String, completion: @escaping (Bool?) -> Void) { - queue.async { - guard let manifest = self.latest(id) else { completion(nil); return } - if manifest.stalled || manifest.allAccepted { completion(false); return } - var entry = JournaledEvent( - eventId: UUID().uuidString, id: id, type: "cancelled", timestamp: self.nowMs()) - entry.cancelReason = "user" - self.stall(id, entry: entry) - completion(true) + /// Relaunch: adopt the daemon's live part tasks of the current + /// incarnation, one per part index. Cancel the rest (a replaced plan, an + /// accepted part, a duplicate: concurrent PUTs of one partNum are unsafe on + /// the server). Then refill. + func reconcile(_ id: String, tasks: [LiveTask]) { + guard let e = q.index.entry(id) else { return } + var live: [Int: String] = [:] + for t in tasks { + let keep = t.incarnation == e.incarnation && e.parts.indices.contains(t.part) + && !e.parts[t.part].accepted && live[t.part] == nil + if keep { + live[t.part] = t.task.key + q.liveTasks[t.task.key] = (id, t.task) + if let begin = t.task.beginAt.map({ $0.timeIntervalSince1970 * 1000 }), begin > q.now() { + partBeginAt[id, default: [:]][t.part] = begin + } + } else { + q.taskMap.setPurpose(.superseded, forKey: t.task.key, id: id) + t.task.cancel() + } } - } - - /// An explicit release. It cancels the in-flight part tasks, with no - /// terminal event: the consumer lets go, and awaits no outcome. It deletes - /// the manifest, the moved bytes, and all part temp files. - func remove(_ id: String, completion: @escaping () -> Void) { - queue.async { - if self.latest(id) != nil { self.cancelTasks(for: id) } - ChunkedStore.remove(id) - self.clearState(id) - completion() + inFlight[id] = live + // Part files of accepted parts with no live task are orphans. + for i in e.parts.indices where e.parts[i].accepted && live[i] == nil { + q.store.removePartFile(id, i) } + start(id) + updateWait(id) } - /// The one moment when the library may delete a chunked upload's bytes: the - /// consumer acknowledged its 'completed' terminal event. - func releaseCompleted(_ ids: [String], completion: @escaping () -> Void) { - queue.async { - for id in ids { - ChunkedStore.remove(id) - self.clearState(id) - } - completion() - } - } + // MARK: - Delegate hooks - /// The chunked rows for getAllUploads: one aggregate row per manifest. The - /// part tasks are transport detail. bytesSent counts accepted parts only. - /// That is the durable number. - func snapshots(completion: @escaping ([[String: Any]]) -> Void) { - queue.async { - let rows = ChunkedStore.all().map { manifest -> [String: Any] in - let state: String - if manifest.allAccepted { - state = "completed" - } else if manifest.stalled { - state = "error" - } else if !(self.inFlight[manifest.id] ?? [:]).isEmpty { - state = "running" - } else { - state = "pending" - } - return ["id": manifest.id, - "state": state, - "bytesSent": manifest.acceptedBytes, - "totalBytes": manifest.totalBytes] + func partCompleted(id: String, part: Int, incarnation: String?, key: String, meta: TaskMap.Meta?, + completion c: TaskCompletion) { + let owned = inFlight[id]?[part] == key + if owned { + inFlight[id]?[part] = nil + partSent[id]?[part] = nil + partBeginAt[id]?[part] = nil + } + // Every path below may change the window; a settle or park makes this + // a no-op. + defer { updateWait(id) } + guard var e = q.index.entry(id), e.isChunked, !e.legacy else { + if owned { q.store.removePartFile(id, part) } + return + } + // A late callback from a replaced plan: its response is about ranges and + // urls this entry no longer describes. Write nothing from it. + guard incarnation == e.incarnation, e.parts.indices.contains(part) else { + if owned { refill(id) } + return + } + let cancelled = RetryClassifier.isCancellation(c.error) + if cancelled, meta?.purpose == .pause || meta?.purpose == .superseded { return } + let accepted = c.error == nil + && c.statusCode.map { UploadOutcome.isAccepted($0, body: c.body, accept: e.accept) } == true + q.emitAttempt(e, requestId: meta?.requestId, attempt: meta?.attempt ?? e.attempts, completion: c, + partIndex: part, accepted: accepted, systemCancel: cancelled) + e.lastRequestId = meta?.requestId ?? e.lastRequestId + e.lastUrl = e.parts[part].url + e.lastPartIndex = part + + // Accept first, whatever the entry's state: the server holds these bytes + // now. Losing the flag would re-send a part the server already has. + if accepted { + e = e.withPartAccepted(part) + q.commit(e, emit: false) + q.store.removePartFile(id, part) + guard e.state == .running else { return } + if e.allAccepted { + q.settle(id, .completed(RawResponseRecord(bodyTruncated: false))) + } else { + emitProgress(e) + refill(id) } - completion(rows) + return } - } - - // MARK: - Delegate hooks (called by RNBackgroundUpload) - func partProgress(id: String, part: Int, incarnation: String?, sent: Int64) { - let now = Date().timeIntervalSince1970 - progressLock.lock() - if let last = lastProgressAt[id], now - last < Self.progressThrottle { - progressLock.unlock() + // A failure of a task this process does not own is a relaunch replay or + // a superseded duplicate. The live task, or the reconcile refill, drives + // the part. The part file stays for reuse. + guard owned, e.state == .running else { return } + q.index.upsert(e) + if e.parts[part].accepted { + refill(id) return } - lastProgressAt[id] = now - progressLock.unlock() - queue.async { - // A removed or replaced incarnation's task must not feed the aggregate. - guard let manifest = self.manifests[id], manifest.incarnation == incarnation else { return } - self.partSent[id, default: [:]][part] = sent - self.emitAggregateProgress(id, manifest) + if cancelled { + retryPart(e, part) + return + } + let blobExists = e.bodyPath.map { FileIO.exists(q.store.fileURL(id, $0)) } ?? false + let verdict = RetryClassifier.classify(RetryClassifier.Input( + statusCode: c.statusCode, body: c.body, error: c.error, accept: e.accept, policy: q.policy(e), + isChunkedPart: true, fileExists: blobExists, now: q.now(), expiresAt: e.expiresAt)) + switch verdict { + case .accepted: + break // handled above + case .transient, .fileUnreadable: + retryPart(e, part) + case .auth: + if let g = meta?.headerGeneration, g < q.settings.headerGeneration { + _ = enqueuePart(id, part, delayMs: nil) + } else { + // Park the whole entry: the other parts would get the same answer. + q.park(e) + } + case .terminalHttp: + q.settle(id, .error(OutcomeErrorRecord( + errorKind: "http", message: "HTTP \(c.statusCode ?? 0) on part \(part)", + response: q.response(c), partIndex: part))) + case .fileMissing: + q.settle(id, .fileError("the chunked source blob is missing", partIndex: part)) + case .expired: + q.settle(id, .expired) } } - /// One part task finished (a foreground or background-wake delegate - /// callback). We evaluate the accept rules, update the manifest, delete the - /// temp file, and refill the window. It is synchronous on purpose: the - /// journal write for a terminal outcome must land before the delegate - /// callback returns. The simple-upload path obeys the same rule. - func handlePartCompletion(id: String, part: Int, incarnation: String?, taskKey: String, - statusCode: Int?, headers: [String: String], - body: String?, error: NSError?) { - queue.sync { - TaskMap.removeKey(taskKey) - let owned = inFlight[id]?[part] == taskKey - if owned { + /// A delayed part retry is about to start. Rebuild its request from the + /// entry's current headers, or cancel it when the entry moved on. + func partWillBegin(id: String, part: Int, incarnation: String?, key: String, + meta: TaskMap.Meta?) -> URLRequest? { + // Before the first reconcile nothing is owned yet; accept the task if the + // entry wants it. Reconcile then adopts or cancels it. + let ownedOrUnknown = !q.ready || inFlight[id]?[part] == key + guard let e = q.index.entry(id), e.isChunked, incarnation == e.incarnation, + e.parts.indices.contains(part), !e.parts[part].accepted, e.state == .running, + !q.settings.paused, ownedOrUnknown, let url = URL(string: e.parts[part].url) else { + q.taskMap.setPurpose(.superseded, forKey: key, id: id) + q.liveTasks[key] = nil + if inFlight[id]?[part] == key { inFlight[id]?[part] = nil - partSent[id]?[part] = nil - } - guard var manifest = latest(id) else { - // The upload was removed (removeUpload, or a completed ack) while - // this task was in flight. There is nothing left to report. - if owned { ChunkedStore.removePartFile(id, part) } - return + partBeginAt[id]?[part] = nil + updateWait(id) } - // A late callback from a removed-then-recreated or replaced - // incarnation. Its response is about byte ranges and URLs that this - // manifest no longer describes. Thus nothing about it, the accept flag - // included, may be written into the current manifest. Its temp file has - // the old token in its name. The sweep removes it when the current plan - // next materializes this index. - guard incarnation == manifest.incarnation else { - if owned { refill(id) } - return - } - // A part index that the manifest does not know (corrupt task metadata) - // must not crash the delegate. Drop the task's outcome and let refill - // plan again. - guard manifest.parts.indices.contains(part) else { - if owned { refill(id) } - return - } - - // Accept evaluation comes first. The server holds these bytes now, - // regardless of a concurrent stall or a superseded task in the same - // incarnation. If we lose the flag, we re-send a part that the server - // already has. - if error == nil, let statusCode, - UploadOutcome.isAccepted(statusCode, body: body, accept: manifest.accept) { - manifest = updateManifest(id) { $0.withPartAccepted(part) } - ?? manifest.withPartAccepted(part) - transientAttempts[id]?[part] = nil - cooldownUntil[id]?[part] = nil - if owned { ChunkedStore.removePartFile(id, part) } - // A stalled upload keeps the flag but reports nothing more. The - // journaled terminal stands until the next startUpload resume. That - // resume finds all parts accepted and completes without a re-send. - guard !manifest.stalled else { return } - if manifest.allAccepted { - finalizeCompleted(id, manifest, reemit: false) - } else { - emitAggregateProgress(id, manifest) - if owned { refill(id) } - } - return - } - - // A superseded task's failure carries no policy weight. The live task - // for this part drives the retries. But an UNOWNED task with no live - // replacement is a relaunch replay that runs before reconcile rebuilds - // ownership. If we drop its deterministic HTTP rejection, the part gets - // a fresh retry budget on every system wake. So count it, and let it - // trip the budget. The in-flight reconcile does the re-enqueueing. - guard owned else { - if inFlight[id]?[part] == nil, !manifest.stalled, !manifest.parts[part].accepted, - error == nil, let code = statusCode, !ChunkedEngine.isTransientHttp(code) { - recordRejection(id, part: part, manifest: manifest, code: code, - headers: headers, body: body, scheduleRetryInBudget: false) - } - return - } - ChunkedStore.removePartFile(id, part) // the retry builds the file again - // This is a duplicate of a part that a superseded task already - // delivered. The part is settled, whatever this task's outcome was. Its - // failure must not burn retries. - if manifest.parts[part].accepted { - if !manifest.stalled { refill(id) } - return - } - // A terminal is already journaled (a cancel, or a sibling part's - // stall). Swallow the fallout. - guard !manifest.stalled else { return } - - if let error, error.domain == NSURLErrorDomain, error.code == NSURLErrorCancelled { - // A user cancel journals and stalls in cancel() before the tasks are - // torn down. Thus a cancel here, with no stall, comes from the - // system. Retry it like a transient failure. - scheduleTransientRetry(id, part: part) - return - } - - if manifest.isExpired(nowMs()) { - stall(id, entry: expiredEntry(id)) - return - } - - if let error { - if RNBackgroundUpload.errorKind(for: error) == "file", - !FileManager.default.fileExists(atPath: ChunkedStore.blobURL(id).path) { - stall(id, entry: errorEntry( - id: id, error: "chunked source blob missing", errorKind: "file", partIndex: part)) - } else { - // This includes a lost temp part file. The retry rebuilds it from - // the blob. - scheduleTransientRetry(id, part: part) - } - return - } - - let code = statusCode ?? 0 - if ChunkedEngine.isTransientHttp(code) { - scheduleTransientRetry(id, part: part) - return - } - recordRejection(id, part: part, manifest: manifest, code: code, - headers: headers, body: body, scheduleRetryInBudget: true) + return nil } + partBeginAt[id]?[part] = nil + updateWait(id) + q.taskMap.setHeaderGeneration(q.settings.headerGeneration, forKey: key) + return q.buildRequest(e, url: url, requestId: meta?.requestId ?? UUID().uuidString, + partHeaders: e.parts[part].headers) } - /// The identity of a chunked part task, or nil for a simple upload's task. - /// taskDescription is primary. The persisted TaskMap entry, written before - /// the task first resumed, is the durable fallback. `incarnation` is the - /// manifest token that the task was created under. It is nil only for - /// corrupt metadata, and the consumers treat nil as a mismatch. - static func partRef(_ session: URLSession, _ task: URLSessionTask) - -> (id: String, part: Int, incarnation: String?)? { - if let ref = ChunkedEngine.parseTaskDescription(task.taskDescription) { return ref } - if let meta = TaskMap.meta(forKey: TaskMap.key(session, task)), let part = meta.partIndex { - return (meta.id, part, meta.incarnation) - } - return nil + func partProgress(id: String, part: Int, incarnation: String?, sent: Int64) { + guard let e = q.index.entry(id), e.incarnation == incarnation, e.state == .running else { return } + partSent[id, default: [:]][part] = sent + // A delayed part that began while the app was dead reports progress + // before any willBegin. + if partBeginAt[id]?.removeValue(forKey: part) != nil { updateWait(id) } + emitProgress(q.index.entry(id) ?? e) } - // MARK: - Window (all on `queue`) - - /// Rebuilds inFlight for one upload from the daemon's live tasks, and then - /// refills. A task in the .completed or .canceling state is NOT live: its - /// delegate callback, replayed after a relaunch, settles it. A part with no - /// live task simply enqueues again. Accept evaluation absorbs a - /// completed-but-unreported duplicate. We never guess. - /// `completion` fires, on `queue`, when this reconcile has settled: the - /// refill ran, or a newer reconcile superseded this one. reconcileAll gates - /// the background completion handlers on it. - private func reconcileLocked(_ id: String, resumedByStart: Bool, - completion: (() -> Void)? = nil) { - let token = UUID() - reconcileToken[id] = token - enumerateAllTasks { tasks in - self.queue.async { - defer { completion?() } - guard self.reconcileToken[id] == token else { return } // superseded - let manifest = self.latest(id) - var live: [Int: String] = [:] - for (session, task) in tasks { - guard let ref = Self.partRef(session, task), ref.id == id, - task.state == .running || task.state == .suspended else { continue } - if ref.incarnation != manifest?.incarnation || live[ref.part] != nil { - // Never adopt a task from a replaced incarnation. Its bytes and - // URL belong to the old plan, and the token check in - // handlePartCompletion drops its late completion. Never adopt a - // second live task for one part index: concurrent PUTs of one - // partNum are verified unsafe on the server side. - task.cancel() - } else { - live[ref.part] = TaskMap.key(session, task) - } - } - self.inFlight[id] = live - self.reconcileToken[id] = nil - if let manifest { - // Temp files for accepted parts with no live task are orphans. - for index in manifest.parts.indices - where manifest.parts[index].accepted && live[index] == nil { - ChunkedStore.removePartFile(id, index) - } - } - self.refill(id, resumedByStart: resumedByStart) - } - } - } + // MARK: - Window - /// Fills the window back up to [ChunkedEngine.window] enqueued part tasks. - /// Called after every part completion (the background-wake refill that the - /// design's liveness rationale requires), after a retry cooldown, and at - /// the end of every reconcile. - private func refill(_ id: String, resumedByStart: Bool = false) { - guard reconcileToken[id] == nil, let manifest = latest(id) else { return } - // Stalled wins, even over all-accepted. The journaled terminal stands - // until an explicit startUpload resume. The resume clears the stall, - // lands here again, and completes without a re-send. - guard !manifest.stalled else { return } - if manifest.allAccepted { - finalizeCompleted(id, manifest, reemit: resumedByStart) + /// Fills the window back up. Called after every part completion (the + /// background-wake refill that keeps the upload moving while the app is + /// dead), at start, and at the end of every reconcile. + func refill(_ id: String) { + guard q.ready, !q.settings.paused, let e = q.index.entry(id), e.isChunked, + e.state == .running else { return } + if e.allAccepted { + q.settle(id, .completed(RawResponseRecord(bodyTruncated: false))) return } - let now = nowMs() - if manifest.isExpired(now) { - stall(id, entry: expiredEntry(id)) + if q.now() >= e.expiresAt { + q.settle(id, .expired) return } - armExpiryCheck(id, expiresAt: manifest.expiresAt) - // A blob shorter than a part's range can never finish. Report a terminal - // 'file' now, not a surprise when the window reaches the short part - // later. A retry cannot help, because the bytes are not there. Thus this - // stalls, and awaits removeUpload or a recreate whose tiling rule fits - // the real size. - let blobSize = ChunkedStore.blobSize(id) - if let short = manifest.parts.indices.first(where: { manifest.parts[$0].end > blobSize }) { - stall(id, entry: errorEntry( - id: id, - error: "source blob is \(blobSize) bytes; part \(short) needs " - + "[\(manifest.parts[short].start), \(manifest.parts[short].end))", - errorKind: "file", partIndex: short)) + // A blob shorter than a part can never finish: report it now. + let blobSize = e.bodyPath.flatMap { FileIO.size(q.store.fileURL(id, $0)) } ?? 0 + if let short = e.parts.indices.first(where: { e.parts[$0].end > blobSize }) { + q.settle(id, .fileError( + "source blob is \(blobSize) bytes; part \(short) needs " + + "[\(e.parts[short].start), \(e.parts[short].end))", partIndex: short)) return } let flight = Set((inFlight[id] ?? [:]).keys) - let cooling = Set((cooldownUntil[id] ?? [:]).filter { $0.value > now }.keys) - for index in ChunkedEngine.indexesToEnqueue( - pending: manifest.pendingIndexes(), inFlight: flight, cooling: cooling) { - if !enqueuePart(id, index, manifest) { return } // stalled inside + for index in ChunkedEngine.indexesToEnqueue(pending: e.pendingIndexes(), inFlight: flight) { + if !enqueuePart(id, index, delayMs: nil) { return } // settled or deferred inside } } - private func enqueuePart(_ id: String, _ index: Int, _ manifest: ChunkedManifest) -> Bool { - let part = manifest.parts[index] + // MARK: - Private + + /// One part task. Write-ahead: the attempt count is saved first; the + /// TaskMap entry is written before resume. Returns false when the entry + /// settled instead, or when the save failed: then no task exists and a + /// refill runs after a backoff. + private func enqueuePart(_ id: String, _ index: Int, delayMs: Int?) -> Bool { + guard var e = q.index.entry(id), e.parts.indices.contains(index) else { return false } + let part = e.parts[index] guard let url = URL(string: part.url) else { - stall(id, entry: errorEntry( - id: id, error: "part \(index) url is not a valid URL", errorKind: "unknown", - partIndex: index)) + q.settle(id, .error(OutcomeErrorRecord( + errorKind: "unknown", message: "part \(index) url is not valid", partIndex: index))) return false } - // A background session can upload only from a file. Thus each enqueued - // part gets a temp file that holds exactly its byte range. The transient - // disk usage stays at window × partSize, not a second full copy of the - // source. - let partFile: URL + let file: URL do { - partFile = try ChunkedStore.writePartFile( - id: id, index: index, start: part.start, end: part.end, - incarnation: manifest.incarnation) + file = try q.store.writePartFile( + id: id, blob: e.bodyPath ?? ChunkedManifestV9.blobName, index: index, start: part.start, + end: part.end, incarnation: e.incarnation) } catch { - stall(id, entry: errorEntry( - id: id, error: "cannot materialize part \(index): \(error.localizedDescription)", - errorKind: "file", partIndex: index)) + q.settle(id, .fileError("cannot build part \(index): \(error.localizedDescription)", partIndex: index)) return false } - var request = URLRequest(url: url) - request.httpMethod = "PUT" - // Unchanged, per the protocol-as-data rule. The library adds nothing. - for (key, value) in part.headers { - request.setValue(value, forHTTPHeaderField: key) - } - let session = uploader.session(wifiOnly: manifest.wifiOnly) - let task: URLSessionUploadTask - do { - task = try RNBackgroundUpload.uploadTask(session, request, fromFile: partFile) - } catch { - stall(id, entry: errorEntry( - id: id, error: "cannot enqueue part \(index): \(error.localizedDescription)", - errorKind: "file", partIndex: index)) + e.attempts += 1 + guard q.commitAhead(e, emit: false) else { + let backoff = RetryClassifier.backoffMs( + attempt: max(part.rejections, 1), policy: q.policy(e), random: q.random) + q.schedule(max(delayMs ?? 0, backoff)) { [weak self] in self?.refill(id) } return false } - task.taskDescription = ChunkedEngine.taskDescription( - id: id, part: index, incarnation: manifest.incarnation) - let key = TaskMap.key(session, task) - TaskMap.set(TaskMap.Meta(id: id, accept: nil, partIndex: index, - incarnation: manifest.incarnation), forKey: key) - inFlight[id, default: [:]][index] = key - task.resume() - return true - } - - // MARK: - Terminal transitions (all on `queue`) - /// Journals the terminal, marks the upload stalled, and cancels its - /// in-flight tasks. The stall is durable: relaunch reconciliation must not - /// resume the upload; only startUpload may. The manifest and the bytes are - /// kept. Every non-completed terminal leaves the consumer its recovery - /// options. - private func stall(_ id: String, entry: JournaledEvent) { - _ = updateManifest(id) { manifest in - var next = manifest - next.stalled = true - return next + let requestId = UUID().uuidString + let meta = TaskMap.Meta( + id: id, partIndex: index, incarnation: e.incarnation, attempt: e.attempts, + requestId: requestId, headerGeneration: q.settings.headerGeneration, + generation: e.generation, purpose: .attempt) + let task = q.transport.upload( + q.buildRequest(e, url: url, requestId: requestId, partHeaders: part.headers), + fromFile: file, wifiOnly: q.settings.wifiOnly, + description: ChunkedEngine.taskDescription(id: id, part: index, incarnation: e.incarnation), + beginAt: delayMs.map { Date(timeIntervalSince1970: (q.now() + Double($0)) / 1000) }, + beforeResume: { key in self.q.taskMap.set(meta, forKey: key) }) + inFlight[id, default: [:]][index] = task.key + q.liveTasks[task.key] = (id, task) + if let delayMs { + partBeginAt[id, default: [:]][index] = q.now() + Double(delayMs) + } else { + partBeginAt[id]?[index] = nil } - cancelTasks(for: id) - partSent[id] = nil - cooldownUntil[id] = nil - RNBackgroundUpload.journalAndEmit(entry) + return true } - private func finalizeCompleted(_ id: String, _ manifest: ChunkedManifest, reemit: Bool) { - for index in manifest.parts.indices { ChunkedStore.removePartFile(id, index) } - partSent[id] = nil - // A resume of a finished-but-unacked upload must not mint a second - // terminal event. Emit the journaled event again. Thus a live listener - // still hears it, with the eventId that the consumer will ack. - if let existing = EventJournal.unacknowledgedEntries() - .first(where: { $0.id == id && $0.type == "completed" }) { - if reemit { RNBackgroundUpload.emitEvent(existing) } + /// A transient part failure: the next task is created now with a + /// backoff delay, so it holds the part's window slot and the daemon starts + /// it on time even while the app is dead. + private func retryPart(_ e: QueueEntry, _ part: Int) { + var n = e + n.parts[part].rejections += 1 + let delay = RetryClassifier.backoffMs( + attempt: n.parts[part].rejections, policy: q.policy(n), random: q.random) + if q.now() + Double(delay) >= n.expiresAt { + q.settle(n.id, .expired) return } - // There are no response fields, because no single response represents N - // accepted parts. The blob is deleted only when this event is ACKED (see - // ackEvents). - RNBackgroundUpload.journalAndEmit( - JournaledEvent(eventId: UUID().uuidString, id: id, type: "completed", timestamp: nowMs())) - } - - // MARK: - Retry scheduling (all on `queue`) - - /// Counts one non-transient HTTP rejection against the budget of `part`. - /// The count lives in the manifest, persisted best-effort like the accepted - /// flag. Thus it survives process death and can trip across wakes. A resume - /// or a recreate resets it (ChunkedManifest.reconciled rebuilds the parts - /// from the incoming call). Over budget: journal the terminal 'http' and - /// stall. In budget: schedule the backoff retry when this callback owns the - /// part. For an unowned replay, the reconcile already in flight does the - /// re-enqueueing. - private func recordRejection(_ id: String, part: Int, manifest: ChunkedManifest, - code: Int, headers: [String: String], body: String?, - scheduleRetryInBudget: Bool) { - let count = (manifest.parts[part].rejections ?? 0) + 1 - _ = updateManifest(id) { $0.withPartRejections(part, count) } - if count > ChunkedEngine.partHttpRetries { - let (capped, truncated) = EventJournal.capBody(body) - var entry = errorEntry( - id: id, error: "HTTP \(code) on part \(part)", errorKind: "http", partIndex: part) - entry.responseCode = code - entry.responseBody = capped - entry.responseBodyTruncated = truncated - entry.responseHeaders = headers - stall(id, entry: entry) - } else if scheduleRetryInBudget { - scheduleRetry(id, part: part, attempt: count) - } - } - - private func scheduleTransientRetry(_ id: String, part: Int) { - let attempt = (transientAttempts[id]?[part] ?? 0) + 1 - transientAttempts[id, default: [:]][part] = attempt - scheduleRetry(id, part: part, attempt: attempt) - } - - private func scheduleRetry(_ id: String, part: Int, attempt: Int) { - let delayMs = ChunkedEngine.backoffMs(attempt: attempt) - cooldownUntil[id, default: [:]][part] = nowMs() + Double(delayMs) - queue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in - guard let self else { return } - self.cooldownUntil[id]?[part] = nil - self.refill(id) - } - } - - // Expiry is evaluated on every transition. But an upload whose tasks all - // wait (for connectivity, or for backoff) would pass its deadline silently - // while the app is alive. Thus we arm one timer at the deadline. When a - // resume extended expiresAt, the stale timer's refill is a no-op that arms - // the timer again. - private func armExpiryCheck(_ id: String, expiresAt: Double) { - guard !expiryArmed.contains(id) else { return } - expiryArmed.insert(id) - let delayMs = Int(min(max(expiresAt - nowMs(), 0) + 100, 7 * 24 * 3_600_000)) - queue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in - guard let self else { return } - self.expiryArmed.remove(id) - self.refill(id) - } - } - - // MARK: - Helpers - - // The stored copy is the truth. A reconcile can have replaced the headers - // or expiresAt. The cache exists for the progress path, and as a fallback - // when a read fails in flight. - private func latest(_ id: String) -> ChunkedManifest? { - guard let manifest = ChunkedStore.load(id) else { - manifests[id] = nil - return nil - } - manifests[id] = manifest - return manifest - } - - private func updateManifest( - _ id: String, _ transform: (ChunkedManifest) -> ChunkedManifest - ) -> ChunkedManifest? { - // Here the save is best-effort, unlike in startUpload. A lost accepted - // flag only causes a re-send of a part, and the server absorbs the - // duplicate through the accept rules. That is better than a failed upload - // that the server in fact took. - let next = ChunkedStore.update(id, transform) ?? manifests[id].map(transform) - if let next { manifests[id] = next } - return next - } - - private func takeOwnership(path: String, id: String) throws { - let source = URL(string: path) ?? URL(fileURLWithPath: path) - let blob = ChunkedStore.blobURL(id) - let fm = FileManager.default - guard fm.fileExists(atPath: source.path) else { - // A crash between the move and the manifest save leaves the bytes at - // the blob path with no manifest. Adopt the bytes. Do not fail the - // retry. - if fm.fileExists(atPath: blob.path) { return } - throw ChunkedManifest.ParseError( - message: "chunked source file does not exist: \(source.path)") - } - try fm.createDirectory(at: ChunkedStore.uploadDir(id), withIntermediateDirectories: true) - try? fm.removeItem(at: blob) - // This is an O(1) rename on the same volume. Across volumes, FileManager - // falls back to a copy. - try fm.moveItem(at: source, to: blob) - } - - private func emitAggregateProgress(_ id: String, _ manifest: ChunkedManifest) { - let total = manifest.totalBytes - guard total > 0 else { return } - let sent = min(manifest.acceptedBytes + (partSent[id]?.values.reduce(0, +) ?? 0), total) - RNBackgroundUpload.emitProgress(id: id, progress: 100.0 * Float(sent) / Float(total)) - } - - private func clearState(_ id: String) { - inFlight[id] = nil - reconcileToken[id] = nil // discards any pending reconcile snapshot - partSent[id] = nil - cooldownUntil[id] = nil - transientAttempts[id] = nil - manifests[id] = nil - // A removed-then-recreated id must be able to arm its own expiry - // deadline, which is possibly earlier. It must not wait out the stale - // timer. - expiryArmed.remove(id) - progressLock.lock() - lastProgressAt[id] = nil // without this, one entry per id stays forever - progressLock.unlock() - } - - private func cancelTasks(for id: String) { - enumerateAllTasks { tasks in - for (session, task) in tasks where Self.partRef(session, task)?.id == id { - task.cancel() - } - } - } - - // Always examine both sessions. A resume can change wifiOnly while earlier - // part tasks continue where they started. - private func enumerateAllTasks( - _ completion: @escaping ([(URLSession, URLSessionTask)]) -> Void - ) { - let sessions = [uploader.session(wifiOnly: false), uploader.session(wifiOnly: true)] - let group = DispatchGroup() - let lock = NSLock() - var collected: [(URLSession, URLSessionTask)] = [] - for session in sessions { - group.enter() - session.getAllTasks { tasks in - lock.lock() - collected.append(contentsOf: tasks.map { (session, $0) }) - lock.unlock() - group.leave() - } - } - group.notify(queue: .global()) { completion(collected) } + q.commit(n, emit: false) + _ = enqueuePart(n.id, part, delayMs: delay) } - private func expiredEntry(_ id: String) -> JournaledEvent { - errorEntry(id: id, error: "upload expired before every part was accepted", - errorKind: "expired") + /// Sets nextAttemptAt to the earliest begin date when every part in the + /// window is a delayed task that has not begun, and clears it otherwise. + /// The state stays running. Emits `state` only on a change. + private func updateWait(_ id: String) { + guard var e = q.index.entry(id), e.isChunked, e.state == .running else { return } + let flight = inFlight[id] ?? [:] + let waits = partBeginAt[id] ?? [:] + let next = !flight.isEmpty && flight.keys.allSatisfy { waits[$0] != nil } + ? flight.keys.compactMap { waits[$0] }.min() : nil + guard next != e.nextAttemptAt else { return } + e.nextAttemptAt = next + q.commit(e) } - private func errorEntry(id: String, error: String, errorKind: String, - partIndex: Int? = nil) -> JournaledEvent { - var entry = JournaledEvent( - eventId: UUID().uuidString, id: id, type: "error", timestamp: nowMs()) - entry.error = error - entry.errorKind = errorKind - entry.partIndex = partIndex - return entry + private func emitProgress(_ e: QueueEntry) { + guard e.totalBytes > 0 else { return } + let sent = min(e.acceptedBytes + (partSent[e.id]?.values.reduce(0, +) ?? 0), e.totalBytes) + q.emitProgress(e.id, sent: sent, total: e.totalBytes) } } diff --git a/ios/ChunkedEngine.swift b/ios/ChunkedEngine.swift index caf3007c..38b6dbdf 100644 --- a/ios/ChunkedEngine.swift +++ b/ios/ChunkedEngine.swift @@ -1,63 +1,34 @@ import Foundation -// The pure scheduling half of chunked execution: window arithmetic, the -// retry policy, backoff, and the part-task identity encoding. It is kept free -// of session state. Thus the highest-consequence invariants (at most WINDOW -// part tasks enqueued per upload, and never two for one part index) can be -// examined in one place. [ChunkedCoordinator] owns the session side. +// The pure half of task scheduling: the chunked window and the task identity +// encodings. Free of session state, so the high-consequence invariants (at +// most `window` part tasks per upload, never two for one part index) can be +// examined in one place. ChunkedCoordinator owns the session side. enum ChunkedEngine { // The number of part tasks of one upload enqueued with the daemon at one - // time. It is a library constant, not an option: if soak data argues for a - // different value, this constant changes, not the API. The window is also a - // liveness decision. A background session only progresses tasks that are - // already enqueued. Thus WINDOW tasks of runway let a multi-part upload - // proceed while the app is dead. Without them, the upload pays a - // rate-limited wake per part. + // time. A library constant, not an option. It is also a liveness decision: + // a background session only progresses tasks already enqueued, so `window` + // tasks of runway let an upload proceed while the app is dead. static let window = 3 - // A non-accepted, non-transient HTTP response is retried this many times - // for each part. Then it becomes a terminal error and stalls the upload. - // The number is small on purpose. A response that the server repeats (401, - // 400) will not change without a new startUpload. Only transient failures - // retry without a limit. - static let partHttpRetries = 3 - - private static let backoffBaseMs = 1_000 - private static let backoffCapMs = 60_000 - - // A 5xx means that the server fails, not that the request is wrong. Thus - // it retries like a transport failure: without a limit, within expiresAt. - static func isTransientHttp(_ code: Int) -> Bool { (500...599).contains(code) } - - /// Exponential backoff for transient failures: 1s, 2s, 4s, up to a 60s cap. - static func backoffMs(attempt: Int) -> Int { - min(backoffBaseMs << min(max(attempt - 1, 0), 6), backoffCapMs) - } - - /// The part indexes to enqueue now: pending (not accepted), not already - /// enqueued, and not cooling down after a failure, up to the window size. - /// It never returns an index in `inFlight`. That is the one-task-per-part - /// invariant. - static func indexesToEnqueue( - pending: [Int], inFlight: Set, cooling: Set, window: Int = window - ) -> [Int] { + /// The part indexes to enqueue now: pending (not accepted) and not already + /// in flight, up to the free window slots. It never returns an index in + /// `inFlight`: the one-task-per-part invariant. A part waiting out a + /// backoff is in flight (its delayed task holds the slot). + static func indexesToEnqueue(pending: [Int], inFlight: Set, window: Int = window) -> [Int] { let slots = window - inFlight.count guard slots > 0 else { return [] } - return Array(pending.filter { !inFlight.contains($0) && !cooling.contains($0) }.prefix(slots)) + return Array(pending.filter { !inFlight.contains($0) }.prefix(slots)) } - // MARK: - Part-task identity + // MARK: - Task identity - // A chunked part task must carry (uploadId, partIndex, incarnation) - // through the daemon. taskDescription is the primary carrier. It is a - // prefix plus JSON, so a consumer id that contains a delimiter survives. - // TaskMap holds the same triple as the durable fallback, per the DTS - // guidance that TaskMap documents. The incarnation is the manifest token - // that the task was created under. A callback whose token no longer matches - // the stored manifest's token is from a removed or replaced plan. It must - // not write into the current plan. - private static let descriptionPrefix = "rnbgu-chunk:" + // A task carries its owner through the daemon in taskDescription: a prefix + // plus JSON, so an id with a colon or a slash survives. TaskMap holds the + // same fields as the durable fallback. + private static let partPrefix = "rnbgu-chunk:" + private static let requestPrefix = "rnbgu-req:" private struct PartRef: Codable { let id: String @@ -65,17 +36,39 @@ enum ChunkedEngine { var inc: String? } + private struct RequestRef: Codable { + let id: String + let gen: Int + let att: Int + } + + /// A chunked part task: (id, part index, incarnation). static func taskDescription(id: String, part: Int, incarnation: String) -> String { - let data = (try? JSONEncoder().encode(PartRef(id: id, part: part, inc: incarnation))) ?? Data() - return descriptionPrefix + (String(data: data, encoding: .utf8) ?? "") + partPrefix + encode(PartRef(id: id, part: part, inc: incarnation)) } - static func parseTaskDescription( - _ description: String? - ) -> (id: String, part: Int, incarnation: String?)? { - guard let description, description.hasPrefix(descriptionPrefix) else { return nil } - let json = Data(description.dropFirst(descriptionPrefix.count).utf8) - guard let ref = try? JSONDecoder().decode(PartRef.self, from: json) else { return nil } + static func parsePartDescription(_ description: String?) -> (id: String, part: Int, incarnation: String?)? { + guard let ref: PartRef = decode(description, partPrefix) else { return nil } return (ref.id, ref.part, ref.inc) } + + /// A simple attempt: (id, entry generation, attempt ordinal). + static func taskDescription(id: String, attempt: Int, generation: Int) -> String { + requestPrefix + encode(RequestRef(id: id, gen: generation, att: attempt)) + } + + static func parseRequestDescription(_ description: String?) -> (id: String, generation: Int, attempt: Int)? { + guard let ref: RequestRef = decode(description, requestPrefix) else { return nil } + return (ref.id, ref.gen, ref.att) + } + + private static func encode(_ value: T) -> String { + let data = (try? JSONEncoder().encode(value)) ?? Data() + return String(data: data, encoding: .utf8) ?? "" + } + + private static func decode(_ description: String?, _ prefix: String) -> T? { + guard let description, description.hasPrefix(prefix) else { return nil } + return try? JSONDecoder().decode(T.self, from: Data(description.dropFirst(prefix.count).utf8)) + } } diff --git a/ios/ChunkedManifest.swift b/ios/ChunkedManifest.swift deleted file mode 100644 index 5375cd3c..00000000 --- a/ios/ChunkedManifest.swift +++ /dev/null @@ -1,404 +0,0 @@ -import Foundation - -/// The durable record of one chunked upload: the parts that the consumer -/// authored, and which of them the server has accepted. [ChunkedStore] saves -/// it at startUpload, BEFORE any task is enqueued. Thus a process that the -/// system relaunches (or a startUpload after a crash, a stop, or a reauth) -/// resumes from it without a call into JS. This manifest IS the resume -/// mechanism. -/// -/// The content is the same as the Android manifest, with two platform -/// differences: -/// - There is no sourcePath field. iOS moves the app container between -/// launches, so an absolute path would go stale. The moved bytes live at a -/// location derived from the id (ChunkedStore.blobURL). -/// - `stalled` is persisted. On Android, "stalled" only means that the worker -/// is not scheduled. iOS reconciles every upload on relaunch. Thus an -/// upload that journaled a terminal outcome needs a durable marker that -/// says: await an explicit startUpload resume, and do not refill. -struct ChunkedManifest: Codable { - /// One part, exactly as the consumer authored it. The library sends the - /// file bytes [start, end) as the body of a PUT to `url`, with `headers` - /// unchanged. It never derives or edits a protocol field. - struct Part: Codable { - let url: String - var headers: [String: String] - let start: Int64 - let end: Int64 // exclusive - var accepted: Bool = false - /// The non-transient HTTP rejections counted against this part's retry - /// budget (nil means 0). It is persisted so that the budget survives - /// process death. An in-memory count resets on every system wake. That - /// would let a deterministic 4xx upload the part again until expiresAt, - /// with no terminal ever journaled. The count resets when the part is - /// rebuilt from an incoming call. Resume and recreate both do that (see - /// [reconciled]). - var rejections: Int? - - var size: Int64 { end - start } - } - - let id: String - var parts: [Part] - var accept: [UploadOutcome.AcceptRule] - /// Epoch ms. After this time, the upload stops with errorKind 'expired'. - var expiresAt: Double - var wifiOnly: Bool - let createdAt: Double - var stalled: Bool = false - /// The identity of this parts plan. It rotates on a recreate (a startUpload - /// that replaced the parts wholesale). It never rotates on a resume. Part - /// tasks carry it in their identity. Thus a late delegate callback from a - /// removed or replaced incarnation can be told apart from the live plan's - /// callbacks and dropped. Its response is about byte ranges and URLs that - /// this manifest no longer describes. - var incarnation: String - - var totalBytes: Int64 { parts.reduce(0) { $0 + $1.size } } - var acceptedBytes: Int64 { parts.filter(\.accepted).reduce(0) { $0 + $1.size } } - - /// The server's auto-publish condition. It is the only thing that - /// 'completed' may mean. - var allAccepted: Bool { parts.allSatisfy(\.accepted) } - - func isExpired(_ nowMs: Double) -> Bool { nowMs >= expiresAt } - - func pendingIndexes() -> [Int] { parts.indices.filter { !parts[$0].accepted } } - - func withPartAccepted(_ index: Int) -> ChunkedManifest { - var next = self - next.parts[index].accepted = true - return next - } - - func withPartRejections(_ index: Int, _ count: Int) -> ChunkedManifest { - var next = self - next.parts[index].rejections = count - return next - } - - struct ReconcileError: LocalizedError { - let message: String - var errorDescription: String? { message } - } - - /// A new startUpload call with an existing id is one of two things. The - /// semantics are identical to Android's `ChunkedManifest.reconcile`. - /// - /// **Resume** — the incoming parts are the SAME array (identical count, - /// ranges, and urls). The headers, the accept rules, expiresAt, and wifiOnly - /// come from the new call. This is how fresh auth reaches stalled parts, - /// and how a salvage extends the deadline. The accepted part statuses, - /// createdAt, and the incarnation survive from this manifest. A resume is - /// permitted at any time, running or not. The stall clears, because a - /// resume is the whole point of the new call. - /// - /// **Recreate** — a DIFFERENT parts array: the consumer authored the upload - /// again, under a fresh server uploadId, after the old one died. The owned - /// bytes are kept. The parts are replaced wholesale. Every part status - /// resets to unsent. The headers, accept rules, and expiresAt come from the - /// new call. The new ranges must tile exactly [0, blobSize). A partial or - /// overlapping cover would silently upload wrong bytes. A recreate is - /// accepted only while the upload is NOT running (stalled on a terminal - /// error or cancel, or expired). A different parts array while part tasks - /// are live is a consumer bug, not a recreate, because the in-flight - /// requests belong to the old parts. The incarnation rotates to the - /// incoming manifest's fresh token. Thus late callbacks from the replaced - /// parts are dropped. - func reconciled(with incoming: ChunkedManifest, running: Bool, - blobSize: Int64) throws -> ChunkedManifest { - if samePartsAs(incoming) { - // Built from `incoming`, so the per-part rejection counts reset. A - // resume arrives with fresh headers and gets a fresh retry budget. - let mergedParts = incoming.parts.enumerated().map { i, new -> Part in - var part = new - part.accepted = parts[i].accepted - return part - } - return ChunkedManifest( - id: id, parts: mergedParts, accept: incoming.accept, expiresAt: incoming.expiresAt, - wifiOnly: incoming.wifiOnly, createdAt: createdAt, stalled: false, - incarnation: incarnation) - } - guard !running else { - throw ReconcileError(message: - "chunked upload '\(id)' is running; a different parts array is only accepted once it stops") - } - guard Self.tilesExactly(incoming.parts, size: blobSize) else { - throw ReconcileError(message: - "chunked upload '\(id)' recreate parts must tile exactly [0, \(blobSize))") - } - return ChunkedManifest( - id: id, parts: incoming.parts, accept: incoming.accept, expiresAt: incoming.expiresAt, - wifiOnly: incoming.wifiOnly, createdAt: createdAt, stalled: false, - incarnation: incoming.incarnation) - } - - private func samePartsAs(_ incoming: ChunkedManifest) -> Bool { - incoming.parts.count == parts.count && parts.indices.allSatisfy { i in - incoming.parts[i].url == parts[i].url - && incoming.parts[i].start == parts[i].start - && incoming.parts[i].end == parts[i].end - } - } - - /// Tells whether `parts` cover [0, size) exactly: no gap, no overlap, and - /// nothing past the end. It is order-independent, like everything else - /// about parts. - static func tilesExactly(_ parts: [Part], size: Int64) -> Bool { - guard !parts.isEmpty else { return false } - var cursor: Int64 = 0 - for part in parts.sorted(by: { $0.start < $1.start }) { - guard part.start == cursor, part.end > part.start else { return false } - cursor = part.end - } - return cursor == size - } - - struct ParseError: LocalizedError { - let message: String - var errorDescription: String? { message } - } - - /// Turns bridged options into a manifest. It throws on each field that the - /// engine relies on. JS validates first. Thus a throw here is a bug worth - /// surfacing, not UX. - static func parse(_ options: [String: Any], createdAt: Double) throws -> ChunkedManifest { - guard let id = options["id"] as? String, !id.isEmpty else { - throw ParseError(message: "Missing 'id'") - } - guard let rawParts = options["parts"] as? [[String: Any]], !rawParts.isEmpty else { - throw ParseError(message: "'parts' must be a non-empty array") - } - guard let expiresAt = (options["expiresAt"] as? NSNumber)?.doubleValue else { - throw ParseError(message: "Missing 'expiresAt'") - } - let parts = try rawParts.enumerated().map { i, raw -> Part in - guard let url = raw["url"] as? String else { - throw ParseError(message: "Missing 'parts[\(i)].url'") - } - guard let range = raw["range"] as? [String: Any], - let start = (range["start"] as? NSNumber)?.int64Value, - let end = (range["end"] as? NSNumber)?.int64Value, - start >= 0, start < end else { - throw ParseError(message: "Invalid 'parts[\(i)].range'") - } - return Part(url: url, headers: parseHeaders(raw["headers"]), start: start, end: end) - } - return ChunkedManifest( - id: id, - parts: parts, - accept: UploadOutcome.parseAcceptRules(options["accept"]), - expiresAt: expiresAt, - wifiOnly: (options["wifiOnly"] as? Bool) ?? false, - createdAt: createdAt, - incarnation: UUID().uuidString) - } - - // The same header coercion as the simple-upload path: strings and numbers - // only. Anything else is skipped. It is not interpolated onto the wire. - private static func parseHeaders(_ raw: Any?) -> [String: String] { - guard let headers = raw as? [String: Any] else { return [:] } - var result: [String: String] = [:] - for (key, value) in headers { - if let s = value as? String { - result[key] = s - } else if let n = value as? NSNumber { - result[key] = n.stringValue - } - } - return result - } -} - -/// A file-backed store: one directory per upload id. The directory holds -/// `manifest.json`, `blob` (the moved source bytes), and the in-flight part -/// temp files. It has the same durability pattern as [EventJournal]: a -/// synchronous serial queue, atomic writes, and corrupt files read as -/// absent. -enum ChunkedStore { - struct StoreError: LocalizedError { - let message: String - var errorDescription: String? { message } - } - - private static let queue = DispatchQueue(label: "ai.openspace.rnbgupload.chunkedstore") - - private static let dirURL: URL = { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - var dir = base.appendingPathComponent("RNFileUploaderChunked", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - // This is device-local upload state. Keep it out of iCloud/iTunes - // backups. - var values = URLResourceValues() - values.isExcludedFromBackup = true - try? dir.setResourceValues(values) - return dir - }() - - // Upload ids come from the consumer. They can contain path separators or - // other filesystem-hostile characters. Thus the directory name is an - // encoding of the id, never the id itself. The id is read back from the - // manifest, not decoded from the name. - static func uploadDir(_ id: String) -> URL { - dirURL.appendingPathComponent( - Data(id.utf8).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: ""), - isDirectory: true) - } - - private static func manifestURL(_ id: String) -> URL { - uploadDir(id).appendingPathComponent("manifest.json") - } - - /// The location where startUpload moves the source file for this id. - static func blobURL(_ id: String) -> URL { - uploadDir(id).appendingPathComponent("blob") - } - - /// The temp file that holds exactly the byte range of part `index` while - /// that part is enqueued with the daemon (a background session can upload - /// only from a file). The name encodes the manifest incarnation and the - /// byte range. Thus a stale file from a previous incarnation or a different - /// plan can never be adopted by a size coincidence. Reuse checks the full - /// identity, not only the byte count. - static func partFileURL(_ id: String, _ index: Int, incarnation: String, - start: Int64, end: Int64) -> URL { - uploadDir(id).appendingPathComponent("part-\(index).\(incarnation).\(start)-\(end)") - } - - /// The size in bytes of the moved blob. It is 0 when the blob is missing. - static func blobSize(_ id: String) -> Int64 { - queue.sync { - (((try? FileManager.default.attributesOfItem(atPath: blobURL(id).path))?[.size] - as? NSNumber)?.int64Value) ?? 0 - } - } - - static func load(_ id: String) -> ChunkedManifest? { - queue.sync { read(manifestURL(id)) } - } - - /// Throws on a write failure. A manifest that did not persist must fail - /// the startUpload call. - static func save(_ manifest: ChunkedManifest) throws { - try queue.sync { - try FileManager.default.createDirectory( - at: uploadDir(manifest.id), withIntermediateDirectories: true) - let data = try JSONEncoder().encode(manifest) - try data.write(to: manifestURL(manifest.id), options: .atomic) - } - } - - /// An atomic read-modify-write. Thus a mark of one part as accepted can - /// never clobber a concurrent reconcile's fresh headers, or another part's - /// flag. It returns nil, and does not throw, when the manifest is gone or - /// the write failed. Callers that can proceed from memory do so. - static func update(_ id: String, _ transform: (ChunkedManifest) -> ChunkedManifest) -> ChunkedManifest? { - queue.sync { - guard let manifest = read(manifestURL(id)) else { return nil } - let next = transform(manifest) - guard let data = try? JSONEncoder().encode(next) else { return nil } - do { - try data.write(to: manifestURL(id), options: .atomic) - return next - } catch { - return nil - } - } - } - - /// Deletes the manifest, the moved bytes, AND all part temp files. It does - /// nothing for an unknown id (for example, a simple upload's id). - static func remove(_ id: String) { - queue.sync { try? FileManager.default.removeItem(at: uploadDir(id)) } - } - - static func all() -> [ChunkedManifest] { - queue.sync { - let dirs = (try? FileManager.default.contentsOfDirectory( - at: dirURL, includingPropertiesForKeys: nil)) ?? [] - return dirs.compactMap { read($0.appendingPathComponent("manifest.json")) } - } - } - - // Codable enforces the non-optional fields at decode time, unlike Gson. - // Thus a corrupt or field-renamed file simply reads as absent. - private static func read(_ url: URL) -> ChunkedManifest? { - guard let data = try? Data(contentsOf: url) else { return nil } - guard let m = try? JSONDecoder().decode(ChunkedManifest.self, from: data), - !m.parts.isEmpty else { return nil } - return m - } - - /// Writes bytes [start, end) of the blob into `dest`. It writes a tmp file - /// and renames it. Thus a partial write can never be mistaken for a - /// finished part file. It throws when the blob is missing or shorter than - /// `end`. For the caller that is a 'file' terminal, because a retry can - /// never succeed. - static func writePartFile(id: String, index: Int, start: Int64, end: Int64, - incarnation: String) throws -> URL { - try queue.sync { - let dest = partFileURL(id, index, incarnation: incarnation, start: start, end: end) - // Sweep the other files of this index first. A temp file left by a - // replaced incarnation or plan must not stay and leak disk. It cannot - // be reused, because the identity is in the name, but it can pile up. - removePartFilesLocked(id, index, keeping: dest) - // An existing file with exactly this identity and size is a finished - // copy from a previous enqueue of this part. Reuse it. Size alone is - // not trusted. The name carries the incarnation and the range that - // produced the file. - if let size = try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? NSNumber, - size.int64Value == end - start { - return dest - } - let blob = blobURL(id) - let blobSize = ((try FileManager.default.attributesOfItem(atPath: blob.path)[.size] - as? NSNumber)?.int64Value) ?? 0 - guard blobSize >= end else { - throw StoreError( - message: "source blob is \(blobSize) bytes; part \(index) needs [\(start), \(end))") - } - let tmp = uploadDir(id).appendingPathComponent("part-\(index).tmp") - FileManager.default.createFile(atPath: tmp.path, contents: nil) - let reader = try FileHandle(forReadingFrom: blob) - defer { try? reader.close() } - let writer = try FileHandle(forWritingTo: tmp) - defer { try? writer.close() } - try reader.seek(toOffset: UInt64(start)) - var remaining = end - start - while remaining > 0 { - let chunk = Int(min(remaining, 1 << 20)) - guard let data = try reader.read(upToCount: chunk), !data.isEmpty else { - throw StoreError(message: "short read building part \(index)") - } - try writer.write(contentsOf: data) - remaining -= Int64(data.count) - } - try? FileManager.default.removeItem(at: dest) - try FileManager.default.moveItem(at: tmp, to: dest) - return dest - } - } - - /// Removes every file for part `index`: the current incarnation's file, - /// stale files, and half-written tmp files. They all share the - /// `part-.` prefix. - static func removePartFile(_ id: String, _ index: Int) { - queue.sync { removePartFilesLocked(id, index, keeping: nil) } - } - - // Must run on `queue`. The `part-.` prefix cannot collide across - // indexes ("part-1." is not a prefix of "part-12.<...>"). - private static func removePartFilesLocked(_ id: String, _ index: Int, keeping: URL?) { - let files = (try? FileManager.default.contentsOfDirectory( - at: uploadDir(id), includingPropertiesForKeys: nil)) ?? [] - for file in files - where file.lastPathComponent.hasPrefix("part-\(index).") - && file.lastPathComponent != keeping?.lastPathComponent { - try? FileManager.default.removeItem(at: file) - } - } -} diff --git a/ios/ChunkedManifestV9.swift b/ios/ChunkedManifestV9.swift new file mode 100644 index 00000000..c7cad595 --- /dev/null +++ b/ios/ChunkedManifestV9.swift @@ -0,0 +1,40 @@ +import Foundation + +/// The v9 chunked manifest (`manifest.json`), kept only to read the files a +/// v9 build left behind. Decode only; v10 never writes it. A same-id enqueue +/// with the same parts adopts its accepted flags, incarnation and blob. +struct ChunkedManifestV9: Codable, Equatable { + struct Part: Codable, Equatable { + let url: String + var headers: [String: String] + let start: Int64 + let end: Int64 + var accepted: Bool = false + var rejections: Int? + + var size: Int64 { end - start } + } + + let id: String + var parts: [Part] + var accept: [UploadOutcome.AcceptRule] + var expiresAt: Double + var wifiOnly: Bool + let createdAt: Double + var stalled: Bool = false + var incarnation: String + + /// v9 wrote the moved bytes at this fixed name. + static let blobName = "blob" + + var totalBytes: Int64 { parts.reduce(0) { $0 + $1.size } } + var acceptedBytes: Int64 { parts.filter(\.accepted).reduce(0) { $0 + $1.size } } + + /// Same count, and the same url and range at each index. + func sameParts(as other: [QueueEntry.Part]) -> Bool { + parts.count == other.count && parts.indices.allSatisfy { + parts[$0].url == other[$0].url && parts[$0].start == other[$0].start + && parts[$0].end == other[$0].end + } + } +} diff --git a/ios/EnqueueParser.swift b/ios/EnqueueParser.swift new file mode 100644 index 00000000..956d015f --- /dev/null +++ b/ios/EnqueueParser.swift @@ -0,0 +1,189 @@ +import Foundation + +/// What enqueue() received, validated. JS validates first, so a throw here +/// is a bug worth surfacing (it rejects E_STORAGE), not a user error. +struct ParsedEnqueue { + enum Body { + case none + case data(json: String) + case form([FormField]) + case file(path: String) + case parts(file: String) + } + + struct FormField: Equatable { + let name: String + let contentType: String + let string: String? + let path: String? + let fileName: String? + } + + let id: String + let key: String + let varsJSON: String + let descriptorJSON: String + let url: String? + let method: String + let headers: [String: String] + let body: Body + let parts: [QueueEntry.Part] + let accept: [UploadOutcome.AcceptRule] + let expiresAt: Double + let retry: RetryOverride? + /// Body identity for the same-id rules. + let fingerprint: String +} + +struct ParseError: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +enum EnqueueParser { + static let methods: Set = ["POST", "PUT", "PATCH", "DELETE", "GET"] + + /// Parses the bridged `{ id, key, vars, descriptor }`. NSNull counts as + /// absent for every field but `data`, where it is the JSON body `null` + /// (the JS contract accepts any JSON value). The bridge drops a JS + /// `undefined` before native code sees it. + static func parse(_ raw: [String: Any]) throws -> ParsedEnqueue { + guard let id = raw["id"] as? String, !id.isEmpty else { throw ParseError(message: "missing 'id'") } + guard let key = raw["key"] as? String, !key.isEmpty else { throw ParseError(message: "missing 'key'") } + guard let d = raw["descriptor"] as? [String: Any] else { + throw ParseError(message: "missing 'descriptor'") + } + guard let varsJSON = JSONText.encode(present(raw["vars"])) else { + throw ParseError(message: "'vars' is not JSON") + } + // The data body lives in the staged file. Keeping it here too would + // double it in entry.json, which is rewritten on every transition. + var described = d + described["data"] = nil + guard let descriptorJSON = JSONText.encode(described) else { + throw ParseError(message: "'descriptor' is not JSON") + } + let method = ((present(d["method"]) as? String) ?? "POST").uppercased() + guard methods.contains(method) else { throw ParseError(message: "unknown method '\(method)'") } + guard let expiresAt = (present(d["expiresAt"]) as? NSNumber)?.doubleValue else { + throw ParseError(message: "missing 'expiresAt'") + } + + let url = present(d["url"]) as? String + if let url { try requireURL(url, "url") } + + var kinds: [ParsedEnqueue.Body] = [] + if d["data"] is NSNull { + kinds.append(.data(json: "null")) + } else if let data = d["data"] { + guard let json = JSONText.encode(data) else { throw ParseError(message: "'data' is not JSON") } + kinds.append(.data(json: json)) + } + if let form = present(d["form"]) { kinds.append(.form(try parseForm(form))) } + let file = present(d["file"]) as? String + var parts: [QueueEntry.Part] = [] + if let rawParts = present(d["parts"]) { + guard let file else { throw ParseError(message: "'parts' requires 'file'") } + parts = try parseParts(rawParts) + kinds.append(.parts(file: file)) + } else if let file { + kinds.append(.file(path: file)) + } + guard kinds.count <= 1 else { throw ParseError(message: "more than one body kind") } + guard url != nil || !parts.isEmpty else { throw ParseError(message: "'url' is required unless 'parts' is set") } + let body = kinds.first ?? .none + + return ParsedEnqueue( + id: id, key: key, varsJSON: varsJSON, descriptorJSON: descriptorJSON, url: url, + method: method, headers: headers(present(d["headers"])), body: body, parts: parts, + accept: UploadOutcome.parseAcceptRules(present(d["accept"])), expiresAt: expiresAt, + retry: RetryOverride.parse(present(d["retry"])), + fingerprint: fingerprint(body, parts: parts)) + } + + /// Strings and numbers become headers. Anything else is skipped, never + /// interpolated onto the wire ("" in an Authorization header). + static func headers(_ raw: Any?) -> [String: String] { + guard let headers = raw as? [String: Any] else { return [:] } + var result: [String: String] = [:] + for (k, v) in headers { + if let s = v as? String { + result[k] = s + } else if let n = v as? NSNumber { + result[k] = n.stringValue + } + } + return result + } + + /// Body identity. data: canonical JSON, so key order does not matter. + /// form: the fields as sent; paths compare as strings, because the caller + /// may have deleted the source after the first mutate() resolved. file: the + /// path as sent. parts: url and range of each part; the `file` path is not + /// part of it, because the moved blob is the body. + static func fingerprint(_ body: ParsedEnqueue.Body, parts: [QueueEntry.Part]) -> String { + switch body { + case .none: + return "none" + case .data(let json): + return "data:" + JSONText.sha256(json) + case .form(let fields): + let text = fields.map { f in + [f.name, f.contentType, f.string.map { "s:" + $0 } ?? "", f.path.map { "p:" + $0 } ?? "", + f.fileName ?? ""].map { $0.replacingOccurrences(of: "|", with: "||") }.joined(separator: "|") + }.joined(separator: "\n") + return "form:" + JSONText.sha256(text) + case .file(let path): + return "file:" + path + case .parts: + return "parts:" + JSONText.sha256(parts.map { "\($0.url)|\($0.start)|\($0.end)" }.joined(separator: "\n")) + } + } + + private static func present(_ value: Any?) -> Any? { + value is NSNull ? nil : value + } + + private static func requireURL(_ s: String, _ field: String) throws { + guard let u = URL(string: s), u.scheme != nil, u.host != nil else { + throw ParseError(message: "'\(field)' is not a valid URL") + } + } + + private static func parseForm(_ raw: Any) throws -> [ParsedEnqueue.FormField] { + guard let fields = raw as? [[String: Any]], !fields.isEmpty else { + throw ParseError(message: "'form' must be a non-empty array") + } + return try fields.enumerated().map { i, f in + guard let name = f["name"] as? String, let contentType = f["contentType"] as? String else { + throw ParseError(message: "'form[\(i)]' needs name and contentType") + } + let string = present(f["string"]) as? String + let path = present(f["path"]) as? String + guard (string == nil) != (path == nil) else { + throw ParseError(message: "'form[\(i)]' must set exactly one of string, path") + } + return ParsedEnqueue.FormField( + name: name, contentType: contentType, string: string, path: path, + fileName: present(f["fileName"]) as? String) + } + } + + private static func parseParts(_ raw: Any) throws -> [QueueEntry.Part] { + guard let parts = raw as? [[String: Any]], !parts.isEmpty else { + throw ParseError(message: "'parts' must be a non-empty array") + } + return try parts.enumerated().map { i, p in + guard let url = p["url"] as? String else { throw ParseError(message: "missing 'parts[\(i)].url'") } + try requireURL(url, "parts[\(i)].url") + guard let range = p["range"] as? [String: Any], + let start = (range["start"] as? NSNumber)?.int64Value, + let end = (range["end"] as? NSNumber)?.int64Value, + start >= 0, start < end else { + throw ParseError(message: "invalid 'parts[\(i)].range'") + } + return QueueEntry.Part(url: url, headers: headers(present(p["headers"])), start: start, + end: end, accepted: false, rejections: 0) + } + } +} diff --git a/ios/EventJournal.swift b/ios/EventJournal.swift index 3011acd8..5fe1dcdb 100644 --- a/ios/EventJournal.swift +++ b/ios/EventJournal.swift @@ -1,134 +1,257 @@ import Foundation -// A terminal upload outcome, persisted before it is emitted to JS. -struct JournaledEvent: Codable { +/// The last response of a request, as the journal keeps it. +struct RawResponseRecord: Codable, Equatable { + var status: Int? + var headers: [String: String]? + var body: String? + var bodyTruncated: Bool + + var bridged: [String: Any] { + var m: [String: Any] = ["bodyTruncated": bodyTruncated] + if let status { m["status"] = status } + if let headers { m["headers"] = headers } + if let body { m["body"] = body } + return m + } +} + +struct OutcomeErrorRecord: Codable, Equatable { + var errorKind: String // http | network | file | expired | unknown + var message: String + var response: RawResponseRecord? + var partIndex: Int? + + var bridged: [String: Any] { + var m: [String: Any] = ["errorKind": errorKind, "message": message] + if let response { m["response"] = response.bridged } + if let partIndex { m["partIndex"] = partIndex } + return m + } +} + +/// A terminal outcome, in the SettledEvent shape. Journaled before it is +/// emitted; deleted when JS acknowledges it. +struct JournaledEvent: Codable, Equatable { + enum Kind: String, Codable { case completed, error, cancelled } + let eventId: String - let id: String // upload id - var type: String // completed | error | cancelled - let timestamp: Double // epoch ms - var responseCode: Int? - var responseBody: String? - var responseBodyTruncated: Bool? - var responseHeaders: [String: String]? - var error: String? - var errorKind: String? // http | network | file | expired | unknown - var cancelReason: String? // user | system - var partIndex: Int? // chunked uploads only: the failing part, when known - - // Bridge-friendly dictionary (nil fields omitted so nothing becomes NSNull). + let id: String + let key: String + let varsJSON: String + let at: Double + var attempts: Int + var requestId: String? + /// 0 when journaled; +1 on every emit and every getUnacknowledgedEvents. + var deliveries: Int + var bytesSent: Int64 + var totalBytes: Int64 + var url: String + var method: String + var partIndex: Int? + /// The entry generation this outcome settled. An ack forgets the entry + /// only when it still matches. + var generation: Int + var kind: Kind + var response: RawResponseRecord? + var error: OutcomeErrorRecord? + var cancelReason: String? + + /// The SettledEvent dictionary. Nil fields are omitted, so nothing becomes + /// NSNull; `vars` is the decoded object (NSNull for JS null). var bridged: [String: Any] { - var m: [String: Any] = ["eventId": eventId, "id": id, "type": type, "timestamp": timestamp] - if let responseCode { m["responseCode"] = responseCode } - if let responseBody { m["responseBody"] = responseBody } - if let responseBodyTruncated { m["responseBodyTruncated"] = responseBodyTruncated } - if let responseHeaders { m["responseHeaders"] = responseHeaders } - if let error { m["error"] = error } - if let errorKind { m["errorKind"] = errorKind } - if let cancelReason { m["cancelReason"] = cancelReason } + var m: [String: Any] = [ + "eventId": eventId, "id": id, "key": key, "vars": JSONText.decode(varsJSON), "at": at, + "attempts": attempts, "deliveries": deliveries, "state": kind.rawValue, + "bytesSent": bytesSent, "totalBytes": totalBytes, "url": url, "method": method, + "kind": kind.rawValue, + ] + if let requestId { m["requestId"] = requestId } if let partIndex { m["partIndex"] = partIndex } + switch kind { + case .completed: + m["response"] = (response ?? RawResponseRecord(bodyTruncated: false)).bridged + case .error: + m["error"] = (error ?? OutcomeErrorRecord(errorKind: "unknown", message: "")).bridged + case .cancelled: + m["cancelReason"] = cancelReason ?? "user" + } return m } } -// Durable record of terminal upload events (completed / error / cancelled). -// Written BEFORE the event is emitted to JS, deleted only when JS acknowledges, -// so an outcome that fires while JS is dead survives to the next launch. -// -// Synchronous (serial queue) — deliberately NOT an actor. The URLSession delegate -// is synchronous and must journal an outcome BEFORE emitting it; an actor would -// force that ordering to become async and racy. -// -// One JSON file per event: Data.write(atomically:) is its own tmp+rename, so a -// crash mid-write can't corrupt other entries, and separate files avoid a shared -// mutable file across processes. -enum EventJournal { - static let maxBodyChars = 64 * 1024 - static let maxEntries = 1000 - - private static let queue = DispatchQueue(label: "ai.openspace.rnbgupload.journal") - - // Char-count cap (a byte-accurate split could cut a surrogate pair). Single - // source of truth so the journaled body and the live-emitted body match. - static func capBody(_ body: String?) -> (String?, Bool) { - guard let body, body.count > maxBodyChars else { return (body, false) } - return (String(body.prefix(maxBodyChars)), true) - } - - // Computed once: creating the dir and re-setting the backup flag on every - // append/read/ack call is wasteful. - private static let dirURL: URL = { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - var dir = base.appendingPathComponent("RNFileUploaderEvents", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - // Transient device-local state; keep it out of iCloud/iTunes backups. - var values = URLResourceValues() - values.isExcludedFromBackup = true - try? dir.setResourceValues(values) - return dir - }() - - static func append(_ event: JournaledEvent) { +/// The v9 journal entry, kept only for the one-time import as legacy rows. +struct JournaledEventV9: Codable, Equatable { + let eventId: String + let id: String + var type: String // completed | error | cancelled + let timestamp: Double + var responseCode: Int? + var errorKind: String? + var cancelReason: String? + var partIndex: Int? +} + +/// Durable record of terminal outcomes: `RNFileUploaderEvents/.json`, +/// one file per event, each written tmp + fsync + rename. Written BEFORE the +/// event is emitted and deleted only when JS acknowledges it, so an outcome +/// that fires while JS is dead survives to the next launch. +/// +/// Synchronous on a serial queue, not an actor: the delegate must journal an +/// outcome before it emits, and an actor would make that ordering async. +final class EventJournal { + static let shared = EventJournal( + root: FileIO.applicationSupport().appendingPathComponent("RNFileUploaderEvents", isDirectory: true)) + + /// The settled response body cap, in UTF-8 bytes. + static let maxBodyBytes = 1_048_576 + /// The attempt event body cap, in characters. + static let maxAttemptBodyChars = 4_096 + /// Runaway guard: past this count the oldest files a row does not name + /// are dropped. + let maxEntries: Int + + let root: URL + private let queue = DispatchQueue(label: "ai.openspace.rnbgupload.journal") + + init(root: URL, maxEntries: Int = 1000) { + self.root = root + self.maxEntries = maxEntries + FileIO.makeLocalDirectory(root) + } + + /// Returns false when the write failed. `keeping` holds the eventIds that + /// rows still name; the prune never deletes them, because an entry whose + /// outcome file is gone cannot be acked. + @discardableResult + func append(_ event: JournaledEvent, keeping: Set = []) -> Bool { queue.sync { - var e = event - let (body, truncated) = capBody(e.responseBody) - if truncated { - e.responseBody = body - e.responseBodyTruncated = true - } - // A journal write must never throw into the caller: the delegate calls this - // right after a completed upload, and a propagated failure could misfire the - // error path. Dropping one entry is the lesser evil. - guard let data = try? JSONEncoder().encode(e) else { return } do { - try data.write(to: dirURL.appendingPathComponent("\(e.eventId).json"), options: .atomic) + try write(event) } catch { NSLog("[RNFileUploader] journal append failed: \(error.localizedDescription)") - return + return false } - pruneToMax() + pruneToMax(keeping: keeping.union([event.eventId])) + return true } } - static func unacknowledged() -> [[String: Any]] { - unacknowledgedEntries().map { $0.bridged } + func load(_ eventId: String) -> JournaledEvent? { + queue.sync { read(url(eventId)) } } - static func unacknowledgedEntries() -> [JournaledEvent] { + /// deliveries += 1 on each event, persisted. Returns the updated events in + /// the order of `ids`; unknown ids are skipped. Every emit and every + /// getUnacknowledgedEvents goes through this. + func markDelivered(_ ids: [String]) -> [JournaledEvent] { queue.sync { - let files = (try? FileManager.default.contentsOfDirectory(at: dirURL, includingPropertiesForKeys: nil)) ?? [] - return files - .filter { $0.pathExtension == "json" } - .compactMap { url -> JournaledEvent? in - guard let data = try? Data(contentsOf: url) else { return nil } - return try? JSONDecoder().decode(JournaledEvent.self, from: data) + ids.compactMap { id in + guard var e = read(url(id)) else { return nil } + e.deliveries += 1 + do { + try write(e) + } catch { + NSLog("[RNFileUploader] journal update failed: \(error.localizedDescription)") } - .sorted { $0.timestamp < $1.timestamp } + return e + } } } - static func ack(_ eventIds: [String]) { + /// Every v10 event, oldest first. v9-shaped files are left for the import. + func unacknowledged() -> [JournaledEvent] { + queue.sync { jsonFiles().compactMap(read).sorted { ($0.at, $0.eventId) < ($1.at, $1.eventId) } } + } + + func unacknowledgedForId(_ id: String) -> [JournaledEvent] { + unacknowledged().filter { $0.id == id } + } + + /// Deletes the files. Unknown ids are ignored, so ack is idempotent. + func ack(_ ids: [String]) { + queue.sync { + for id in ids { try? FileManager.default.removeItem(at: url(id)) } + } + } + + /// cancel() on a settled entry: its unacked outcomes go with it. + func removeForId(_ id: String) { + ack(unacknowledgedForId(id).map(\.eventId)) + } + + /// v9 entries: files that decode as the v9 shape (have `type` and + /// `timestamp`, no `kind`). + func legacyEvents() -> [JournaledEventV9] { queue.sync { - for id in eventIds { - try? FileManager.default.removeItem(at: dirURL.appendingPathComponent("\(id).json")) + jsonFiles().compactMap { file -> JournaledEventV9? in + guard read(file) == nil, let data = try? Data(contentsOf: file) else { return nil } + return try? JSONDecoder().decode(JournaledEventV9.self, from: data) } } } - // Runaway guard: assumes JS drains via ack on each boot, but bounds the - // directory if that loop breaks or hasn't been adopted. Drops the oldest by - // file modification time (no parsing). Caller already holds `queue`. - private static func pruneToMax() { + func removeLegacy(_ eventId: String) { + queue.sync { _ = try? FileManager.default.removeItem(at: url(eventId)) } + } + + /// Decodes a response body capped at `cap` bytes. A cut that splits a + /// UTF-8 sequence backs off to the last whole character. + static func decodeBody(_ data: Data, cap: Int = maxBodyBytes, truncated: Bool = false) + -> (body: String, truncated: Bool) { + guard data.count > cap || truncated else { + return (String(decoding: data, as: UTF8.self), false) + } + var cut = data.prefix(cap) + for _ in 0..<4 { + if let s = String(data: cut, encoding: .utf8) { return (s, true) } + cut = cut.dropLast() + } + return (String(decoding: data.prefix(cap), as: UTF8.self), true) + } + + /// A character cap, for the 4 KB attempt body. + static func capChars(_ s: String?, _ max: Int) -> (String?, Bool) { + guard let s, s.count > max else { return (s, false) } + return (String(s.prefix(max)), true) + } + + // MARK: - Private (callers hold `queue`) + + private func url(_ eventId: String) -> URL { + // eventId is a UUID the library minted, but keep a hostile one inside root. + root.appendingPathComponent(eventId.replacingOccurrences(of: "/", with: "_") + ".json") + } + + private func write(_ event: JournaledEvent) throws { + try FileIO.writeAtomically(try JSONEncoder().encode(event), to: url(event.eventId)) + } + + private func read(_ file: URL) -> JournaledEvent? { + guard let data = try? Data(contentsOf: file) else { return nil } + return try? JSONDecoder().decode(JournaledEvent.self, from: data) + } + + private func jsonFiles() -> [URL] { + ((try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []) + .filter { $0.pathExtension == "json" } + } + + // Drops the oldest unnamed files by modification time, without parsing. + // When rows name more than maxEntries events, the count stays above it. + private func pruneToMax(keeping: Set) { let key: URLResourceKey = .contentModificationDateKey guard let files = try? FileManager.default.contentsOfDirectory( - at: dirURL, includingPropertiesForKeys: [key]) else { return } + at: root, includingPropertiesForKeys: [key]) else { return } let jsons = files.filter { $0.pathExtension == "json" } guard jsons.count > maxEntries else { return } - let sorted = jsons.sorted { + let kept = Set(keeping.map { url($0).lastPathComponent }) + let candidates = jsons.filter { !kept.contains($0.lastPathComponent) }.sorted { let a = (try? $0.resourceValues(forKeys: [key]).contentModificationDate) ?? .distantPast let b = (try? $1.resourceValues(forKeys: [key]).contentModificationDate) ?? .distantPast return a < b } - for f in sorted.prefix(jsons.count - maxEntries) { + for f in candidates.prefix(jsons.count - maxEntries) { try? FileManager.default.removeItem(at: f) } } diff --git a/ios/Events.swift b/ios/Events.swift new file mode 100644 index 00000000..66afe57f --- /dev/null +++ b/ios/Events.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Builds the live `attempt` event: one HTTP attempt before the library +/// interprets it for retry. `outcome` follows the v8 taxonomy: 'completed' +/// only for a 2xx or a matching accept rule; any other response is 'error' +/// with errorKind 'http'; a transport failure is 'error' with its kind; a +/// cancel the library did not ask for is 'cancelled' with 'system'. +enum AttemptEvent { + struct Input { + var id: String + var key: String + var requestId: String + var attempt: Int + var url: String + var method: String + var partIndex: Int? + var statusCode: Int? + var headers: [String: String] + var body: String? + var error: NSError? + var accepted: Bool + var systemCancel: Bool + var at: Double + } + + static func build(_ i: Input) -> [String: Any] { + var m: [String: Any] = [ + "id": i.id, "key": i.key, "requestId": i.requestId, "attempt": i.attempt, + "url": i.url, "method": i.method, "at": i.at, + ] + if let partIndex = i.partIndex { m["partIndex"] = partIndex } + if let code = i.statusCode, i.error == nil { + m["httpCode"] = code + m["responseHeaders"] = i.headers + let (body, truncated) = EventJournal.capChars(i.body ?? "", EventJournal.maxAttemptBodyChars) + m["responseBody"] = body ?? "" + m["responseBodyTruncated"] = truncated + } + if i.systemCancel { + m["outcome"] = "cancelled" + m["cancelReason"] = "system" + } else if let error = i.error { + m["outcome"] = "error" + m["errorKind"] = RetryClassifier.errorKind(for: error) + m["errorMessage"] = error.localizedDescription + } else if i.accepted { + m["outcome"] = "completed" + } else { + m["outcome"] = "error" + m["errorKind"] = "http" + m["errorMessage"] = "HTTP \(i.statusCode ?? 0)" + } + return m + } +} diff --git a/ios/FileIO.swift b/ios/FileIO.swift new file mode 100644 index 00000000..e41690ec --- /dev/null +++ b/ios/FileIO.swift @@ -0,0 +1,65 @@ +import Foundation + +// Small file helpers shared by the store, the journal, the task map and body +// staging. Every durable write is tmp + fsync + rename, so a reader never sees +// a half-written file: after a crash there is either the old file or the new +// one, plus at most a stray `.tmp` that the next write replaces. +enum FileIO { + struct IOError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + static func tmpURL(for url: URL) -> URL { + url.deletingLastPathComponent().appendingPathComponent(url.lastPathComponent + ".tmp") + } + + /// Writes `data` to `url.tmp`, flushes it to disk, then renames it onto `url`. + static func writeAtomically(_ data: Data, to url: URL) throws { + let tmp = tmpURL(for: url) + try writeSynced(data, to: tmp) + try rename(tmp, onto: url) + } + + /// Writes and fsyncs a file in place. Callers rename it afterwards. + static func writeSynced(_ data: Data, to url: URL) throws { + let fm = FileManager.default + try? fm.removeItem(at: url) + guard fm.createFile(atPath: url.path, contents: nil) else { + throw IOError(message: "cannot create \(url.lastPathComponent)") + } + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.write(contentsOf: data) + try handle.synchronize() + } + + /// POSIX rename: atomic, and it replaces an existing destination. + static func rename(_ from: URL, onto to: URL) throws { + guard Foundation.rename(from.path, to.path) == 0 else { + throw IOError(message: "rename \(from.lastPathComponent) -> \(to.lastPathComponent) failed: errno \(errno)") + } + } + + static func size(_ url: URL) -> Int64? { + ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? NSNumber)?.int64Value + } + + static func exists(_ url: URL) -> Bool { + FileManager.default.fileExists(atPath: url.path) + } + + /// Creates a directory that holds device-local upload state and keeps it + /// out of iCloud and iTunes backups. + static func makeLocalDirectory(_ url: URL) { + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + var dir = url + var values = URLResourceValues() + values.isExcludedFromBackup = true + try? dir.setResourceValues(values) + } + + static func applicationSupport() -> URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + } +} diff --git a/ios/JSONText.swift b/ios/JSONText.swift new file mode 100644 index 00000000..c9ed7f7e --- /dev/null +++ b/ios/JSONText.swift @@ -0,0 +1,32 @@ +import CryptoKit +import Foundation + +// JSON text for values that come over the bridge (NSDictionary, NSArray, +// NSString, NSNumber, NSNull). `vars`, the descriptor and a `data` body are +// persisted as text and decoded again when a row or an event is built. +enum JSONText { + /// Canonical JSON text: keys sorted, fragments allowed. The same value with + /// its keys in another order gives the same text. Nil when the value is not + /// JSON (a NaN, a non-string key). The check runs first because + /// JSONSerialization raises an Obj-C exception, not a Swift error, on an + /// invalid object. + static func encode(_ value: Any?) -> String? { + let v: Any = value ?? NSNull() + guard JSONSerialization.isValidJSONObject([v]), + let data = try? JSONSerialization.data( + withJSONObject: v, options: [.sortedKeys, .fragmentsAllowed, .withoutEscapingSlashes]) + else { return nil } + return String(data: data, encoding: .utf8) + } + + /// The bridged object for stored text. NSNull (JS null) when the text is + /// "null" or does not parse. + static func decode(_ text: String) -> Any { + (try? JSONSerialization.jsonObject(with: Data(text.utf8), options: [.fragmentsAllowed])) + ?? NSNull() + } + + static func sha256(_ text: String) -> String { + SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/ios/LegacyImport.swift b/ios/LegacyImport.swift new file mode 100644 index 00000000..9a290e18 --- /dev/null +++ b/ios/LegacyImport.swift @@ -0,0 +1,49 @@ +import Foundation + +/// The pure half of the first-launch v9 import: which legacy rows to create. +/// +/// Each v9 journal entry becomes a read-only settled row with key "legacy" +/// and id = the v9 upload id. Nothing is delivered: Diana reads the rows, +/// marks those transfers terminal, and cancels them. When the id also has a +/// v9 chunked manifest, the row reports its bytes, and the blob stays in the +/// directory until cancel(id). +/// +/// A manifest with no journal entry makes no row. It stays dormant until a +/// same-id enqueue adopts it (a legacy row would be cancelled by Diana, and +/// the capture re-send needs the bytes). +enum LegacyImport { + static let key = "legacy" + static let fingerprint = "legacy" + + static func plan(events: [JournaledEventV9], manifests: [String: ChunkedManifestV9]) -> [QueueEntry] { + // One row per id: the latest v9 outcome wins. + var latest: [String: JournaledEventV9] = [:] + for e in events where latest[e.id].map({ $0.timestamp <= e.timestamp }) ?? true { + latest[e.id] = e + } + return latest.values.sorted { ($0.timestamp, $0.id) < ($1.timestamp, $1.id) }.compactMap { e in + guard let state = state(e.type) else { return nil } + let manifest = manifests[e.id] + return QueueEntry( + id: e.id, key: key, varsJSON: "null", descriptorJSON: "{}", url: nil, method: "POST", + accept: [], retry: nil, bodyKind: .none, + bodyPath: manifest == nil ? nil : ChunkedManifestV9.blobName, + bodyContentType: nil, forceContentType: false, bodyFingerprint: fingerprint, parts: [], + incarnation: manifest?.incarnation ?? UUID().uuidString, headers: [:], headerGeneration: 0, + state: state, authParked: false, generation: 1, attempts: 0, + bytesSent: manifest?.acceptedBytes ?? 0, totalBytes: manifest?.totalBytes ?? 0, + expiresAt: manifest?.expiresAt ?? e.timestamp, nextAttemptAt: nil, settledEventId: nil, + lastRequestId: nil, lastUrl: nil, lastPartIndex: e.partIndex, legacy: true, + createdAt: e.timestamp, updatedAt: e.timestamp) + } + } + + static func state(_ v9Type: String) -> QueueEntry.State? { + switch v9Type { + case "completed": return .completed + case "error": return .error + case "cancelled": return .cancelled + default: return nil + } + } +} diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 00000000..8a78b51a --- /dev/null +++ b/ios/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version:5.9 +// Host-side unit tests for the pure half of the iOS module: `cd ios && swift test`. +// The CocoaPods build ignores this file (see exclude_files in the podspec). +// RNBackgroundUpload.swift and the .mm import React and UIKit, so they are +// not part of this package. +import PackageDescription + +let package = Package( + name: "RNBGUCore", + platforms: [.macOS(.v12)], + targets: [ + .target( + name: "RNBGUCore", + path: ".", + exclude: ["Tests", "RNBackgroundUpload.swift", "RNFileUploader.h", "RNFileUploader.mm", ".gitignore"], + sources: [ + "BodyStaging.swift", + "ChunkedCoordinator.swift", + "ChunkedEngine.swift", + "ChunkedManifestV9.swift", + "EnqueueParser.swift", + "EventJournal.swift", + "Events.swift", + "FileIO.swift", + "JSONText.swift", + "LegacyImport.swift", + "ProgressThrottle.swift", + "QueueCoordinator.swift", + "QueueCoordinator+Enqueue.swift", + "QueueCoordinator+Outcomes.swift", + "QueueCoordinator+Reconcile.swift", + "QueueCoordinator+Simple.swift", + "QueueEntry.swift", + "QueueSettings.swift", + "QueueStore.swift", + "RequestIndex.swift", + "RetryClassifier.swift", + "TaskMap.swift", + "Transport.swift", + "UploadOutcome.swift", + ]), + .testTarget(name: "RNBGUCoreTests", dependencies: ["RNBGUCore"], path: "Tests"), + ] +) diff --git a/ios/ProgressThrottle.swift b/ios/ProgressThrottle.swift new file mode 100644 index 00000000..b024ba8a --- /dev/null +++ b/ios/ProgressThrottle.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Per-id progress throttle: one event per second while the app is in the +/// foreground, one per 10 minutes in the background. settle() forces a +/// trailing edge past it. It runs on the URLSession delegate queue, before +/// the hop onto the coordinator queue, so a chatty task never floods that +/// queue. Lock-guarded. +final class ProgressThrottle { + static let foregroundMs = 1_000.0 + static let backgroundMs = 600_000.0 + + private let lock = NSLock() + private var last: [String: Double] = [:] + private var foreground = false + + /// Set from the UIApplication notifications. Reading applicationState + /// needs the main thread; the delegate queue is not it. + var isForeground: Bool { + get { lock.lock(); defer { lock.unlock() }; return foreground } + set { lock.lock(); foreground = newValue; lock.unlock() } + } + + /// true when an event for `id` may go now; records it. + func shouldEmit(_ id: String, now: Double) -> Bool { + lock.lock() + defer { lock.unlock() } + let interval = foreground ? Self.foregroundMs : Self.backgroundMs + if let t = last[id], now - t < interval { return false } + last[id] = now + return true + } + + /// The next event for `id` passes. Called at issue and at settle. + func reset(_ id: String) { + lock.lock() + last[id] = nil + lock.unlock() + } +} diff --git a/ios/QueueCoordinator+Enqueue.swift b/ios/QueueCoordinator+Enqueue.swift new file mode 100644 index 00000000..7d0be80d --- /dev/null +++ b/ios/QueueCoordinator+Enqueue.swift @@ -0,0 +1,181 @@ +import Foundation + +// enqueue() and the same-id rules (spec 5.4). Runs on the coordinator queue. +// Every reject path leaves the previous entry and its body as they were: +// a new body is staged under a fresh name, and the old one is deleted only +// after the new entry.json landed. +extension QueueCoordinator { + + /// Returns the id, and whether to issue it after the resolve. + func enqueueLocked(_ raw: [String: Any]) throws -> (id: String, issue: Bool) { + let p: ParsedEnqueue + do { + p = try EnqueueParser.parse(raw) + } catch { + throw EnqueueError.storage("enqueue: \(error.localizedDescription)") + } + if let existing = index.entry(p.id) { + if existing.legacy { + // A legacy row over v9 chunked bytes: create() adopts the manifest + // (same parts resume their accepted parts) or keeps the blob. It + // writes entry.json over the legacy row. + if case .parts = p.body, store.loadV9Manifest(p.id) != nil { return try create(p) } + return try enqueueExisting(existing, p) + } + // A completed entry whose ack landed but whose forget did not (a crash + // between the two) is gone for JS. Start over. + if existing.state == .completed, settledEvent(existing) == nil { + forget(existing.id, dropEvents: true) + } else { + return try enqueueExisting(existing, p) + } + } + return try create(p) + } + + /// Rule 2: no entry has the id. + private func create(_ p: ParsedEnqueue) throws -> (id: String, issue: Bool) { + let dir = store.dir(p.id) + var fallback = store.adoptableBlob(p.id) + var adopted: ChunkedManifestV9? + if case .parts = p.body, let manifest = store.loadV9Manifest(p.id), + FileIO.exists(store.fileURL(p.id, ChunkedManifestV9.blobName)) { + fallback = ChunkedManifestV9.blobName + // A dormant v9 upload with the same parts resumes: its blob and its + // accepted parts are the body. The source path is ignored, as in v9. + if manifest.sameParts(as: p.parts) { adopted = manifest } + } + + let staged: StagedBody + if adopted != nil { + let size = FileIO.size(store.fileURL(p.id, ChunkedManifestV9.blobName)) ?? 0 + try mapStaging { try BodyStaging.requireTiling(p.parts, size: size) } + staged = StagedBody(kind: .parts, relativePath: ChunkedManifestV9.blobName, contentType: nil, + forceContentType: false, totalBytes: size, adopted: true) + } else { + staged = try mapStaging { + try BodyStaging.stage(p.body, parts: p.parts, into: dir, fallbackBlob: fallback) + } + } + + var e = QueueEntry.created(from: p, staged: staged, headerGeneration: settings.headerGeneration, + paused: settings.paused, now: now()) + if let manifest = adopted { + e.incarnation = manifest.incarnation + for i in e.parts.indices { e.parts[i].accepted = manifest.parts[i].accepted } + e.bytesSent = e.acceptedBytes + } + try saveOrDiscard(e, staged: staged) + store.removeV9Manifest(p.id) + store.sweep(e) + publish(e) + armExpiry(e) + return (p.id, !settings.paused) + } + + private func enqueueExisting(_ existing: QueueEntry, _ p: ParsedEnqueue) throws + -> (id: String, issue: Bool) { + let paused = settings.paused + if existing.bodyFingerprint == p.fingerprint && !existing.legacy { + switch existing.state { + case .completed: + // Rule 7: re-emit the journaled outcome. Do not run again. + if let eventId = existing.settledEventId, let event = redeliver(eventId) { + sink?.emitSettled(event.bridged) + } + return (p.id, false) + + case .error, .cancelled: + // Rule 3 on a settled entry, and rule 6 for a cancelled one: reopen + // under a fresh generation. The old outcome's ack no longer forgets it. + var n = existing.resumed(with: p, resetBudget: true, now: now()) + n.generation += 1 + n.state = paused ? .paused : .queued + n.settledEventId = nil + n.authParked = false + n.nextAttemptAt = nil + n.bytesSent = n.isChunked ? n.acceptedBytes : 0 + try saveOrThrow(n) + publish(n) + armExpiry(n) + return (p.id, !paused) + + case .awaitingAuth: + // Fresh headers came with the call: leave the parking spot. + var n = existing.resumed(with: p, resetBudget: true, now: now()) + n.authParked = false + n.state = paused ? .paused : .queued + try saveOrThrow(n) + publish(n) + armExpiry(n) + return (p.id, !paused) + + case .queued, .running, .paused: + // The in-flight task keeps its request. A retry waiting in the daemon + // picks up the new headers in willBeginDelayedRequest. + let n = existing.resumed(with: p, resetBudget: false, now: now()) + try saveOrThrow(n) + publish(n) + armExpiry(n) + return (p.id, false) + } + } + + // Rules 4 and 5: a different body. + guard existing.state != .running else { throw EnqueueError.running(p.id) } + // A delayed retry task may wait in the daemon. Mark it superseded first, + // so its NSURLErrorCancelled is dropped. + cancelTasks(existing.id, purpose: .superseded) + chunked.stop(existing.id) + // A chunked replace may keep the current blob when the caller already + // deleted its source (the part-404 recreate over moved bytes). + var fallback: String? + if let path = existing.bodyPath, existing.isChunked || existing.legacy, + path == ChunkedManifestV9.blobName || path.hasPrefix(BodyStaging.blobPrefix) { + fallback = path + } + let staged = try mapStaging { + try BodyStaging.stage(p.body, parts: p.parts, into: store.dir(p.id), fallbackBlob: fallback) + } + var n = existing.replaced(with: p, staged: staged, now: now()) + n.headerGeneration = settings.headerGeneration + n.state = paused ? .paused : .queued + try saveOrDiscard(n, staged: staged) + store.removeV9Manifest(p.id) + store.sweep(n) // deletes the old body + publish(n) + armExpiry(n) + return (p.id, !paused) + } + + // MARK: - Helpers + + private func saveOrThrow(_ e: QueueEntry) throws { + do { + try store.save(e) + } catch { + throw EnqueueError.storage("enqueue: cannot save '\(e.id)': \(error.localizedDescription)") + } + } + + /// A failed save deletes the body staged for it, unless that body is an + /// adopted file the store already owned. + private func saveOrDiscard(_ e: QueueEntry, staged: StagedBody) throws { + do { + try store.save(e) + } catch { + if !staged.adopted { try? FileManager.default.removeItem(at: store.fileURL(e.id, staged.relativePath)) } + throw EnqueueError.storage("enqueue: cannot save '\(e.id)': \(error.localizedDescription)") + } + } + + private func mapStaging(_ body: () throws -> T) throws -> T { + do { + return try body() + } catch StagingError.fileMissing(let path) { + throw EnqueueError.fileMissing(path) + } catch StagingError.invalid(let message), StagingError.io(let message) { + throw EnqueueError.storage("enqueue: \(message)") + } + } +} diff --git a/ios/QueueCoordinator+Outcomes.swift b/ios/QueueCoordinator+Outcomes.swift new file mode 100644 index 00000000..777d290c --- /dev/null +++ b/ios/QueueCoordinator+Outcomes.swift @@ -0,0 +1,165 @@ +import Foundation + +// Terminal outcomes: settle, ack, forget, and the repair paths for a settled +// row whose journal file is missing. Runs on the coordinator queue. +extension QueueCoordinator { + /// First wait before a failed journal write is tried again. It doubles up + /// to `journalRetryMaxMs`. + static let journalRetryMs = 5_000 + static let journalRetryMaxMs = 600_000 + + /// The one terminal path. Cancels the entry's remaining tasks, emits the + /// trailing progress, journals the outcome, saves the settled row, emits + /// `state`, then emits `settled` with deliveries 1. + /// + /// A failed journal write still settles and emits: the request already + /// ran, so a retry would send it twice, and a user cancel must not run + /// again. The event stays in memory, a timer retries the write, and ack + /// finds the row by its settledEventId. + func settle(_ id: String, _ outcome: Outcome) { + guard var e = index.entry(id) else { return } + cancelTasks(id, purpose: .superseded) + chunked.stop(id) + switch outcome { + case .completed: e.bytesSent = e.totalBytes + default: e.bytesSent = e.isChunked ? e.acceptedBytes : (lastSent[id] ?? e.bytesSent) + } + emitProgress(id, sent: e.bytesSent, total: e.totalBytes) + + var event = JournaledEvent( + eventId: UUID().uuidString, id: id, key: e.key, varsJSON: e.varsJSON, at: now(), + attempts: e.attempts, requestId: e.lastRequestId, deliveries: 0, bytesSent: e.bytesSent, + totalBytes: e.totalBytes, url: e.targetURL, method: e.method, partIndex: e.lastPartIndex, + generation: e.generation, kind: .completed) + switch outcome { + case .completed(let response): + event.kind = .completed + event.response = response + e.state = .completed + case .error(let error): + event.kind = .error + event.error = error + event.partIndex = error.partIndex ?? e.lastPartIndex + e.state = .error + case .cancelled(let reason): + event.kind = .cancelled + event.cancelReason = reason + event.partIndex = nil + e.state = .cancelled + } + let journaled = journal.append(event, keeping: referencedEventIds().union([event.eventId])) + e.settledEventId = event.eventId + e.nextAttemptAt = nil + e.authParked = false + commit(e) + var delivered: JournaledEvent + if journaled { + delivered = journal.markDelivered([event.eventId]).first ?? event + } else { + event.deliveries = 1 + pendingJournal[event.eventId] = event + retryJournal(event.eventId, delayMs: Self.journalRetryMs) + delivered = event + } + delivered.deliveries = max(delivered.deliveries, 1) + sink?.emitSettled(delivered.bridged) + disarmExpiry(id) + throttle.reset(id) + lastSent[id] = nil + } + + /// Deletes the row and the bytes. `dropEvents` also deletes the id's + /// unacked outcomes (cancel on a settled entry). + func forget(_ id: String, dropEvents: Bool) { + cancelTasks(id, purpose: .superseded) + chunked.stop(id) + store.remove(id) + index.remove(id) + if dropEvents { + journal.removeForId(id) + pendingJournal = pendingJournal.filter { $0.value.id != id } + } + disarmExpiry(id) + throttle.reset(id) + lastSent[id] = nil + } + + /// One ack. The event comes from the journal, or from memory when its + /// write failed. When neither has it (pruned, or a crash after the file + /// went), the row that names the eventId still settles. + func ackLocked(_ eventId: String) { + let event = journal.load(eventId) ?? pendingJournal[eventId] + pendingJournal[eventId] = nil + journal.ack([eventId]) + let owner = event.flatMap { index.entry($0.id) } + ?? index.entries().first { $0.settledEventId == eventId } + guard let e = owner, e.isSettled, !e.legacy, e.state != .error else { return } + if let event { + guard event.kind != .error, event.generation == e.generation else { return } + } else { + guard e.settledEventId == eventId else { return } + } + forget(e.id, dropEvents: false) + } + + /// Every unacked outcome, oldest first, the in-memory ones included. Each + /// return counts as a delivery. + func unacknowledgedLocked() -> [JournaledEvent] { + let stored = journal.markDelivered(journal.unacknowledged().map(\.eventId)) + for (eventId, var event) in pendingJournal { + event.deliveries += 1 + pendingJournal[eventId] = event + } + return (stored + pendingJournal.values).sorted { ($0.at, $0.eventId) < ($1.at, $1.eventId) } + } + + /// A re-emit (same-id rule 7). Counts a delivery. + func redeliver(_ eventId: String) -> JournaledEvent? { + if let event = journal.markDelivered([eventId]).first { return event } + guard var event = pendingJournal[eventId] else { return nil } + event.deliveries += 1 + pendingJournal[eventId] = event + return event + } + + /// The settled outcome of `e`, from the journal or from memory. + func settledEvent(_ e: QueueEntry) -> JournaledEvent? { + e.settledEventId.flatMap { journal.load($0) ?? pendingJournal[$0] } + } + + /// Relaunch repair: a completed or cancelled row whose outcome file is + /// gone can never be acked. That happens after a crash between the ack's + /// delete and the forget, or when the journal write failed and the process + /// died. Forget the row and its bytes. An error row stays, as it does + /// after an ack, until cancel() or a same-id enqueue. + func sweepOrphanedOutcomes() { + for e in index.entries() where e.isSettled && !e.legacy && e.state != .error { + guard let eventId = e.settledEventId, pendingJournal[eventId] == nil, + journal.load(eventId) == nil else { continue } + forget(e.id, dropEvents: false) + } + } + + /// Every eventId a row still names. The journal never prunes these. + func referencedEventIds() -> Set { + Set(index.entries().compactMap(\.settledEventId)) + } + + /// Tries a failed journal write again, with doubling waits, while the + /// entry still names the event. An outcome the entry no longer names (an + /// ack, a cancel, a reopen) is dropped from memory. + private func retryJournal(_ eventId: String, delayMs: Int) { + schedule(delayMs) { [weak self] in + guard let self, let event = self.pendingJournal[eventId] else { return } + guard self.index.entry(event.id)?.settledEventId == eventId else { + self.pendingJournal[eventId] = nil + return + } + if self.journal.append(event, keeping: self.referencedEventIds()) { + self.pendingJournal[eventId] = nil + } else { + self.retryJournal(eventId, delayMs: min(delayMs * 2, Self.journalRetryMaxMs)) + } + } + } +} diff --git a/ios/QueueCoordinator+Reconcile.swift b/ios/QueueCoordinator+Reconcile.swift new file mode 100644 index 00000000..15bda599 --- /dev/null +++ b/ios/QueueCoordinator+Reconcile.swift @@ -0,0 +1,137 @@ +import Foundation + +// Relaunch: matching the daemon's surviving tasks to the stored entries, and +// the one-time v9 import. Runs at `shared` init: an app launch, a JS reload, +// or the AppDelegate background wake. +extension QueueCoordinator { + /// How long a running entry with no live task waits for a completion the + /// daemon may still replay before it re-issues. + static let graceMs = 10_000 + + /// `completion` runs on the queue once every entry has its tasks again. + /// The caller holds the background completion handlers until then, so the + /// system cannot suspend the app before the refill enqueues new tasks. + func reconcileAll(completion: @escaping () -> Void) { + queue.async { + self.transport.allTasks { tasks in + self.queue.async { + self.reconcile(tasks) + completion() + } + } + } + } + + func reconcile(_ tasks: [UploadTask]) { + ready = true + sweepOrphanedOutcomes() + var simpleLive: Set = [] + var partTasks: [String: [ChunkedCoordinator.LiveTask]] = [:] + let liveKeys = Set(tasks.filter(\.isLive).map(\.key)) + + for task in tasks where task.isLive { + let owner = TaskOwner.resolve(description: task.taskDescription, + meta: taskMap.meta(forKey: task.key)) + let entry = owner.flatMap { index.entry($0.id) } + let runnable = entry.map { !$0.legacy && ($0.state == .queued || $0.state == .running) } ?? false + switch owner { + case .part(let id, let part, let incarnation)? where runnable && entry?.isChunked == true: + partTasks[id, default: []].append(.init(task: task, part: part, incarnation: incarnation)) + case .request(let id, let generation, let attempt)? + where runnable && entry?.isChunked == false && entry?.generation == generation + && entry?.attempts == attempt && !simpleLive.contains(id): + simpleLive.insert(id) + liveTasks[task.key] = (id, task) + default: + // No v10 owner (a v9 task), an older generation or attempt, a + // duplicate, or an entry that must have no task. Drop its callback. + taskMap.setPurpose(.superseded, forKey: task.key, id: owner?.id ?? "") + task.cancel() + } + } + // A key whose id has no entry belongs to nothing. A live task keeps its + // key until its cancel callback, which reads the purpose. + taskMap.removeAll { key, meta in !liveKeys.contains(key) && index.entry(meta.id) == nil } + + for e in index.entries() where e.isLive && !e.legacy { + armExpiry(e) + guard e.state == .queued || e.state == .running else { continue } + if e.isChunked { + chunked.reconcile(e.id, tasks: partTasks[e.id] ?? []) + } else if !simpleLive.contains(e.id) { + withoutTask(e) + } + } + } + + /// A queued or running entry with no live task. Either the daemon finished + /// the task while we were dead and will replay its completion now, or the + /// task never reached the daemon (a crash between the save and resume), or + /// it was lost. The TaskMap tells them apart: its key goes when a + /// completion is handled. + private func withoutTask(_ e: QueueEntry) { + let pending = !taskMap.keys(where: { + $0.id == e.id && $0.generation == e.generation && $0.attempt == e.attempts + }).isEmpty + guard pending else { + // No task was ever made for a waiting attempt: it never ran, so it + // keeps its ordinal and request id. + reissue(e, advanceAttempt: false) + return + } + guard !graceChecks.contains(e.id) else { return } + graceChecks.insert(e.id) + schedule(Self.graceMs) { [weak self] in + guard let self else { return } + self.graceChecks.remove(e.id) + guard let current = self.index.entry(e.id), current.state == e.state, + current.generation == e.generation, current.attempts == e.attempts, + !self.liveTasks.values.contains(where: { $0.id == e.id }) else { return } + // No replay came: the task is lost. Its key would send every later + // launch through this wait again. + self.taskMap.removeAll { _, m in + m.id == e.id && m.generation == e.generation && m.attempt == e.attempts + } + // It may have run, so the next attempt gets a new ordinal: a late + // replay of this one is then dropped as stale. + self.reissue(current, advanceAttempt: true) + } + } + + /// Issues again, keeping what is left of a wait. + private func reissue(_ e: QueueEntry, advanceAttempt: Bool) { + var n = e + n.state = .queued + index.upsert(n) + let remaining = e.nextAttemptAt.map { Int($0 - now()) }.flatMap { $0 > 0 ? $0 : nil } + issue(n.id, delayMs: remaining, advanceAttempt: advanceAttempt) + } + + /// First v10 launch, from init: disk only, before any session exists. + /// Each v9 journal entry becomes a legacy row; nothing is emitted. v9 + /// manifests with no journal entry stay dormant. v9 task metadata is + /// dropped; reconcile cancels the v9 tasks. The marker is written last, so + /// a crash mid-import runs it again. + func importLegacyIfNeeded() { + guard !store.isImported() else { return } + let events = journal.legacyEvents() + let manifests = store.allV9Manifests() + for e in LegacyImport.plan(events: events, manifests: manifests) { + if let existing = index.entry(e.id), !existing.legacy { continue } + do { + try store.save(e) + } catch { + NSLog("[RNFileUploader] v9 import: cannot save \(e.id): \(error.localizedDescription)") + return + } + index.upsert(e) + } + for event in events { journal.removeLegacy(event.eventId) } + taskMap.removeAll { _, meta in meta.generation == nil } + do { + try store.markImported() + } catch { + NSLog("[RNFileUploader] v9 import: cannot write the marker: \(error.localizedDescription)") + } + } +} diff --git a/ios/QueueCoordinator+Simple.swift b/ios/QueueCoordinator+Simple.swift new file mode 100644 index 00000000..2ebc7ea2 --- /dev/null +++ b/ios/QueueCoordinator+Simple.swift @@ -0,0 +1,256 @@ +import Foundation + +// Simple (one-body) entries: one task per attempt, and the URLSession +// delegate hooks. A chunked entry routes to ChunkedCoordinator from each hook. +extension QueueCoordinator { + + /// Starts the next attempt of a queued entry. With `delayMs` the task is + /// created now with earliestBeginDate, so nsurlsessiond starts it on time + /// whether the app lives or not; the row stays queued with nextAttemptAt. + /// Write-ahead: the attempt ordinal and request id are saved before the + /// task exists. A failed save creates no task and tries again later. + /// + /// `advanceAttempt: false` re-creates a waiting attempt that never ran (a + /// session move, or a delayed task that never reached the daemon). It keeps + /// the ordinal and request id. It applies only while the entry holds such + /// an attempt (nextAttemptAt set); otherwise a new attempt is minted. + func issue(_ id: String, delayMs: Int? = nil, advanceAttempt: Bool = true) { + guard var e = index.entry(id), !e.legacy, e.state == .queued, !settings.paused else { return } + let t = now() + if t >= e.expiresAt { + settle(id, .expired) + return + } + guard ready else { + // Reconcile has not matched the daemon's tasks yet. Keep the queued + // state (and the wait) on disk; reconcile issues it. + if let delayMs { e.nextAttemptAt = t + Double(delayMs) } + commit(e) + return + } + if e.isChunked { + chunked.start(id) + return + } + guard let body = store.bodyURL(e), FileIO.exists(body) else { + settle(id, .fileError("the staged body is missing")) + return + } + guard let urlString = e.url, let url = URL(string: urlString) else { + settle(id, .error(OutcomeErrorRecord(errorKind: "unknown", message: "the url is not valid"))) + return + } + + let before = e + let reuse = !advanceAttempt && e.nextAttemptAt != nil && e.attempts > 0 && e.lastRequestId != nil + let requestId = reuse ? e.lastRequestId! : UUID().uuidString + if !reuse { e.attempts += 1 } + e.lastRequestId = requestId + e.lastUrl = urlString + e.lastPartIndex = nil + let beginAt = delayMs.map { t + Double($0) } + if let beginAt { + e.nextAttemptAt = beginAt + } else { + e.state = .running + e.nextAttemptAt = nil + } + guard commitAhead(e) else { + deferIssue(before, delayMs: delayMs) + return + } + + let meta = TaskMap.Meta( + id: id, accept: e.accept, attempt: e.attempts, requestId: requestId, + headerGeneration: settings.headerGeneration, generation: e.generation, purpose: .attempt) + let task = transport.upload( + buildRequest(e, url: url, requestId: requestId), fromFile: body, wifiOnly: settings.wifiOnly, + description: ChunkedEngine.taskDescription(id: id, attempt: e.attempts, generation: e.generation), + beginAt: beginAt.map { Date(timeIntervalSince1970: $0 / 1000) }, + beforeResume: { key in self.taskMap.set(meta, forKey: key) }) + liveTasks[task.key] = (id, task) + throttle.reset(id) + } + + /// The attempt could not be written ahead (disk full, protected data). + /// The entry stays as the disk has it, queued in memory, with no task. + /// Issue again after the wait it asked for, or a backoff, whichever is + /// longer, unless something else moved the entry first. + private func deferIssue(_ e: QueueEntry, delayMs: Int?) { + let backoff = RetryClassifier.backoffMs(attempt: max(e.attempts, 1), policy: policy(e), random: random) + let generation = e.generation + let attempts = e.attempts + schedule(max(delayMs ?? 0, backoff)) { [weak self] in + guard let self, let current = self.index.entry(e.id), current.generation == generation, + current.attempts == attempts else { return } + self.issue(e.id) + } + } + + // MARK: - Delegate hooks + + /// didCompleteWithError. Synchronous, so the journal write for a terminal + /// lands before the delegate callback returns (a background wake may + /// suspend the app right after). + func taskCompleted(_ c: TaskCompletion) { + queue.sync { taskCompletedLocked(c) } + } + + func taskCompletedLocked(_ c: TaskCompletion) { + let meta = taskMap.meta(forKey: c.key) + taskMap.removeKey(c.key) + liveTasks[c.key] = nil + guard let owner = TaskOwner.resolve(description: c.description, meta: meta) else { return } + if case .part(let id, let part, let incarnation) = owner { + chunked.partCompleted(id: id, part: part, incarnation: incarnation, key: c.key, meta: meta, + completion: c) + return + } + // Only the current attempt of the current generation may drive the entry. + guard case .request(let id, let generation, let attempt) = owner, + var e = index.entry(id), !e.legacy, !e.isChunked, + e.generation == generation, e.attempts == attempt else { return } + + let cancelled = RetryClassifier.isCancellation(c.error) + if cancelled, meta?.purpose == .pause || meta?.purpose == .superseded { return } + let accepted = c.error == nil + && c.statusCode.map { UploadOutcome.isAccepted($0, body: c.body, accept: e.accept) } == true + // A replaced task that finished before its cancel took effect. Its + // replacement drives the entry, unless this one landed. + if meta?.purpose == .superseded && !accepted { return } + emitAttempt(e, requestId: meta?.requestId ?? e.lastRequestId, attempt: attempt, completion: c, + partIndex: nil, accepted: accepted, systemCancel: cancelled) + + guard e.state == .running || e.state == .queued else { + // A pause raced this completion. An accepted response did land, so + // settle it: a resume must not send it twice. + if accepted && e.state == .paused { settle(id, .completed(response(c))) } + return + } + // A cancel the library did not ask for (the system, a force-quit) is a + // transient failure, never a 'cancelled' outcome. + if cancelled { + scheduleRetry(e) + return + } + let fileExists = store.bodyURL(e).map(FileIO.exists) ?? false + let verdict = RetryClassifier.classify(RetryClassifier.Input( + statusCode: c.statusCode, body: c.body, error: c.error, accept: e.accept, policy: policy(e), + isChunkedPart: false, fileExists: fileExists, now: now(), expiresAt: e.expiresAt)) + switch verdict { + case .accepted: + settle(id, .completed(response(c))) + case .transient, .fileUnreadable: + scheduleRetry(e) + case .auth: + if let g = meta?.headerGeneration, g < settings.headerGeneration { + // Issued under older headers. updateHeaders() already merged the new + // ones into the entry, so re-issue at once. + e.state = .queued + index.upsert(e) + issue(id) + } else { + park(e) + } + case .terminalHttp: + settle(id, .error(OutcomeErrorRecord( + errorKind: "http", message: "HTTP \(c.statusCode ?? 0)", response: response(c)))) + case .fileMissing: + settle(id, .fileError("the staged body is missing")) + case .expired: + settle(id, .expired) + } + } + + /// willBeginDelayedRequest: a delayed retry is about to start while the + /// app is alive. Returns the request rebuilt from the entry's current + /// headers (an updateHeaders during the backoff reaches the retry), or nil + /// to cancel a task whose entry moved on. + func taskWillBegin(key: String, description: String?) -> URLRequest? { + queue.sync { + let meta = taskMap.meta(forKey: key) + guard let owner = TaskOwner.resolve(description: description, meta: meta) else { + taskMap.setPurpose(.superseded, forKey: key, id: "") + return nil + } + if case .part(let id, let part, let incarnation) = owner { + return chunked.partWillBegin(id: id, part: part, incarnation: incarnation, key: key, meta: meta) + } + // A task the library already replaced (a session move keeps the + // attempt ordinal, so the ordinal alone cannot tell them apart). + guard meta?.purpose != .superseded, + case .request(let id, let generation, let attempt) = owner, + var e = index.entry(id), !e.isChunked, e.generation == generation, + e.attempts == attempt, e.state == .queued || e.state == .running, !settings.paused, + let url = e.url.flatMap(URL.init(string:)) else { + taskMap.setPurpose(.superseded, forKey: key, id: owner.id) + liveTasks[key] = nil + return nil + } + if e.state == .queued { + e.state = .running + e.nextAttemptAt = nil + commit(e) + } + taskMap.setHeaderGeneration(settings.headerGeneration, forKey: key) + return buildRequest(e, url: url, requestId: meta?.requestId ?? e.lastRequestId ?? UUID().uuidString) + } + } + + /// didSendBodyData. The throttle runs here, on the delegate queue, before + /// the hop. The first event after an issue always passes, which also moves + /// a delayed retry that began while the app was dead to running. + func taskProgress(key: String, description: String?, sent: Int64, expected: Int64) { + let meta = taskMap.meta(forKey: key) + guard meta?.purpose != .superseded, let owner = TaskOwner.resolve(description: description, meta: meta), + throttle.shouldEmit(owner.id, now: now()) else { return } + queue.async { + switch owner { + case .part(let id, let part, let incarnation): + self.chunked.partProgress(id: id, part: part, incarnation: incarnation, sent: sent) + case .request(let id, let generation, let attempt): + guard var e = self.index.entry(id), e.generation == generation, e.attempts == attempt, + e.state == .queued || e.state == .running else { return } + if e.state == .queued { + e.state = .running + e.nextAttemptAt = nil + self.commit(e) + } + self.lastSent[id] = sent + self.emitProgress(id, sent: sent, total: expected > 0 ? expected : e.totalBytes) + } + } + } + + // MARK: - Retry and auth + + /// Backoff from the attempt count. A wait that would pass expiresAt + /// settles 'expired' now instead of scheduling. + func scheduleRetry(_ e: QueueEntry) { + let delay = RetryClassifier.backoffMs(attempt: max(e.attempts, 1), policy: policy(e), random: random) + if now() + Double(delay) >= e.expiresAt { + settle(e.id, .expired) + return + } + var n = e + n.state = .queued + index.upsert(n) + issue(n.id, delayMs: delay) + } + + /// awaiting-auth: no task, one `state` event. updateHeaders() resumes it. + func park(_ e: QueueEntry) { + cancelTasks(e.id, purpose: .superseded) + chunked.stop(e.id) + var n = e + n.state = .awaitingAuth + n.authParked = true + n.nextAttemptAt = nil + commit(n) + } + + func response(_ c: TaskCompletion) -> RawResponseRecord { + RawResponseRecord(status: c.statusCode, headers: c.headers, body: c.body ?? "", + bodyTruncated: c.bodyTruncated) + } +} diff --git a/ios/QueueCoordinator.swift b/ios/QueueCoordinator.swift new file mode 100644 index 00000000..2b9990f5 --- /dev/null +++ b/ios/QueueCoordinator.swift @@ -0,0 +1,387 @@ +import Foundation + +/// A rejection that crosses to JS with a code. +struct EnqueueError: Error { + let code: String + let message: String + + static func storage(_ message: String) -> EnqueueError { EnqueueError(code: "E_STORAGE", message: message) } + static func running(_ id: String) -> EnqueueError { + EnqueueError(code: "E_RUNNING", message: "enqueue: '\(id)' is running; a different body is accepted once it stops") + } + static func fileMissing(_ path: String) -> EnqueueError { + EnqueueError(code: "E_FILE_MISSING", message: "enqueue: file does not exist: \(path)") + } +} + +/// A terminal outcome, before it is journaled. +enum Outcome { + case completed(RawResponseRecord) + case error(OutcomeErrorRecord) + case cancelled(reason: String) + + static let expired = Outcome.error(OutcomeErrorRecord( + errorKind: "expired", message: "expiresAt passed before the request completed")) + + static func fileError(_ message: String, partIndex: Int? = nil) -> Outcome { + .error(OutcomeErrorRecord(errorKind: "file", message: message, partIndex: partIndex)) + } +} + +/// The queue's owner. It holds the store, the settings, the in-memory index, +/// the journal and the task map, schedules every entry, and makes every +/// emit. Every state change runs on one serial queue, which the chunked +/// coordinator shares. Rules it keeps: +/// - Write-ahead: an entry, its body and each attempt ordinal are on disk +/// before a task exists. +/// - Journal before emit: a terminal is a journal file before any emit. +/// - Store first, then index, then the `state` event. +/// - The module queue never waits on this queue, except the synchronous +/// delegate hops, which never wait on JS. +/// +/// The files next to this one extend it: enqueue and the same-id rules, +/// simple attempts, outcomes (settle, ack, forget), and relaunch +/// reconciliation. +final class QueueCoordinator { + static let queueLabel = "ai.openspace.rnbgupload.queue" + + let queue: DispatchQueue + let store: QueueStore + let journal: EventJournal + let taskMap: TaskMap + let transport: Transport + weak var sink: EventSink? + let index = RequestIndex() + let throttle = ProgressThrottle() + + let now: () -> Double + let random: () -> Double + /// Runs `block` on `queue` after `delayMs`. Injected so tests control time. + let schedule: (_ delayMs: Int, _ block: @escaping () -> Void) -> Void + + var settings: QueueSettings + /// false until the first reconcile has matched the daemon's tasks to the + /// entries. Until then nothing issues: a task made now could duplicate one + /// the daemon already holds. Reconcile issues whatever waited. + var ready = false + /// Every task this process created or adopted, by TaskMap key. + var liveTasks: [String: (id: String, task: UploadTask)] = [:] + var expiryTokens: [String: UUID] = [:] + var graceChecks: Set = [] + /// Last bytesSent reported for a simple entry, for the trailing edge. + var lastSent: [String: Int64] = [:] + /// Outcomes whose journal write failed, by eventId. They were emitted + /// live; a timer retries the write while the entry still names them. + var pendingJournal: [String: JournaledEvent] = [:] + lazy var chunked = ChunkedCoordinator(self) + + init(store: QueueStore, journal: EventJournal, taskMap: TaskMap, transport: Transport, + sink: EventSink?, queue: DispatchQueue = DispatchQueue(label: QueueCoordinator.queueLabel), + now: @escaping () -> Double = { Date().timeIntervalSince1970 * 1000 }, + random: @escaping () -> Double = { Double.random(in: 0..<1) }, + schedule: ((Int, @escaping () -> Void) -> Void)? = nil) { + self.queue = queue + self.store = store + self.journal = journal + self.taskMap = taskMap + self.transport = transport + self.sink = sink + self.now = now + self.random = random + self.schedule = schedule ?? { ms, block in + queue.asyncAfter(deadline: .now() + .milliseconds(ms), execute: block) + } + settings = store.loadSettings() + // Synchronous, before any session exists, so getRequests() is warm by + // the time JS can call it, legacy rows included. + index.load(store.all()) + importLegacyIfNeeded() + } + + // MARK: - Module methods + + func configure(_ options: [String: Any]) { + queue.async { + var next = self.settings + next.apply(configure: options) + _ = self.saveSettings(next) + } + } + + func enqueue(_ raw: [String: Any], resolve: @escaping (String) -> Void, + reject: @escaping (String, String) -> Void) { + queue.async { + let result: (id: String, issue: Bool) + do { + result = try self.enqueueLocked(raw) + } catch let e as EnqueueError { + reject(e.code, e.message) + return + } catch { + reject("E_STORAGE", error.localizedDescription) + return + } + resolve(result.id) + if result.issue { self.issue(result.id) } + } + } + + /// Whole-queue pause. Cancels every task with purpose "pause", so its + /// NSURLErrorCancelled produces no outcome and no attempt event. A single + /// body restarts from byte 0 on resume; a chunked upload keeps its + /// accepted parts. + func pause(resolve: @escaping () -> Void, reject: @escaping (String, String) -> Void) { + queue.async { + var next = self.settings + next.paused = true + guard self.saveSettings(next) else { + reject("E_STORAGE", "pause: cannot save the queue settings") + return + } + for e in self.index.entries() where !e.legacy + && [.queued, .running, .awaitingAuth].contains(e.state) { + self.cancelTasks(e.id, purpose: .pause) + self.chunked.stop(e.id) + var n = e + n.state = .paused + n.nextAttemptAt = nil + self.commit(n) + } + resolve() + } + } + + func resume(resolve: @escaping () -> Void, reject: @escaping (String, String) -> Void) { + queue.async { + var next = self.settings + next.paused = false + guard self.saveSettings(next) else { + reject("E_STORAGE", "resume: cannot save the queue settings") + return + } + for e in self.index.entries() where e.state == .paused && !e.legacy { + var n = e + n.state = e.authParked ? .awaitingAuth : .queued + self.commit(n) + if n.state == .queued { self.issue(n.id) } + } + resolve() + } + } + + /// Live entry: journal 'cancelled' (user); forgotten after its ack. + /// Settled entry: forgotten now, with its unacked outcomes. Unknown: no-op. + func cancel(_ id: String, resolve: @escaping () -> Void) { + queue.async { + defer { resolve() } + guard let e = self.index.entry(id) else { return } + if e.isLive && !e.legacy { + self.settle(id, .cancelled(reason: "user")) + } else { + self.forget(id, dropEvents: true) + } + } + } + + /// Persisted. Queued and future entries use the session it picks. A queued + /// entry whose retry waits in the daemon moves to the new session; a + /// running task finishes where it started (session config is fixed). + func setWifiOnly(_ enabled: Bool, resolve: @escaping () -> Void, + reject: @escaping (String, String) -> Void) { + queue.async { + var next = self.settings + let changed = next.wifiOnly != enabled + next.wifiOnly = enabled + guard self.saveSettings(next) else { + reject("E_STORAGE", "setWifiOnly: cannot save the queue settings") + return + } + if changed && self.ready { + for e in self.index.entries() where e.state == .queued && !e.isChunked && !e.legacy { + let remaining = e.nextAttemptAt.map { Int($0 - self.now()) } + self.cancelTasks(e.id, purpose: .superseded) + // The waiting attempt never ran: keep its ordinal and request id. + self.issue(e.id, delayMs: remaining.flatMap { $0 > 0 ? $0 : nil }, advanceAttempt: false) + } + } + resolve() + } + } + + /// Merges the patch into every entry not yet forgotten (names match + /// without regard to case) and bumps the header generation. Parked entries + /// go back to queued and issue. Resolves after the loop. + func updateHeaders(_ patch: [String: Any], resolve: @escaping () -> Void, + reject: @escaping (String, String) -> Void) { + queue.async { + let headers = EnqueueParser.headers(patch) + var next = self.settings + next.headerGeneration += 1 + guard self.saveSettings(next) else { + reject("E_STORAGE", "updateHeaders: cannot save the queue settings") + return + } + for e in self.index.entries() where !e.legacy { + var n = e + n.headers = HeaderMerge.merge(e.headers, headers) + n.headerGeneration = self.settings.headerGeneration + if n.state == .awaitingAuth { + n.authParked = false + n.state = self.settings.paused ? .paused : .queued + self.commit(n) + self.issue(n.id) + } else { + n.authParked = false + self.commit(n, emit: false) + } + } + resolve() + } + } + + /// Synchronous: the in-memory index, under its lock. Called from the JS + /// thread; never touches this queue or the disk. + func rows() -> [[String: Any]] { + index.rows() + } + + /// Every unacked outcome, oldest first. Each return counts as a delivery. + func unacknowledgedEvents(resolve: @escaping ([[String: Any]]) -> Void) { + queue.async { + resolve(self.unacknowledgedLocked().map(\.bridged)) + } + } + + /// Removes the outcomes. An acked 'completed' or 'cancelled' of the + /// entry's current generation forgets the entry: row and bytes. An 'error' + /// keeps both until cancel() or a same-id enqueue. Unknown ids are ignored. + func ack(_ eventIds: [String], resolve: @escaping () -> Void) { + queue.async { + for eventId in eventIds { self.ackLocked(eventId) } + resolve() + } + } + + // MARK: - Transitions (on `queue`) + + /// Store, then index, then the `state` event. A failed save is logged and + /// the index still moves: the in-memory state is what runs, and the next + /// save of this entry writes it. Returns whether the save landed. + @discardableResult + func commit(_ entry: QueueEntry, emit: Bool = true) -> Bool { + let (e, saved) = save(entry) + publish(e, emit: emit) + return saved + } + + /// Write-ahead for an attempt. Publishes only when the save landed, so a + /// failed save leaves the index at what the disk holds. On false the caller + /// creates no task. + func commitAhead(_ entry: QueueEntry, emit: Bool = true) -> Bool { + let (e, saved) = save(entry) + if saved { publish(e, emit: emit) } + return saved + } + + private func save(_ entry: QueueEntry) -> (QueueEntry, Bool) { + var e = entry + e.updatedAt = now() + do { + try store.save(e) + return (e, true) + } catch { + NSLog("[RNFileUploader] cannot save entry \(e.id): \(error.localizedDescription)") + return (e, false) + } + } + + /// Index, then the `state` event, for an entry already saved. + func publish(_ entry: QueueEntry, emit: Bool = true) { + index.upsert(entry) + guard emit, let row = index.row(entry.id) else { return } + sink?.emitState(row) + } + + /// Records why, then cancels every task this process holds for `id`. + func cancelTasks(_ id: String, purpose: TaskMap.Purpose) { + for (key, owner) in liveTasks where owner.id == id { + taskMap.setPurpose(purpose, forKey: key, id: id) + owner.task.cancel() + liveTasks[key] = nil + } + } + + func emitProgress(_ id: String, sent: Int64, total: Int64) { + sink?.emitProgress(["id": id, "bytesSent": sent, "totalBytes": total]) + } + + func policy(_ e: QueueEntry) -> RetryPolicy { + RetryPolicy.resolve([settings.retry, e.retry]) + } + + /// The request for one attempt: the entry's method and headers, part + /// headers over them, the staged body's Content-Type when the headers set + /// none (a multipart body always uses its own), and a fresh X-Request-Id. + func buildRequest(_ e: QueueEntry, url: URL, requestId: String, + partHeaders: [String: String] = [:]) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = e.method + let headers = HeaderMerge.merge(e.headers, partHeaders) + for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } + if let contentType = e.bodyContentType, + e.forceContentType || HeaderMerge.value("Content-Type", in: headers) == nil { + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + request.setValue(requestId, forHTTPHeaderField: "X-Request-Id") + return request + } + + func emitAttempt(_ e: QueueEntry, requestId: String?, attempt: Int, completion c: TaskCompletion, + partIndex: Int?, accepted: Bool, systemCancel: Bool) { + let url = c.url ?? partIndex.flatMap { e.parts.indices.contains($0) ? e.parts[$0].url : nil } + ?? e.url ?? "" + sink?.emitAttempt(AttemptEvent.build(AttemptEvent.Input( + id: e.id, key: e.key, requestId: requestId ?? "", attempt: attempt, url: url, + method: e.method, partIndex: partIndex, statusCode: c.statusCode, headers: c.headers, + body: c.body, error: systemCancel ? nil : c.error, accepted: accepted, + systemCancel: systemCancel, at: now()))) + } + + // MARK: - Expiry + + /// One in-process timer per live entry at expiresAt + 100 ms. A later arm + /// replaces the token, so a resume that moved expiresAt makes the old timer + /// a no-op. Long waits re-arm daily. + func armExpiry(_ e: QueueEntry) { + guard e.isLive, !e.legacy else { return } + let token = UUID() + expiryTokens[e.id] = token + let delay = Int(min(max(e.expiresAt - now(), 0) + 100, 86_400_000)) + schedule(delay) { [weak self] in + guard let self, self.expiryTokens[e.id] == token else { return } + self.expiryTokens[e.id] = nil + guard let current = self.index.entry(e.id), current.isLive, !current.legacy else { return } + if self.now() >= current.expiresAt { + self.settle(current.id, .expired) + } else { + self.armExpiry(current) + } + } + } + + func disarmExpiry(_ id: String) { + expiryTokens[id] = nil + } + + // Assigns only when the write landed. + func saveSettings(_ next: QueueSettings) -> Bool { + do { + try store.saveSettings(next) + settings = next + return true + } catch { + NSLog("[RNFileUploader] cannot save settings: \(error.localizedDescription)") + return false + } + } +} diff --git a/ios/QueueEntry.swift b/ios/QueueEntry.swift new file mode 100644 index 00000000..f829461e --- /dev/null +++ b/ios/QueueEntry.swift @@ -0,0 +1,214 @@ +import Foundation + +/// One durable queue entry: what JS sent at enqueue(), the staged body, and +/// where the entry is in its life. Saved as `entry.json` in the entry's +/// directory (see QueueStore). A pure model: no I/O here. +/// +/// It generalizes the v9 chunked manifest. A chunked entry keeps the v9 +/// fields (parts, accepted flags, incarnation) and gains the queue fields. +struct QueueEntry: Codable, Equatable { + enum State: String, Codable { + case queued, running, awaitingAuth = "awaiting-auth", paused, completed, error, cancelled + } + + enum BodyKind: String, Codable { case none, data, form, file, parts } + + /// One part of a chunked upload, exactly as the consumer authored it. The + /// library sends the file bytes [start, end) to `url`. + struct Part: Codable, Equatable { + let url: String + var headers: [String: String] + let start: Int64 + let end: Int64 // exclusive + var accepted: Bool + /// Failed attempts of this part since its last success or resume. Drives + /// the backoff exponent. Persisted, so a relaunch does not reset it. + var rejections: Int + + var size: Int64 { end - start } + } + + let id: String + var key: String + var varsJSON: String + var descriptorJSON: String + /// nil only for a chunked entry whose descriptor has no url. + var url: String? + var method: String + var accept: [UploadOutcome.AcceptRule] + var retry: RetryOverride? + var bodyKind: BodyKind + /// File name inside the entry directory: "body-" or a blob name. + /// nil for a legacy row with no bytes. + var bodyPath: String? + /// Content-Type the staged body needs (JSON or multipart). + var bodyContentType: String? + /// true for a multipart body: its boundary is ours, so our Content-Type wins. + var forceContentType: Bool + /// The body identity for the same-id rules. See EnqueueParser.fingerprint. + var bodyFingerprint: String + var parts: [Part] + /// The parts-plan identity. Rotates when the body is replaced. Part tasks + /// carry it, so a late callback from a replaced plan is dropped. + var incarnation: String + /// The merged request headers. updateHeaders() patches them. + var headers: [String: String] + /// settings.headerGeneration at the last header write. + var headerGeneration: Int + var state: State + /// true while parked on a 401/403. Survives a pause. + var authParked: Bool + /// Bumps when a settled entry reopens or its body is replaced. An ack + /// forgets the entry only when the event's generation matches. + var generation: Int + /// HTTP attempts issued, parts included. The current simple attempt's + /// ordinal equals this value. + var attempts: Int + var bytesSent: Int64 + var totalBytes: Int64 + var expiresAt: Double // epoch ms + var nextAttemptAt: Double? // epoch ms, set while a delayed retry waits + var settledEventId: String? + var lastRequestId: String? + var lastUrl: String? + var lastPartIndex: Int? + /// An imported v9 journal row: read-only, never scheduled. + var legacy: Bool + let createdAt: Double + var updatedAt: Double +} + +extension QueueEntry { + var isLive: Bool { + switch state { + case .queued, .running, .awaitingAuth, .paused: return true + case .completed, .error, .cancelled: return false + } + } + + var isSettled: Bool { !isLive } + var isChunked: Bool { bodyKind == .parts } + + var acceptedBytes: Int64 { parts.filter(\.accepted).reduce(0) { $0 + $1.size } } + var allAccepted: Bool { !parts.isEmpty && parts.allSatisfy(\.accepted) } + func pendingIndexes() -> [Int] { parts.indices.filter { !parts[$0].accepted } } + + /// The url a SettledEvent reports when no attempt has run yet. + var targetURL: String { lastUrl ?? url ?? parts.first?.url ?? "" } + + func withPartAccepted(_ index: Int) -> QueueEntry { + var next = self + next.parts[index].accepted = true + next.parts[index].rejections = 0 + next.bytesSent = next.acceptedBytes + return next + } + + /// true when `parts` cover [0, size) exactly: no gap, no overlap, nothing + /// past the end. + static func tilesExactly(_ parts: [Part], size: Int64) -> Bool { + guard !parts.isEmpty else { return false } + var cursor: Int64 = 0 + for part in parts.sorted(by: { $0.start < $1.start }) { + guard part.start == cursor, part.end > part.start else { return false } + cursor = part.end + } + return cursor == size + } + + /// Same parts = same count, and the same url and range at each index. + static func sameParts(_ a: [Part], _ b: [Part]) -> Bool { + a.count == b.count && a.indices.allSatisfy { + a[$0].url == b[$0].url && a[$0].start == b[$0].start && a[$0].end == b[$0].end + } + } + + /// Rule 2: a new entry. + static func created(from p: ParsedEnqueue, staged: StagedBody, headerGeneration: Int, + paused: Bool, now: Double, createdAt: Double? = nil) -> QueueEntry { + QueueEntry( + id: p.id, key: p.key, varsJSON: p.varsJSON, descriptorJSON: p.descriptorJSON, + url: p.url, method: p.method, accept: p.accept, retry: p.retry, + bodyKind: staged.kind, bodyPath: staged.relativePath, + bodyContentType: staged.contentType, forceContentType: staged.forceContentType, + bodyFingerprint: p.fingerprint, parts: p.parts, incarnation: UUID().uuidString, + headers: p.headers, headerGeneration: headerGeneration, + state: paused ? .paused : .queued, authParked: false, generation: 1, attempts: 0, + bytesSent: 0, totalBytes: staged.totalBytes, expiresAt: p.expiresAt, + nextAttemptAt: nil, settledEventId: nil, lastRequestId: nil, lastUrl: nil, + lastPartIndex: nil, legacy: false, createdAt: createdAt ?? now, updatedAt: now) + } + + /// Rule 3, same body: the new vars, headers, expiresAt and descriptor + /// fields replace the stored ones. The body, accepted parts, generation and + /// createdAt stay. `resetBudget` (a reopen) also resets attempts and part + /// rejections, because a resume brings fresh headers. + func resumed(with p: ParsedEnqueue, resetBudget: Bool, now: Double) -> QueueEntry { + var next = self + next.key = p.key + next.varsJSON = p.varsJSON + next.descriptorJSON = p.descriptorJSON + next.url = p.url + next.method = p.method + next.headers = p.headers + next.expiresAt = p.expiresAt + next.accept = p.accept + next.retry = p.retry + // Same parts by definition of "same body"; the incoming ones carry the + // new per-part headers. + if isChunked, p.parts.count == parts.count { + next.parts = p.parts.enumerated().map { i, part in + var merged = part + merged.accepted = parts[i].accepted + merged.rejections = resetBudget ? 0 : parts[i].rejections + return merged + } + } + if resetBudget { next.attempts = 0 } + next.updatedAt = now + return next + } + + /// Rule 4, different body: a new body and plan, a new generation, state + /// queued. The caller moves it to paused when the queue is paused. + func replaced(with p: ParsedEnqueue, staged: StagedBody, now: Double) -> QueueEntry { + var next = QueueEntry.created(from: p, staged: staged, headerGeneration: headerGeneration, + paused: false, now: now, createdAt: createdAt) + next.generation = generation + 1 + return next + } + + /// The RequestRow dictionary. `vars` is the decoded object (the index + /// caches it). nextAttemptAt is omitted when nil, so nothing becomes NSNull. + func row(vars: Any) -> [String: Any] { + var r: [String: Any] = [ + "id": id, + "key": key, + "vars": vars, + "state": state.rawValue, + "bytesSent": state == .completed ? totalBytes : bytesSent, + "totalBytes": totalBytes, + "attempts": attempts, + "updatedAt": updatedAt, + ] + if let nextAttemptAt { r["nextAttemptAt"] = nextAttemptAt } + return r + } +} + +/// Header names match without regard to case, as in the JS layer. +enum HeaderMerge { + /// `over` wins. A name in `base` that `over` sets in another case is + /// dropped, so the request never carries both spellings. + static func merge(_ base: [String: String], _ over: [String: String]) -> [String: String] { + let overridden = Set(over.keys.map { $0.lowercased() }) + var result = base.filter { !overridden.contains($0.key.lowercased()) } + for (k, v) in over { result[k] = v } + return result + } + + static func value(_ name: String, in headers: [String: String]) -> String? { + let lower = name.lowercased() + return headers.first { $0.key.lowercased() == lower }?.value + } +} diff --git a/ios/QueueSettings.swift b/ios/QueueSettings.swift new file mode 100644 index 00000000..4e85ce7b --- /dev/null +++ b/ios/QueueSettings.swift @@ -0,0 +1,76 @@ +import Foundation + +/// The retry policy after every override is applied. Defaults are the spec's +/// table: base 1 s, max 2 h, jitter 0.2, exempt [404]. +struct RetryPolicy: Codable, Equatable { + var baseMs: Double + var maxMs: Double + var jitter: Double + var exempt: [Int] + + static let defaults = RetryPolicy(baseMs: 1_000, maxMs: 7_200_000, jitter: 0.2, exempt: [404]) + + /// Applies the overrides in order. A later override wins, field by field. + static func resolve(_ overrides: [RetryOverride?]) -> RetryPolicy { + var p = defaults + for o in overrides.compactMap({ $0 }) { + if let v = o.baseMs { p.baseMs = v } + if let v = o.maxMs { p.maxMs = v } + if let v = o.jitter { p.jitter = v } + if let v = o.exempt { p.exempt = v } + } + return p + } +} + +/// A partial retry policy, as `configure({ retry })` or `descriptor.retry` +/// sends it: `{ backoff?: { baseMs, maxMs, jitter }, terminalHttp?: { exempt } }`. +/// Each field is optional, because JS checks names, not presence. +struct RetryOverride: Codable, Equatable { + var baseMs: Double? + var maxMs: Double? + var jitter: Double? + var exempt: [Int]? + + static func parse(_ raw: Any?) -> RetryOverride? { + guard let r = raw as? [String: Any] else { return nil } + let backoff = r["backoff"] as? [String: Any] + let terminal = r["terminalHttp"] as? [String: Any] + let o = RetryOverride( + baseMs: (backoff?["baseMs"] as? NSNumber)?.doubleValue, + maxMs: (backoff?["maxMs"] as? NSNumber)?.doubleValue, + jitter: (backoff?["jitter"] as? NSNumber)?.doubleValue, + exempt: (terminal?["exempt"] as? [NSNumber])?.map(\.intValue)) + return o == RetryOverride() ? nil : o + } +} + +/// Queue-wide settings, persisted as `settings.json` in the queue directory. +struct QueueSettings: Codable, Equatable { + var wifiOnly = false + var paused = false + /// Bumped by every updateHeaders(). An attempt records the value it was + /// issued under; a 401/403 from an older value re-issues instead of parking. + var headerGeneration = 0 + var retry: RetryOverride? + var lifetimeMs: Double? + + init() {} + + // Every field is optional on decode, so a settings file from an older build + // (or a newer one) still loads. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + wifiOnly = try c.decodeIfPresent(Bool.self, forKey: .wifiOnly) ?? false + paused = try c.decodeIfPresent(Bool.self, forKey: .paused) ?? false + headerGeneration = try c.decodeIfPresent(Int.self, forKey: .headerGeneration) ?? 0 + retry = try c.decodeIfPresent(RetryOverride.self, forKey: .retry) + lifetimeMs = try c.decodeIfPresent(Double.self, forKey: .lifetimeMs) + } + + /// configure(options). The Android notification keys are ignored on iOS. + mutating func apply(configure options: [String: Any]) { + retry = RetryOverride.parse(options["retry"]) + lifetimeMs = (options["lifetimeMs"] as? NSNumber)?.doubleValue + } +} diff --git a/ios/QueueStore.swift b/ios/QueueStore.swift new file mode 100644 index 00000000..c926f4de --- /dev/null +++ b/ios/QueueStore.swift @@ -0,0 +1,261 @@ +import Foundation + +/// The durable queue: one directory per entry id under +/// `Application Support/RNFileUploaderChunked/`. It generalizes the v9 +/// chunked store in place, so v9 bytes and manifests survive the upgrade. +/// +/// An entry directory holds: +/// - `entry.json`: the QueueEntry (v10). +/// - `body-` or `blob-` / `blob`: the staged body. +/// - `part-..-`: a chunked part while in flight. +/// - `manifest.json`: a v9 manifest, until a same-id enqueue adopts it. +/// +/// Next to the directories: `settings.json` and the `v10-imported` marker. +/// Writes are tmp + fsync + rename. A corrupt or half-written file reads as +/// absent. Synchronous on a private serial queue, like the v9 store. +final class QueueStore { + static let shared = QueueStore( + root: FileIO.applicationSupport().appendingPathComponent("RNFileUploaderChunked", isDirectory: true)) + + let root: URL + private let queue = DispatchQueue(label: "ai.openspace.rnbgupload.store") + + static let entryName = "entry.json" + static let manifestName = "manifest.json" + private static let settingsName = "settings.json" + private static let importedMarker = "v10-imported" + + init(root: URL) { + self.root = root + FileIO.makeLocalDirectory(root) + } + + // MARK: - Paths + + /// Ids come from the consumer and may hold path separators. The directory + /// name is url-safe base64 of the id, never the id itself. + func dir(_ id: String) -> URL { + root.appendingPathComponent( + Data(id.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: ""), + isDirectory: true) + } + + func fileURL(_ id: String, _ relative: String) -> URL { + dir(id).appendingPathComponent(relative) + } + + /// The staged body of an entry, or nil for a legacy row with no bytes. + func bodyURL(_ entry: QueueEntry) -> URL? { + entry.bodyPath.map { fileURL(entry.id, $0) } + } + + // MARK: - Entries + + /// Throws on a write failure. An entry that did not persist must fail the + /// enqueue. + func save(_ entry: QueueEntry) throws { + try queue.sync { + try FileManager.default.createDirectory(at: dir(entry.id), withIntermediateDirectories: true) + try FileIO.writeAtomically(Self.encode(entry), to: fileURL(entry.id, Self.entryName)) + } + } + + /// Never reads a `.tmp`. A corrupt file reads as nil. + func load(_ id: String) -> QueueEntry? { + queue.sync { Self.read(fileURL(id, Self.entryName)) } + } + + func all() -> [QueueEntry] { + queue.sync { + subdirectories().compactMap { Self.read($0.appendingPathComponent(Self.entryName)) } + } + } + + /// Deletes the id directory: entry, body, blob, part files, and a v9 + /// manifest. + func remove(_ id: String) { + queue.sync { _ = try? FileManager.default.removeItem(at: dir(id)) } + } + + /// Deletes files the entry does not reference: an old body after a + /// replace, a body staged by an enqueue that crashed before its save, + /// `.tmp` leftovers, and part files of another incarnation. + func sweep(_ entry: QueueEntry) { + queue.sync { + for file in files(in: dir(entry.id)) { + let name = file.lastPathComponent + let isBody = name.hasPrefix(BodyStaging.bodyPrefix) || name.hasPrefix(BodyStaging.blobPrefix) + || name == ChunkedManifestV9.blobName + let stalePart = name.hasPrefix("part-") && !name.contains(".\(entry.incarnation).") + if (isBody && name != entry.bodyPath) || name.hasSuffix(".tmp") || stalePart { + try? FileManager.default.removeItem(at: file) + } + } + } + } + + /// A blob in a directory with no entry.json: the bytes a crash left + /// between the move and the first save. A same-id retry adopts them. + func adoptableBlob(_ id: String) -> String? { + queue.sync { + let d = dir(id) + guard !FileIO.exists(d.appendingPathComponent(Self.entryName)) else { return nil } + return files(in: d).map(\.lastPathComponent) + .filter { $0 == ChunkedManifestV9.blobName || $0.hasPrefix(BodyStaging.blobPrefix) } + .sorted().first + } + } + + // MARK: - Settings and the import marker + + func loadSettings() -> QueueSettings { + queue.sync { + guard let data = try? Data(contentsOf: root.appendingPathComponent(Self.settingsName)), + let s = try? JSONDecoder().decode(QueueSettings.self, from: data) else { return QueueSettings() } + return s + } + } + + func saveSettings(_ settings: QueueSettings) throws { + try queue.sync { + try FileIO.writeAtomically( + try JSONEncoder().encode(settings), to: root.appendingPathComponent(Self.settingsName)) + } + } + + func isImported() -> Bool { + queue.sync { FileIO.exists(root.appendingPathComponent(Self.importedMarker)) } + } + + func markImported() throws { + try queue.sync { + try FileIO.writeAtomically(Data(), to: root.appendingPathComponent(Self.importedMarker)) + } + } + + // MARK: - v9 manifests + + /// A v9 `manifest.json` in the id directory, with or without an entry. + func loadV9Manifest(_ id: String) -> ChunkedManifestV9? { + queue.sync { Self.readManifest(fileURL(id, Self.manifestName)) } + } + + /// Every v9 manifest, keyed by id. + func allV9Manifests() -> [String: ChunkedManifestV9] { + queue.sync { + var result: [String: ChunkedManifestV9] = [:] + for d in subdirectories() { + if let m = Self.readManifest(d.appendingPathComponent(Self.manifestName)) { result[m.id] = m } + } + return result + } + } + + /// Manifests with no entry.json next to them: dormant until a same-id + /// enqueue adopts them. Not rows, never scheduled. + func allDormantManifests() -> [ChunkedManifestV9] { + queue.sync { + subdirectories() + .filter { !FileIO.exists($0.appendingPathComponent(Self.entryName)) } + .compactMap { Self.readManifest($0.appendingPathComponent(Self.manifestName)) } + } + } + + func removeV9Manifest(_ id: String) { + queue.sync { _ = try? FileManager.default.removeItem(at: fileURL(id, Self.manifestName)) } + } + + // MARK: - Part files (carried over from v9) + + /// The temp file that holds exactly the byte range of part `index` while + /// that part is enqueued (a background session uploads only from a file). + /// The name carries the incarnation and the range, so a stale file from + /// another plan is never adopted by a size coincidence. + func partFileURL(_ id: String, _ index: Int, incarnation: String, start: Int64, end: Int64) -> URL { + dir(id).appendingPathComponent("part-\(index).\(incarnation).\(start)-\(end)") + } + + /// Writes bytes [start, end) of `blob` into the part file, tmp + rename. + /// Reuses a finished file with the same identity and size. Throws when the + /// blob is missing or shorter than `end`: a 'file' terminal for the caller. + func writePartFile(id: String, blob: String, index: Int, start: Int64, end: Int64, + incarnation: String) throws -> URL { + try queue.sync { + let dest = partFileURL(id, index, incarnation: incarnation, start: start, end: end) + removePartFilesLocked(id, index, keeping: dest) + if FileIO.size(dest) == end - start { return dest } + let blobURL = fileURL(id, blob) + let blobSize = FileIO.size(blobURL) ?? 0 + guard blobSize >= end else { + throw FileIO.IOError(message: "source blob is \(blobSize) bytes; part \(index) needs [\(start), \(end))") + } + let tmp = dir(id).appendingPathComponent("part-\(index).tmp") + try? FileManager.default.removeItem(at: tmp) + FileManager.default.createFile(atPath: tmp.path, contents: nil) + let reader = try FileHandle(forReadingFrom: blobURL) + defer { try? reader.close() } + let writer = try FileHandle(forWritingTo: tmp) + defer { try? writer.close() } + try reader.seek(toOffset: UInt64(start)) + var remaining = end - start + while remaining > 0 { + let chunk = Int(min(remaining, 1 << 20)) + guard let data = try reader.read(upToCount: chunk), !data.isEmpty else { + throw FileIO.IOError(message: "short read building part \(index)") + } + try writer.write(contentsOf: data) + remaining -= Int64(data.count) + } + try FileIO.rename(tmp, onto: dest) + return dest + } + } + + /// Removes every file of part `index`: current, stale, and tmp. + func removePartFile(_ id: String, _ index: Int) { + queue.sync { removePartFilesLocked(id, index, keeping: nil) } + } + + // MARK: - Private (callers hold `queue`) + + // The "part-." prefix cannot collide across indexes ("part-1." is not a + // prefix of "part-12."). + private func removePartFilesLocked(_ id: String, _ index: Int, keeping: URL?) { + for file in files(in: dir(id)) + where file.lastPathComponent.hasPrefix("part-\(index).") + && file.lastPathComponent != keeping?.lastPathComponent { + try? FileManager.default.removeItem(at: file) + } + } + + private func subdirectories() -> [URL] { + let items = (try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.isDirectoryKey])) ?? [] + return items.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + } + + private func files(in dir: URL) -> [URL] { + (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? [] + } + + static func encode(_ entry: QueueEntry) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try encoder.encode(entry) + } + + private static func read(_ url: URL) -> QueueEntry? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(QueueEntry.self, from: data) + } + + private static func readManifest(_ url: URL) -> ChunkedManifestV9? { + guard let data = try? Data(contentsOf: url), + let m = try? JSONDecoder().decode(ChunkedManifestV9.self, from: data), + !m.parts.isEmpty else { return nil } + return m + } +} diff --git a/ios/RNBackgroundUpload.swift b/ios/RNBackgroundUpload.swift index cd967f8a..5ab80863 100644 --- a/ios/RNBackgroundUpload.swift +++ b/ios/RNBackgroundUpload.swift @@ -1,536 +1,185 @@ import Foundation import React +import UIKit -// Live events destined for JS. The TurboModule shell (RNFileUploader.mm) adopts -// this and forwards to the codegen-generated emitters. The delegate is nil -// whenever JS isn't around (headless relaunch, before the module is created, -// after a reload tears the old one down) — terminal outcomes are journaled -// before we ever get here, so dropping a live event is always safe. +// Live events destined for JS. The TurboModule shell (RNFileUploader.mm) +// adopts this and forwards to the codegen emitters. The delegate is nil +// whenever JS is not around (a headless relaunch, before the module exists, +// after a reload tears the old one down). Every terminal is journaled before +// it gets here, so dropping a live event is always safe. @objc public protocol RNFileUploaderEventDelegate { + func emitState(_ body: [String: Any]) func emitProgress(_ body: [String: Any]) - func emitCompleted(_ body: [String: Any]) - func emitError(_ body: [String: Any]) - func emitCancelled(_ body: [String: Any]) + func emitAttempt(_ body: [String: Any]) + func emitSettled(_ body: [String: Any]) } -// Background HTTP file uploader (iOS). Uploads run on a background URLSession so -// they continue while the app is suspended and complete/relaunch when terminated -// by the system. Terminal outcomes are journaled before being emitted, so JS can -// recover them even if it was dead when they fired. +// The process-wide owner of the two background URLSessions and their +// delegate. It adapts URLSession callbacks and the TurboModule methods to the +// QueueCoordinator, which holds every queue rule. // -// State that must be consistent for the whole process is STATIC: the background -// sessions, the in-flight response buffers, the user-cancel set, and the event -// delegate. The TurboModule instance comes and goes with the JS runtime while the -// URLSession delegate stays pinned to this object, so keeping that state static -// (rather than on the module) is what keeps cancel attribution and response -// assembly correct across a reload — and guarantees we never create two -// background sessions with the same identifier. +// The TurboModule instance comes and goes with the JS runtime; this object +// lives for the whole process. That keeps one delegate per background session +// identifier, and the response buffers and task ownership stay correct +// across a reload. @objc(RNBackgroundUpload) public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { - // The instance that owns the URLSession delegate callbacks. Created on first - // access — by the TurboModule, or by the AppDelegate's - // handleEventsForBackgroundURLSession hook, whichever happens first. That - // second path is load-bearing: on a system relaunch there may be no JS at all, - // and touching `shared` is what recreates the sessions so nsurlsessiond can - // deliver the delegate events it has queued for us. + // Created on first access: by the TurboModule, or by the AppDelegate's + // handleEventsForBackgroundURLSession hook, whichever comes first. The + // second path matters: on a system relaunch there may be no JS at all, and + // touching `shared` recreates the sessions so nsurlsessiond can deliver the + // events it queued. @objc public static let shared = RNBackgroundUpload() - private static let backgroundSessionId = "ReactNativeBackgroundUpload" - private static let wifiOnlySessionId = "ReactNativeBackgroundUpload_WifiOnly" - private static let progressThrottle: TimeInterval = 0.5 // seconds, per upload - - private static let lock = NSLock() - private static var responsesData: [String: NSMutableData] = [:] // sessionId:taskId -> body - private static var lastProgressAt: [String: TimeInterval] = [:] // uploadId -> time - private static var userCancelledIds = Set() - // The ids that removeUpload is releasing now. The cancellation of their - // tasks is an explicit release, not an outcome that the consumer awaits. - // Thus no terminal event is journaled. This matches Android, whose - // removeUpload cancels work with no user-cancel mark. - private static var removedIds = Set() - // The consumer-supplied ids whose check-and-create is in flight, mapped to - // the promises of the concurrent same-id calls. The existence check - // enumerates the session tasks asynchronously. Without this claim, two - // concurrent calls could both see "no task" and enqueue duplicates. The id - // is claimed synchronously, under `lock`, BEFORE the enumeration is - // dispatched. The map entry drains when the first caller's create-or-find - // lands, and every parked call gets that caller's outcome. - private static var creationsInFlight: - [String: [(resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock)]] = [:] - - private static var backgroundSession: URLSession? - private static var wifiOnlySession: URLSession? - - // Deliberately its own lock, not `lock`: creating `shared` acquires `lock` to - // build the sessions, so guarding the delegate with the same lock would risk a - // deadlock between "ensure shared exists" and "set the delegate". + private let transport: SessionTransport + private let sink: DelegateSink + private let coordinator: QueueCoordinator + + private let bufferLock = NSLock() + private var responses: [String: ResponseBuffer] = [:] // TaskMap key -> body so far + + // Its own lock, not bufferLock: creating `shared` must never wait on the + // lock that guards the delegate. private static let delegateLock = NSLock() private static weak var eventDelegate: RNFileUploaderEventDelegate? - // AppDelegate stores the system-provided completion handler here (per session - // id) so the app can be relaunched to finish uploads after termination. + // AppDelegate stores the system completion handler here per session id. private static let bgHandlerLock = NSLock() private static var bgCompletionHandlers: [String: () -> Void] = [:] - // Relaunch ordering: while the chunked coordinator reconciles (deferrals - // > 0), urlSessionDidFinishEvents must NOT hand the system its completion - // handler. The system could suspend the app before the post-reconcile - // refill enqueues a new part task. That would leave zero daemon tasks and + // While a relaunch reconcile runs (deferrals > 0), urlSessionDidFinishEvents + // must not hand the system its handler: the system could suspend the app + // before the refill enqueues the next tasks, leaving zero daemon tasks and // no future wake. A session that finishes its events in that window parks - // its id here. The release drains it. + // its id here; the release drains it. private static var bgHandlerDeferrals = 0 private static var bgSessionsAwaitingDrain: Set = [] - // Owns the chunked-upload window and the manifests. It is implicitly - // unwrapped only because it needs `self` (for the sessions) and is assigned - // before init returns. It is never nil after that. - private var chunked: ChunkedCoordinator! - public override init() { + let transport = SessionTransport() + let sink = DelegateSink() + self.transport = transport + self.sink = sink + // The coordinator exists, and its index is loaded from disk, before any + // session can deliver a callback. + coordinator = QueueCoordinator( + store: .shared, journal: .shared, taskMap: .shared, transport: transport, sink: sink) super.init() - // Recreate the sessions as early as possible so delegate events queued by - // nsurlsessiond from a previous launch are delivered to this process. - _ = session(wifiOnly: false) - _ = session(wifiOnly: true) - chunked = ChunkedCoordinator(uploader: self) - // Relaunch reconciliation: match the daemon's surviving tasks against - // the stored manifests, and refill each upload's window. It runs on the - // coordinator queue. Thus nothing here re-enters the initialization of - // `shared`. - chunked.reconcileAll() + transport.createSessions(delegate: self) + observeAppState() + // Claim the completion-handler deferral BEFORE the reconcile is queued. + // A relaunch reaches here inside the init of `shared`, and the AppDelegate + // hook finishes that init before it stores the handler, so the claim + // always precedes any drain. + RNBackgroundUpload.deferBackgroundCompletionHandlers() + coordinator.reconcileAll { RNBackgroundUpload.releaseBackgroundCompletionHandlers() } } // MARK: - Event delegate @objc public static func setEventDelegate(_ delegate: RNFileUploaderEventDelegate) { - // Force the singleton (and therefore the sessions) into existence before - // taking the lock — see the note on delegateLock. + // Force the singleton (and the sessions) into existence before the lock. _ = shared delegateLock.lock() eventDelegate = delegate delegateLock.unlock() } - /// Deregisters a delegate, but only if it is still the registered one. - /// - /// React Native dispatches `invalidate` asynchronously and gives up waiting - /// after 10s, so a slow call can let the replacement module register itself - /// before the outgoing module's `invalidate` actually runs. Clearing - /// unconditionally there would null out the live delegate and silently stop - /// every event for the rest of the process. + /// Deregisters a delegate only if it is still the registered one. React + /// Native runs invalidate asynchronously and stops waiting after 10 s, so + /// the replacement module can register first. Clearing unconditionally + /// would stop every event for the rest of the process. @objc public static func clearEventDelegate(_ delegate: RNFileUploaderEventDelegate) { delegateLock.lock() defer { delegateLock.unlock() } if eventDelegate === delegate { eventDelegate = nil } } - private static var currentDelegate: RNFileUploaderEventDelegate? { + fileprivate static var currentDelegate: RNFileUploaderEventDelegate? { delegateLock.lock() defer { delegateLock.unlock() } return eventDelegate } - // Journal-before-emit, the library's one terminal-event path. The write is - // durable. The emit is best-effort, because JS can be dead. The - // simple-upload delegate handling and the chunked coordinator share it. - static func journalAndEmit(_ event: JournaledEvent) { - EventJournal.append(event) - emitEvent(event) - } + // MARK: - Module methods (called from the TurboModule shell) - /// Emits WITHOUT a journal write. Use it to deliver again an event that is - /// already in the journal (a resume of a finished-but-unacked upload). - static func emitEvent(_ event: JournaledEvent) { - let body = event.bridged - let delegate = currentDelegate - switch event.type { - case "completed": delegate?.emitCompleted(body) - case "cancelled": delegate?.emitCancelled(body) - default: delegate?.emitError(body) - } - } - - static func emitProgress(id: String, progress: Float) { - currentDelegate?.emitProgress(["id": id, "progress": progress]) - } + // Each is a one-line forward. The coordinator hops onto its own queue and + // returns, so the module queue never waits. - // MARK: - Sessions - - // Internal, not private: the chunked coordinator enqueues part tasks on the - // same two sessions. - func session(wifiOnly: Bool) -> URLSession { - RNBackgroundUpload.lock.lock() - defer { RNBackgroundUpload.lock.unlock() } - if wifiOnly { - if let s = RNBackgroundUpload.wifiOnlySession { return s } - let s = makeSession(identifier: RNBackgroundUpload.wifiOnlySessionId, wifiOnly: true) - RNBackgroundUpload.wifiOnlySession = s - return s - } else { - if let s = RNBackgroundUpload.backgroundSession { return s } - let s = makeSession(identifier: RNBackgroundUpload.backgroundSessionId, wifiOnly: false) - RNBackgroundUpload.backgroundSession = s - return s - } - } - - // Session configuration is load-bearing and carried over verbatim from the - // original Obj-C. Config must be set before the session is created (URLSession - // copies it). Background upload tasks require uploadTask(with:fromFile:). - private func makeSession(identifier: String, wifiOnly: Bool) -> URLSession { - let config = URLSessionConfiguration.background(withIdentifier: identifier) - config.isDiscretionary = false - // A per-session, connection-level backstop for the design's library-wide - // transmission cap of 4. The request-level control is the chunked window. - // This limit mostly bounds piles of simple uploads over HTTP/1.1. - config.httpMaximumConnectionsPerHost = 4 - config.waitsForConnectivity = true - config.allowsCellularAccess = !wifiOnly - config.allowsConstrainedNetworkAccess = !wifiOnly - config.allowsExpensiveNetworkAccess = !wifiOnly - return URLSession(configuration: config, delegate: self, delegateQueue: nil) + @objc(configure:) + public func configure(_ options: [String: Any]) { + coordinator.configure(options) } - private func taskMapKey(_ session: URLSession, _ task: URLSessionTask) -> String { - TaskMap.key(session, task) + @objc(enqueue:resolve:reject:) + public func enqueue(_ entry: [String: Any], resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + coordinator.enqueue(entry, resolve: { resolve($0) }, reject: { reject($0, $1, nil) }) } - // taskDescription is the primary id; the persisted map is the durable fallback. - // A chunked part task's description encodes (uploadId, partIndex). This - // returns the uploadId in both cases. Thus id matching works uniformly. - private func uploadId(_ session: URLSession, _ task: URLSessionTask) -> String { - if let ref = ChunkedCoordinator.partRef(session, task) { return ref.id } - return task.taskDescription ?? TaskMap.meta(forKey: taskMapKey(session, task))?.id ?? "unknown" + @objc(pause:reject:) + public func pause(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + coordinator.pause(resolve: { resolve(nil) }, reject: { reject($0, $1, nil) }) } - private func acceptRules(_ session: URLSession, _ task: URLSessionTask) -> [UploadOutcome.AcceptRule] { - TaskMap.meta(forKey: taskMapKey(session, task))?.accept ?? [] + @objc(resume:reject:) + public func resume(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + coordinator.resume(resolve: { resolve(nil) }, reject: { reject($0, $1, nil) }) } - private var activeSessions: [URLSession] { - [RNBackgroundUpload.backgroundSession, RNBackgroundUpload.wifiOnlySession].compactMap { $0 } + @objc(cancel:resolve:reject:) + public func cancel(_ id: String, resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + coordinator.cancel(id) { resolve(nil) } } - // MARK: - Exported methods (called from the TurboModule shell) - - @objc(startUpload:resolve:reject:) - public func startUpload(_ options: [String: Any], - resolve: @escaping RCTPromiseResolveBlock, + @objc(setWifiOnly:resolve:reject:) + public func setWifiOnly(_ enabled: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - guard let urlString = options["url"] as? String, let path = options["path"] as? String else { - reject("RN Uploader", "Missing 'url' or 'path'", nil); return - } - guard let requestUrl = URL(string: urlString) else { - reject("RN Uploader", "URL not compliant with RFC 2396", nil); return - } - let type = (options["type"] as? String) ?? "raw" - if type != "raw" { - reject("RN Uploader", "Only type: 'raw' is supported", nil); return - } - - var request = URLRequest(url: requestUrl) - request.httpMethod = (options["method"] as? String) ?? "POST" - if let headers = options["headers"] as? [String: Any] { - for (key, value) in headers { - // Only strings and numbers become headers. The original Obj-C skipped - // anything else, and interpolating instead would put "" (or a - // Swift struct description) on the wire for a null/object value — - // silently corrupting e.g. an Authorization header rather than omitting it. - if let s = value as? String { - request.setValue(s, forHTTPHeaderField: key) - } else if let n = value as? NSNumber { - request.setValue(n.stringValue, forHTTPHeaderField: key) - } - } - } - - let wifiOnly = (options["wifiOnly"] as? Bool) ?? false - let accept = UploadOutcome.parseAcceptRules(options["accept"]) - let uploadId = (options["id"] as? String) ?? UUID().uuidString - let fileURL = URL(string: path) ?? URL(fileURLWithPath: path) - - let session = self.session(wifiOnly: wifiOnly) - let startNew: () throws -> Void = { - let task = try RNBackgroundUpload.uploadTask(session, request, fromFile: fileURL) - task.taskDescription = uploadId - TaskMap.set(TaskMap.Meta(id: uploadId, accept: accept, partIndex: nil), - forKey: self.taskMapKey(session, task)) - task.resume() - } - - // A consumer-supplied id makes startUpload idempotent. This is the same - // behavior as Android's ExistingWorkPolicy.KEEP. If a task with this id is - // already pending or running, we resolve with that id. We do not enqueue a - // second task. We examine both sessions, because a new call can set a - // different wifiOnly value while the first task continues in its first - // session. A generated id cannot collide, so that path does not do the - // (asynchronous) task enumeration. - guard options["id"] != nil else { - do { - try startNew() - resolve(uploadId) - } catch { - reject("RN Uploader", error.localizedDescription, error) - } - return - } - - // Serialize the check-and-create for each id: claim the id synchronously, - // before we dispatch the enumeration. The first caller runs the check and - // creates the task. A concurrent same-id caller parks its promise here. - // When the task lands, we answer the parked calls with the same outcome. - // There is no second task, and there is no polling. - RNBackgroundUpload.lock.lock() - if RNBackgroundUpload.creationsInFlight[uploadId] != nil { - RNBackgroundUpload.creationsInFlight[uploadId]?.append((resolve: resolve, reject: reject)) - RNBackgroundUpload.lock.unlock() - return - } - RNBackgroundUpload.creationsInFlight[uploadId] = [] - RNBackgroundUpload.lock.unlock() - let settle = { (failure: Error?) in - RNBackgroundUpload.lock.lock() - let waiters = RNBackgroundUpload.creationsInFlight.removeValue(forKey: uploadId) ?? [] - RNBackgroundUpload.lock.unlock() - for call in [(resolve: resolve, reject: reject)] + waiters { - if let failure { - call.reject("RN Uploader", failure.localizedDescription, failure) - } else { - call.resolve(uploadId) - } - } - } - - let group = DispatchGroup() - let foundLock = NSLock() - var exists = false - for s in activeSessions { - group.enter() - s.getAllTasks { tasks in - for task in tasks - where self.uploadId(s, task) == uploadId - && (task.state == .running || task.state == .suspended) { - foundLock.lock() - exists = true - foundLock.unlock() - } - group.leave() - } - } - group.notify(queue: .main) { - do { - if !exists { try startNew() } - settle(nil) - } catch { - settle(error) - } - } + coordinator.setWifiOnly(enabled, resolve: { resolve(nil) }, reject: { reject($0, $1, nil) }) } - @objc(startChunkedUpload:resolve:reject:) - public func startChunkedUpload(_ options: [String: Any], - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - chunked.startUpload( - options, - resolve: { id in resolve(id) }, - reject: { message in reject("RN Uploader", message, nil) }) - } - - @objc(removeUpload:resolve:reject:) - public func removeUpload(_ uploadId: String, - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - // The chunked release runs first: it cancels the in-flight part tasks - // and deletes the manifest and the bytes. Then we cancel any simple task - // that wears this id. That cancel is kept out of the journal, because an - // explicit release is not an outcome that the consumer awaits. - chunked.remove(uploadId) { - RNBackgroundUpload.lock.lock() - RNBackgroundUpload.removedIds.insert(uploadId) - RNBackgroundUpload.lock.unlock() - let group = DispatchGroup() - let foundLock = NSLock() - var found = false - for session in self.activeSessions { - group.enter() - session.getAllTasks { tasks in - for task in tasks - where self.uploadId(session, task) == uploadId - && ChunkedCoordinator.partRef(session, task) == nil { - foundLock.lock() - found = true - foundLock.unlock() - task.cancel() - } - group.leave() - } - } - group.notify(queue: .main) { - foundLock.lock() - let matched = found - foundLock.unlock() - if !matched { - // Nothing was cancelled. Thus no delegate callback will consume - // the suppression. Drop it. If we keep it, a later upload that - // reuses this id has its real terminal swallowed. - RNBackgroundUpload.lock.lock() - RNBackgroundUpload.removedIds.remove(uploadId) - RNBackgroundUpload.lock.unlock() - } - resolve(nil) - } - } - } - - @objc(cancelUpload:resolve:reject:) - public func cancelUpload(_ cancelUploadId: String, - resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - // A chunked upload cancels through its coordinator: one 'cancelled' - // terminal for the whole upload, journaled before its part tasks are torn - // down. nil means that the id has no manifest. It then falls through to - // the simple-task path. - chunked.cancel(cancelUploadId) { handled in - if let handled { resolve(handled); return } - self.cancelSimpleUpload(cancelUploadId, resolve: resolve) - } + @objc(updateHeaders:resolve:reject:) + public func updateHeaders(_ patch: [String: Any], resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + coordinator.updateHeaders(patch, resolve: { resolve(nil) }, reject: { reject($0, $1, nil) }) } - private func cancelSimpleUpload(_ cancelUploadId: String, - resolve: @escaping RCTPromiseResolveBlock) { - // Record intent before cancelling so the delegate reports cancelReason 'user'. - RNBackgroundUpload.lock.lock() - RNBackgroundUpload.userCancelledIds.insert(cancelUploadId) - RNBackgroundUpload.lock.unlock() - - let sessions = activeSessions - let group = DispatchGroup() - // Guarded: the two sessions' getAllTasks completions run on independent - // delegate queues, so this is written concurrently. - let foundLock = NSLock() - var found = false - for session in sessions { - group.enter() - session.getAllTasks { tasks in - for task in tasks where self.uploadId(session, task) == cancelUploadId { - foundLock.lock() - found = true - foundLock.unlock() - task.cancel() - } - group.leave() - } - } - group.notify(queue: .main) { - foundLock.lock() - let matched = found - foundLock.unlock() - if !matched { - // Nothing to cancel: drop the intent again so a later upload reusing this - // id isn't misattributed as a user cancel. - RNBackgroundUpload.lock.lock() - RNBackgroundUpload.userCancelledIds.remove(cancelUploadId) - RNBackgroundUpload.lock.unlock() - } - resolve(matched) - } + /// Synchronous, from the in-memory index. + @objc public func getRequests() -> [[String: Any]] { + coordinator.rows() } @objc(getUnacknowledgedEvents:reject:) public func getUnacknowledgedEvents(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - resolve(EventJournal.unacknowledged()) + coordinator.unacknowledgedEvents { resolve($0) } } @objc(ackEvents:resolve:reject:) - public func ackEvents(_ eventIds: [String], - resolve: @escaping RCTPromiseResolveBlock, + public func ackEvents(_ eventIds: [Any], resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - // An acked 'completed' is the ONE moment when a chunked upload's manifest - // and moved bytes may be deleted. Every other terminal keeps them for a - // resume. Find those uploads before the entries are removed. - let completedUploadIds = EventJournal.unacknowledgedEntries() - .filter { $0.type == "completed" && eventIds.contains($0.eventId) } - .map { $0.id } - EventJournal.ack(eventIds) - chunked.releaseCompleted(completedUploadIds) { // no-op for simple uploads - // The spec resolves void. Idempotent: an unknown id is ignored. - resolve(nil) - } + // Resolves void. A non-string id is ignored, like an unknown one. + coordinator.ack(eventIds.compactMap { $0 as? String }) { resolve(nil) } } - @objc(getAllUploads:reject:) - public func getAllUploads(_ resolve: @escaping RCTPromiseResolveBlock, - reject: @escaping RCTPromiseRejectBlock) { - let sessions = activeSessions - let group = DispatchGroup() - let lock = NSLock() - var result: [[String: Any]] = [] - for session in sessions { - group.enter() - session.getAllTasks { tasks in - for task in tasks { - // A chunked upload is one logical row, built from its manifest - // below. Its per-part tasks are transport detail. - if ChunkedCoordinator.partRef(session, task) != nil { continue } - let id = self.uploadId(session, task) - if id == "unknown" { continue } - lock.lock() - // Report the real state. Collapsing everything non-running into - // "pending" told a consumer's boot reconciliation that an upload had - // never started, inviting it to re-enqueue one that was already - // finishing or cancelling. - let state: String - switch task.state { - case .running: state = "running" - case .suspended: state = "pending" - case .canceling: state = "cancelled" - case .completed: state = "completed" - @unknown default: state = "pending" - } - result.append(["id": id, - "state": state, - "bytesSent": task.countOfBytesSent, - "totalBytes": task.countOfBytesExpectedToSend]) - lock.unlock() - } - group.leave() - } - } - group.notify(queue: .main) { - self.chunked.snapshots { chunkedRows in - lock.lock() - let combined = result + chunkedRows - lock.unlock() - resolve(combined) - } - } - } + // MARK: - Background session completion // Called from AppDelegate.application(_:handleEventsForBackgroundURLSession:completionHandler:). // Reachable from a consumer's plain Obj-C via `@import - // react_native_background_upload;` — deliberately NOT on the TurboModule class, - // whose header is Obj-C++ only. + // react_native_background_upload;`, not on the TurboModule class, whose + // header is Obj-C++ only. @objc(setBackgroundSessionCompletionHandler:forIdentifier:) public static func setBackgroundSessionCompletionHandler(_ handler: @escaping () -> Void, forIdentifier identifier: String) { - // Touching `shared` recreates the background sessions when this is a fresh, - // system-relaunched process, which is what lets the queued delegate events - // (and therefore this handler) actually fire. On a relaunch, it also - // claims the handler deferral (see below) BEFORE the handler is stored - // here. Thus the claim provably precedes any drain. + // Touching `shared` recreates the sessions in a system-relaunched + // process, and claims the handler deferral before the handler is stored. _ = shared bgHandlerLock.lock() bgCompletionHandlers[identifier] = handler bgHandlerLock.unlock() } - /// For the chunked coordinator only. It parks every - /// urlSessionDidFinishEvents drain until the matching release. Thus the - /// system cannot suspend the app between a relaunch's replayed part - /// completions and the post-reconcile refill that enqueues the next part - /// tasks. static func deferBackgroundCompletionHandlers() { bgHandlerLock.lock() bgHandlerDeferrals += 1 @@ -546,10 +195,9 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { if let handler = bgCompletionHandlers.removeValue(forKey: identifier) { handlers.append(handler) } - // A parked id with no stored handler is simply dropped. There is - // nothing to hold. If we keep it, a LATER wake's handler could drain - // before that wake's events were processed. } + // A parked id with no stored handler is dropped. Kept, it could let a + // later wake's handler drain before that wake's events ran. bgSessionsAwaitingDrain.removeAll() } bgHandlerLock.unlock() @@ -560,148 +208,57 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { guard !data.isEmpty else { return } - // Key by sessionId:taskId, not taskIdentifier alone: taskIdentifier is unique - // per session, so two concurrent uploads (one wifiOnly, one not) can share an - // identifier and would otherwise cross-contaminate response bodies. - let key = taskMapKey(session, dataTask) - RNBackgroundUpload.lock.lock() - if let existing = RNBackgroundUpload.responsesData[key] { - existing.append(data) - } else { - RNBackgroundUpload.responsesData[key] = NSMutableData(data: data) - } - RNBackgroundUpload.lock.unlock() + // Keyed by session id and task id: taskIdentifier alone is unique per + // session only. + let key = TaskMap.key(session, dataTask) + bufferLock.lock() + responses[key, default: ResponseBuffer()].append(data) + bufferLock.unlock() } public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { - // A chunked part's bytes feed the upload's byte-weighted aggregate. A - // per-task percentage would have no meaning to the consumer. - if let ref = ChunkedCoordinator.partRef(session, task) { - chunked.partProgress(id: ref.id, part: ref.part, incarnation: ref.incarnation, - sent: totalBytesSent) - return - } - // 0 rather than -1 when the length is unknown: the documented range is - // 0-100, Android reports 0 for the same case, and a negative value renders - // as a broken progress bar in a consumer that passes it straight through. - var progress: Float = 0 - if totalBytesExpectedToSend > 0 { - progress = 100.0 * Float(totalBytesSent) / Float(totalBytesExpectedToSend) - } - let id = uploadId(session, task) - let now = Date().timeIntervalSince1970 - RNBackgroundUpload.lock.lock() - if progress < 100, - let last = RNBackgroundUpload.lastProgressAt[id], - now - last < RNBackgroundUpload.progressThrottle { - RNBackgroundUpload.lock.unlock() - return + coordinator.taskProgress(key: TaskMap.key(session, task), description: task.taskDescription, + sent: totalBytesSent, expected: totalBytesExpectedToSend) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, + willBeginDelayedRequest request: URLRequest, + completionHandler: @escaping (URLSession.DelayedRequestDisposition, URLRequest?) -> Void) { + if let next = coordinator.taskWillBegin(key: TaskMap.key(session, task), + description: task.taskDescription) { + completionHandler(.useNewRequest, next) + } else { + completionHandler(.cancel, nil) } - RNBackgroundUpload.lastProgressAt[id] = now - RNBackgroundUpload.lock.unlock() - RNBackgroundUpload.currentDelegate?.emitProgress(["id": id, "progress": progress]) } public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - let id = uploadId(session, task) + let key = TaskMap.key(session, task) let http = task.response as? HTTPURLResponse - let statusCode = http?.statusCode ?? 0 - var headers: [String: String] = [:] if let http { - for (key, value) in http.allHeaderFields { headers["\(key)"] = "\(value)" } - } - - // A chunked part's outcome belongs to the coordinator of its upload: - // accept evaluation against the manifest, the window refill, and one - // journaled terminal, only when the whole upload settles. - if let ref = ChunkedCoordinator.partRef(session, task) { - RNBackgroundUpload.lock.lock() - let bodyData = RNBackgroundUpload.responsesData.removeValue(forKey: taskMapKey(session, task)) - RNBackgroundUpload.lock.unlock() - chunked.handlePartCompletion( - id: ref.id, part: ref.part, incarnation: ref.incarnation, - taskKey: taskMapKey(session, task), - statusCode: http != nil ? statusCode : nil, headers: headers, - body: bodyData.flatMap { String(data: $0 as Data, encoding: .utf8) }, - error: error as NSError?) - return - } - - RNBackgroundUpload.lock.lock() - let bodyData = RNBackgroundUpload.responsesData.removeValue(forKey: taskMapKey(session, task)) - RNBackgroundUpload.lastProgressAt[id] = nil - // Consume the user-cancel intent on EVERY terminal outcome, not only the - // cancelled branch. If cancelUpload lost the race with completion, the id - // would otherwise linger for the life of the process and a later upload - // reusing that id would report a system cancel as a user cancel. - let userCancelled = RNBackgroundUpload.userCancelledIds.remove(id) != nil - let removed = RNBackgroundUpload.removedIds.remove(id) != nil - RNBackgroundUpload.lock.unlock() - - // removeUpload cancelled this task as an explicit release, not as an - // outcome that the consumer awaits. Journal nothing. A non-cancel - // terminal that only raced the removal still reports normally. - if removed, let nsError = error as NSError?, nsError.code == NSURLErrorCancelled { - TaskMap.removeKey(taskMapKey(session, task)) - return - } - - let rawBody = bodyData.flatMap { String(data: $0 as Data, encoding: .utf8) } ?? "" - let (cappedBody, truncated) = EventJournal.capBody(rawBody) - let responseBody = cappedBody ?? "" - - let eventId = UUID().uuidString - let timestamp = Date().timeIntervalSince1970 * 1000 - var event = JournaledEvent(eventId: eventId, id: id, type: "completed", timestamp: timestamp) - if http != nil { - event.responseCode = statusCode - event.responseHeaders = headers - event.responseBody = responseBody - event.responseBodyTruncated = truncated - } - - if error == nil { - // "completed" only for a 2xx or a matching per-request accept rule. - // Any other HTTP response is a terminal http error that carries the - // full response. - let accepted = UploadOutcome.isAccepted( - statusCode, body: rawBody, accept: acceptRules(session, task)) - if accepted { - event.type = "completed" - } else { - event.type = "error" - event.errorKind = "http" - event.error = "HTTP \(statusCode)" - } - } else { - let nsError = error! as NSError - if nsError.code == NSURLErrorCancelled { - event.type = "cancelled" - event.cancelReason = userCancelled ? "user" : "system" - } else { - event.type = "error" - event.errorKind = RNBackgroundUpload.errorKind(for: nsError) - event.error = nsError.localizedDescription - } - } - - TaskMap.removeKey(taskMapKey(session, task)) - // Journals BEFORE it emits. The emit is best-effort, because JS can be - // dead. - RNBackgroundUpload.journalAndEmit(event) + for (name, value) in http.allHeaderFields { headers["\(name)"] = "\(value)" } + } + bufferLock.lock() + let buffer = responses.removeValue(forKey: key) ?? ResponseBuffer() + bufferLock.unlock() + let (body, truncated) = buffer.decoded() + // Synchronous hop: the journal write for a terminal lands before this + // callback returns. + coordinator.taskCompleted(TaskCompletion( + key: key, description: task.taskDescription, + url: task.originalRequest?.url?.absoluteString, statusCode: http?.statusCode, + headers: headers, body: http == nil ? nil : body, bodyTruncated: truncated, + error: error as NSError?)) } public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { guard let identifier = session.configuration.identifier else { return } RNBackgroundUpload.bgHandlerLock.lock() guard RNBackgroundUpload.bgHandlerDeferrals <= 0 else { - // A relaunch reconcile is in flight. If we hand the system the handler - // now, it can suspend the app before the refill enqueues new part - // tasks. The id is parked. releaseBackgroundCompletionHandlers drains - // it. + // A relaunch reconcile is in flight. Park the id; the release drains it. RNBackgroundUpload.bgSessionsAwaitingDrain.insert(identifier) RNBackgroundUpload.bgHandlerLock.unlock() return @@ -711,40 +268,116 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { if let handler { DispatchQueue.main.async { handler() } } } - // A background session raises an NSException, not an error, when the file - // cannot be read: for example, when the file was deleted after the caller - // checked it. Uncaught, the exception ends the process. This throws a - // URL-domain error instead, which errorKind(for:) classifies as 'file'. - static func uploadTask(_ session: URLSession, _ request: URLRequest, - fromFile file: URL) throws -> URLSessionUploadTask { - var task: URLSessionUploadTask? - if let exception = RNBGUCatchException({ - task = session.uploadTask(with: request, fromFile: file) - }) { - throw NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile, userInfo: [ - NSLocalizedDescriptionKey: exception.reason ?? "Cannot read file at \(file.absoluteString)", - ]) + // MARK: - App state + + // The progress throttle is 1 s in the foreground and 10 min in the + // background. The flag is set from notifications, because reading + // applicationState needs the main thread. + private func observeAppState() { + let center = NotificationCenter.default + let throttle = coordinator.throttle + for name in [UIApplication.willEnterForegroundNotification, UIApplication.didBecomeActiveNotification] { + center.addObserver(forName: name, object: nil, queue: nil) { _ in throttle.isForeground = true } + } + center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, + queue: nil) { _ in throttle.isForeground = false } + DispatchQueue.main.async { + throttle.isForeground = UIApplication.shared.applicationState != .background } - return task! } +} + +/// Forwards coordinator events to the registered TurboModule, if any. +private final class DelegateSink: EventSink { + func emitState(_ body: [String: Any]) { RNBackgroundUpload.currentDelegate?.emitState(body) } + func emitProgress(_ body: [String: Any]) { RNBackgroundUpload.currentDelegate?.emitProgress(body) } + func emitAttempt(_ body: [String: Any]) { RNBackgroundUpload.currentDelegate?.emitAttempt(body) } + func emitSettled(_ body: [String: Any]) { RNBackgroundUpload.currentDelegate?.emitSettled(body) } +} + +/// The two background sessions: one that may use cellular, one Wi-Fi only. +/// allowsCellularAccess and friends are session properties, so a task keeps +/// the session it started on. Both exist from init, so reconcile sees every +/// task. +private final class SessionTransport: Transport { + private static let backgroundSessionId = "ReactNativeBackgroundUpload" + private static let wifiOnlySessionId = "ReactNativeBackgroundUpload_WifiOnly" + + private let lock = NSLock() + private var background: URLSession? + private var wifiOnly: URLSession? - // Classify a transport error to match Android's errorKind taxonomy: a missing or - // unreadable source file -> 'file'; other URL-domain errors -> 'network'; anything - // else -> 'unknown'. It is internal because the chunked coordinator also - // classifies with it. - static func errorKind(for error: NSError) -> String { - switch (error.domain, error.code) { - case (NSURLErrorDomain, NSURLErrorFileDoesNotExist), - (NSURLErrorDomain, NSURLErrorCannotOpenFile), - (NSURLErrorDomain, NSURLErrorNoPermissionsToReadFile), - (NSCocoaErrorDomain, NSFileNoSuchFileError), - (NSCocoaErrorDomain, NSFileReadNoSuchFileError), - (NSCocoaErrorDomain, NSFileReadNoPermissionError): - return "file" - case (NSURLErrorDomain, _): - return "network" - default: - return "unknown" + func createSessions(delegate: URLSessionDelegate) { + lock.lock() + defer { lock.unlock() } + background = Self.makeSession(identifier: Self.backgroundSessionId, wifiOnly: false, delegate: delegate) + wifiOnly = Self.makeSession(identifier: Self.wifiOnlySessionId, wifiOnly: true, delegate: delegate) + } + + func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String, + beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask { + let session = self.session(wifiOnly: wifiOnly) + // A background session uploads from a file only. + let task = session.uploadTask(with: request, fromFile: file) + task.taskDescription = description + if let beginAt { task.earliestBeginDate = beginAt } + let handle = SessionTask(session: session, task: task) + beforeResume(handle.key) + task.resume() + return handle + } + + func allTasks(_ completion: @escaping ([UploadTask]) -> Void) { + let sessions = [session(wifiOnly: false), session(wifiOnly: true)] + let group = DispatchGroup() + let collectLock = NSLock() + var collected: [UploadTask] = [] + for session in sessions { + group.enter() + session.getAllTasks { tasks in + collectLock.lock() + collected.append(contentsOf: tasks.map { SessionTask(session: session, task: $0) }) + collectLock.unlock() + group.leave() + } } + group.notify(queue: .global()) { completion(collected) } + } + + private func session(wifiOnly: Bool) -> URLSession { + lock.lock() + defer { lock.unlock() } + // createSessions runs in init, before anything can call this. + return (wifiOnly ? self.wifiOnly : background)! + } + + // Session config is set before the session is created (URLSession copies + // it). Carried over from v9. + private static func makeSession(identifier: String, wifiOnly: Bool, + delegate: URLSessionDelegate) -> URLSession { + let config = URLSessionConfiguration.background(withIdentifier: identifier) + config.isDiscretionary = false + // A per-host backstop. The chunked window of 3 is the real limiter. + config.httpMaximumConnectionsPerHost = 4 + config.waitsForConnectivity = true + config.allowsCellularAccess = !wifiOnly + config.allowsConstrainedNetworkAccess = !wifiOnly + config.allowsExpensiveNetworkAccess = !wifiOnly + return URLSession(configuration: config, delegate: delegate, delegateQueue: nil) } } + +private final class SessionTask: UploadTask { + let key: String + private let task: URLSessionTask + + init(session: URLSession, task: URLSessionTask) { + key = TaskMap.key(session, task) + self.task = task + } + + var taskDescription: String? { task.taskDescription } + var isLive: Bool { task.state == .running || task.state == .suspended } + var beginAt: Date? { task.earliestBeginDate } + func cancel() { task.cancel() } +} diff --git a/ios/RNFileUploader.mm b/ios/RNFileUploader.mm index c0d0ae3b..2a2395c3 100644 --- a/ios/RNFileUploader.mm +++ b/ios/RNFileUploader.mm @@ -66,69 +66,58 @@ + (NSString *)moduleName #pragma mark - Exported methods -// v10 slice 1 ships the JS layer alone. Every queue method rejects with this -// code until slice 3 builds the iOS queue and executor. -static NSString *const kNotImplemented = @"E_NOT_IMPLEMENTED"; +// Each method is a one-line forward to the Swift engine, which hops onto its +// own serial queue and returns. getRequests is the one synchronous method: it +// reads the in-memory index under a lock. -static void RejectNotImplemented(RCTPromiseRejectBlock reject, NSString *method) -{ - reject(kNotImplemented, - [NSString stringWithFormat:@"RNFileUploader.%@: the iOS queue is not built yet", method], - nil); -} - -// configure() carries { lifetimeMs, retry, ...androidNotificationConfig }. iOS -// background uploads have no library-owned notification, and slice 3 persists -// the queue settings. Thus there is nothing to save yet. - (void)configure:(NSDictionary *)options { + [RNBackgroundUpload.shared configure:options]; } - (void)enqueue:(NSDictionary *)entry resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - RejectNotImplemented(reject, @"enqueue"); + [RNBackgroundUpload.shared enqueue:entry resolve:resolve reject:reject]; } - (void)pause:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - RejectNotImplemented(reject, @"pause"); + [RNBackgroundUpload.shared pause:resolve reject:reject]; } - (void)resume:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - RejectNotImplemented(reject, @"resume"); + [RNBackgroundUpload.shared resume:resolve reject:reject]; } - (void)cancel:(NSString *)id resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - RejectNotImplemented(reject, @"cancel"); + [RNBackgroundUpload.shared cancel:id resolve:resolve reject:reject]; } - (void)setWifiOnly:(BOOL)enabled resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - RejectNotImplemented(reject, @"setWifiOnly"); + [RNBackgroundUpload.shared setWifiOnly:enabled resolve:resolve reject:reject]; } - (void)updateHeaders:(NSDictionary *)patch resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - RejectNotImplemented(reject, @"updateHeaders"); + [RNBackgroundUpload.shared updateHeaders:patch resolve:resolve reject:reject]; } -// Synchronous. The live rows of the v10 queue. Slice 3 serializes them from -// the in-memory index; until then the queue is empty. - (NSArray *)getRequests { - return @[]; + return [RNBackgroundUpload.shared getRequests]; } - (void)getUnacknowledgedEvents:(RCTPromiseResolveBlock)resolve @@ -146,7 +135,7 @@ - (void)ackEvents:(NSArray *)ids #pragma mark - RNFileUploaderEventDelegate -// Called synchronously on the URLSession delegate queue. That is safe and +// Called on the engine's serial queue. That is safe and // deliberate: the generated emitter locks its own state and dispatches each // listener through the JS CallInvoker, so it is already thread-safe and already // async onto the JS thread. Deferring to the main queue instead would open a @@ -167,25 +156,24 @@ - (void)safeEmit:(void (^)(RNFileUploader *emitter))block } } -// The v9 Swift engine still reports through the delegate. The v10 spec has no -// per-outcome emitters and a different progress shape ({ id, bytesSent, -// totalBytes }), so until slice 3 rewires the engine to onState/onProgress/ -// onSettled, the live v9 payloads are dropped here. Terminal outcomes are -// journaled first, so nothing durable is lost. -- (void)emitProgress:(NSDictionary *)body +- (void)emitState:(NSDictionary *)body { + [self safeEmit:^(RNFileUploader *m) { [m emitOnState:body]; }]; } -- (void)emitCompleted:(NSDictionary *)body +- (void)emitProgress:(NSDictionary *)body { + [self safeEmit:^(RNFileUploader *m) { [m emitOnProgress:body]; }]; } -- (void)emitError:(NSDictionary *)body +- (void)emitAttempt:(NSDictionary *)body { + [self safeEmit:^(RNFileUploader *m) { [m emitOnAttempt:body]; }]; } -- (void)emitCancelled:(NSDictionary *)body +- (void)emitSettled:(NSDictionary *)body { + [self safeEmit:^(RNFileUploader *m) { [m emitOnSettled:body]; }]; } @end diff --git a/ios/RequestIndex.swift b/ios/RequestIndex.swift new file mode 100644 index 00000000..63257493 --- /dev/null +++ b/ios/RequestIndex.swift @@ -0,0 +1,76 @@ +import Foundation + +/// The in-memory copy of every stored entry. getRequests() reads it +/// synchronously from the JS thread, so it never touches the disk. The +/// coordinator queue writes it after every store write. Guarded by a lock. +final class RequestIndex { + private struct Item { + let entry: QueueEntry + /// varsJSON decoded once, at load or upsert. + let vars: Any + } + + private let lock = NSLock() + private var items: [String: Item] = [:] + + func load(_ entries: [QueueEntry]) { + lock.lock() + defer { lock.unlock() } + items = [:] + for e in entries { items[e.id] = Item(entry: e, vars: JSONText.decode(e.varsJSON)) } + } + + func upsert(_ entry: QueueEntry) { + lock.lock() + defer { lock.unlock() } + // Decode again only when vars changed. + if let old = items[entry.id], old.entry.varsJSON == entry.varsJSON { + items[entry.id] = Item(entry: entry, vars: old.vars) + } else { + items[entry.id] = Item(entry: entry, vars: JSONText.decode(entry.varsJSON)) + } + } + + func remove(_ id: String) { + lock.lock() + defer { lock.unlock() } + items[id] = nil + } + + func entry(_ id: String) -> QueueEntry? { + lock.lock() + defer { lock.unlock() } + return items[id]?.entry + } + + /// One RequestRow, with the cached vars. + func row(_ id: String) -> [String: Any]? { + lock.lock() + defer { lock.unlock() } + return items[id].map { $0.entry.row(vars: $0.vars) } + } + + /// Every entry, oldest first. + func entries() -> [QueueEntry] { + lock.lock() + defer { lock.unlock() } + return items.values.map(\.entry).sorted(by: Self.order) + } + + /// The RequestRow dictionaries, oldest first, for a stable order. + func rows() -> [[String: Any]] { + lock.lock() + defer { lock.unlock() } + return items.values.sorted { Self.order($0.entry, $1.entry) }.map { $0.entry.row(vars: $0.vars) } + } + + var count: Int { + lock.lock() + defer { lock.unlock() } + return items.count + } + + private static func order(_ a: QueueEntry, _ b: QueueEntry) -> Bool { + a.createdAt != b.createdAt ? a.createdAt < b.createdAt : a.id < b.id + } +} diff --git a/ios/RetryClassifier.swift b/ios/RetryClassifier.swift new file mode 100644 index 00000000..9fb7edef --- /dev/null +++ b/ios/RetryClassifier.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Decides what one attempt's result means: the spec's retry, auth and +/// lifetime table (section 6.1). Pure: no I/O, no session state. +enum RetryClassifier { + enum Class: Equatable { + case accepted + case transient + case auth + case terminalHttp + /// The payload is gone. A retry can never succeed. + case fileMissing + /// The payload exists but cannot be read now (iOS before first unlock). + case fileUnreadable + case expired + } + + struct Input { + var statusCode: Int? + var body: String? + var error: NSError? + var accept: [UploadOutcome.AcceptRule] + var policy: RetryPolicy + /// Changes nothing: a chunked definition sets `exempt: []`, so the + /// terminal row applies through the policy. It is here so a test can pin + /// the part-404 case. + var isChunkedPart: Bool + var fileExists: Bool + var now: Double + var expiresAt: Double + } + + /// A cancellation (NSURLErrorCancelled) never reaches here: the caller + /// handles it from the task's recorded purpose. + static func classify(_ i: Input) -> Class { + if i.error == nil, let code = i.statusCode, + UploadOutcome.isAccepted(code, body: i.body, accept: i.accept) { + return .accepted + } + if i.now >= i.expiresAt { return .expired } + if let error = i.error { + if errorKind(for: error) == "file" { return i.fileExists ? .fileUnreadable : .fileMissing } + return .transient + } + guard let code = i.statusCode else { return .transient } + switch code { + case 401, 403: return .auth + case 408, 429, 500...599: return .transient + case 400...499: return i.policy.exempt.contains(code) ? .transient : .terminalHttp + // 1xx and 3xx the session did not follow. The answer will not change. + default: return .terminalHttp + } + } + + /// Jittered exponential backoff. `attempt` is 1-based: 1 gives about base. + /// min(base * 2^(attempt-1), max), times (1 + jitter * (2r - 1)), >= 0. + static func backoffMs(attempt: Int, policy: RetryPolicy, random: () -> Double) -> Int { + let exponent = Double(min(max(attempt - 1, 0), 40)) + let raw = min(policy.baseMs * pow(2, exponent), policy.maxMs) + let jittered = raw * (1 + policy.jitter * (2 * random() - 1)) + return Int(max(jittered, 0).rounded()) + } + + /// The errorKind taxonomy shared with Android: a missing or unreadable + /// source file is 'file'; other URL-domain errors are 'network'; anything + /// else is 'unknown'. + static func errorKind(for error: NSError) -> String { + switch (error.domain, error.code) { + case (NSURLErrorDomain, NSURLErrorFileDoesNotExist), + (NSURLErrorDomain, NSURLErrorCannotOpenFile), + (NSURLErrorDomain, NSURLErrorNoPermissionsToReadFile), + (NSCocoaErrorDomain, NSFileNoSuchFileError), + (NSCocoaErrorDomain, NSFileReadNoSuchFileError), + (NSCocoaErrorDomain, NSFileReadNoPermissionError): + return "file" + case (NSURLErrorDomain, _): + return "network" + default: + return "unknown" + } + } + + static func isCancellation(_ error: NSError?) -> Bool { + error?.domain == NSURLErrorDomain && error?.code == NSURLErrorCancelled + } +} diff --git a/ios/TaskMap.swift b/ios/TaskMap.swift index 2748b39d..49bacc12 100644 --- a/ios/TaskMap.swift +++ b/ios/TaskMap.swift @@ -1,44 +1,53 @@ import Foundation -// Durable ":" -> { id, accept, partIndex } mapping. -// -// Apple documents `taskDescription` only as an uninterpreted app string with no -// guarantee it survives process death, and DTS guidance is to persist task -// metadata externally keyed by the (stable) taskIdentifier. taskDescription -// stays the primary id. This map is the durable fallback. Thus a task -// observed after a relaunch is never orphaned under an unknown id, and the -// accept rules are still known when a task completes after the original -// startUpload options are gone. Chunked part tasks carry `partIndex`. Their -// accept rules live in the manifest, so `accept` is nil for them. -// -// Synchronous serial-queue access; a single JSON file. -enum TaskMap { - struct Meta: Codable { +/// Durable ":" -> Meta mapping. +/// +/// Apple documents `taskDescription` only as an app string with no promise +/// that it survives process death, and DTS guidance is to persist task +/// metadata keyed by the stable taskIdentifier. taskDescription stays the +/// primary owner; this map is the durable fallback. Both are written before +/// the task resumes. +/// +/// It also records why the library cancelled a task (`purpose`), so the +/// NSURLErrorCancelled callback can tell a pause or a supersede (no outcome) +/// from a cancel the system made (a transient retry). +/// +/// One JSON file, cached in memory, written through on every change. +final class TaskMap { + enum Purpose: String, Codable { + case attempt + case pause + case superseded + } + + struct Meta: Codable, Equatable { let id: String - // All are optional. Thus entries that older builds persisted still - // decode. var accept: [UploadOutcome.AcceptRule]? var partIndex: Int? - // Chunked part tasks only: the manifest incarnation that the task was - // created under. It mirrors the taskDescription encoding (see - // ChunkedEngine). var incarnation: String? + var attempt: Int? + var requestId: String? + var headerGeneration: Int? + var generation: Int? + var purpose: Purpose? - init(id: String, accept: [UploadOutcome.AcceptRule]?, partIndex: Int?, - incarnation: String? = nil) { + init(id: String, accept: [UploadOutcome.AcceptRule]? = nil, partIndex: Int? = nil, + incarnation: String? = nil, attempt: Int? = nil, requestId: String? = nil, + headerGeneration: Int? = nil, generation: Int? = nil, purpose: Purpose? = nil) { self.id = id self.accept = accept self.partIndex = partIndex self.incarnation = incarnation + self.attempt = attempt + self.requestId = requestId + self.headerGeneration = headerGeneration + self.generation = generation + self.purpose = purpose } private enum CodingKeys: String, CodingKey { - case id, accept, partIndex, incarnation - // Earlier builds persisted `acceptStatus: [Int]` where this build - // persists `accept` rules. The key is read, and never written. Thus a - // task that an older build enqueued keeps its accept rules when it - // completes under this build. This is the same legacy mapping as - // Android's Upload.normalized(). + case id, accept, partIndex, incarnation, attempt, requestId, headerGeneration, generation, purpose + // Builds before v9 persisted `acceptStatus: [Int]`. Read, never written. case acceptStatus } @@ -47,6 +56,12 @@ enum TaskMap { id = try c.decode(String.self, forKey: .id) partIndex = try c.decodeIfPresent(Int.self, forKey: .partIndex) incarnation = try c.decodeIfPresent(String.self, forKey: .incarnation) + attempt = try c.decodeIfPresent(Int.self, forKey: .attempt) + requestId = try c.decodeIfPresent(String.self, forKey: .requestId) + headerGeneration = try c.decodeIfPresent(Int.self, forKey: .headerGeneration) + generation = try c.decodeIfPresent(Int.self, forKey: .generation) + // An unknown purpose from a newer build reads as nil. + purpose = (try? c.decodeIfPresent(String.self, forKey: .purpose)).flatMap { Purpose(rawValue: $0) } accept = try c.decodeIfPresent([UploadOutcome.AcceptRule].self, forKey: .accept) ?? c.decodeIfPresent([Int].self, forKey: .acceptStatus)? .map { UploadOutcome.AcceptRule(status: $0, bodyIncludes: nil) } @@ -58,48 +73,93 @@ enum TaskMap { try c.encodeIfPresent(accept, forKey: .accept) try c.encodeIfPresent(partIndex, forKey: .partIndex) try c.encodeIfPresent(incarnation, forKey: .incarnation) + try c.encodeIfPresent(attempt, forKey: .attempt) + try c.encodeIfPresent(requestId, forKey: .requestId) + try c.encodeIfPresent(headerGeneration, forKey: .headerGeneration) + try c.encodeIfPresent(generation, forKey: .generation) + try c.encodeIfPresent(purpose, forKey: .purpose) } } - private static let queue = DispatchQueue(label: "ai.openspace.rnbgupload.taskmap") + static let shared = TaskMap( + fileURL: FileIO.applicationSupport().appendingPathComponent("RNFileUploaderTaskMap.json")) + + let fileURL: URL + private let queue = DispatchQueue(label: "ai.openspace.rnbgupload.taskmap") + private var cache: [String: Meta] + + init(fileURL: URL) { + self.fileURL = fileURL + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + cache = Self.read(fileURL) + } static func key(_ session: URLSession, _ task: URLSessionTask) -> String { "\(session.configuration.identifier ?? ""):\(task.taskIdentifier)" } - private static var fileURL: URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) - return base.appendingPathComponent("RNFileUploaderTaskMap.json") + func set(_ meta: Meta, forKey key: String) { + queue.sync { + cache[key] = meta + write() + } + } + + func meta(forKey key: String) -> Meta? { + queue.sync { cache[key] } + } + + func removeKey(_ key: String) { + queue.sync { + guard cache.removeValue(forKey: key) != nil else { return } + write() + } + } + + /// Records why the library is about to cancel a task. Creates the entry + /// when the task has none (a v9 task). + func setPurpose(_ purpose: Purpose, forKey key: String, id: String) { + queue.sync { + var meta = cache[key] ?? Meta(id: id) + meta.purpose = purpose + cache[key] = meta + write() + } } - static func set(_ meta: Meta, forKey key: String) { + func setHeaderGeneration(_ generation: Int, forKey key: String) { queue.sync { - var map = read() - map[key] = meta - write(map) + guard cache[key] != nil else { return } + cache[key]?.headerGeneration = generation + write() } } - static func meta(forKey key: String) -> Meta? { - queue.sync { read()[key] } + func keys(where predicate: (Meta) -> Bool) -> [String] { + queue.sync { cache.filter { predicate($0.value) }.map(\.key) } } - static func removeKey(_ key: String) { + func removeAll(where predicate: (String, Meta) -> Bool) { queue.sync { - var map = read() - map.removeValue(forKey: key) - write(map) + let before = cache.count + cache = cache.filter { !predicate($0.key, $0.value) } + if cache.count != before { write() } } } - private static func read() -> [String: Meta] { - guard let data = try? Data(contentsOf: fileURL) else { return [:] } - return (try? JSONDecoder().decode([String: Meta].self, from: data)) ?? [:] + // Caller holds `queue`. + private func write() { + guard let data = try? JSONEncoder().encode(cache) else { return } + do { + try FileIO.writeAtomically(data, to: fileURL) + } catch { + NSLog("[RNFileUploader] task map write failed: \(error.localizedDescription)") + } } - private static func write(_ map: [String: Meta]) { - guard let data = try? JSONEncoder().encode(map) else { return } - try? data.write(to: fileURL, options: .atomic) + private static func read(_ url: URL) -> [String: Meta] { + guard let data = try? Data(contentsOf: url) else { return [:] } + return (try? JSONDecoder().decode([String: Meta].self, from: data)) ?? [:] } } diff --git a/ios/Tests/BodyStagingTests.swift b/ios/Tests/BodyStagingTests.swift new file mode 100644 index 00000000..b5abc7cf --- /dev/null +++ b/ios/Tests/BodyStagingTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import RNBGUCore + +final class BodyStagingTests: XCTestCase { + private var root: URL! + private var dir: URL! + + override func setUp() { + root = makeTempDir() + dir = root.appendingPathComponent("entry") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: root) + } + + private func part(_ s: Int64, _ e: Int64) -> QueueEntry.Part { + QueueEntry.Part(url: "https://s3.test", headers: [:], start: s, end: e, accepted: false, rejections: 0) + } + + func testDataBodyIsCanonicalJSON() throws { + let staged = try BodyStaging.stage(.data(json: #"{"a":1}"#), parts: [], into: dir, fallbackBlob: nil) + XCTAssertEqual(staged.contentType, "application/json") + XCTAssertFalse(staged.forceContentType) + XCTAssertTrue(staged.relativePath.hasPrefix("body-")) + XCTAssertEqual(try String(contentsOf: dir.appendingPathComponent(staged.relativePath)), #"{"a":1}"#) + XCTAssertEqual(staged.totalBytes, 7) + XCTAssertFalse(FileIO.exists(dir.appendingPathComponent(staged.relativePath + ".tmp"))) + } + + func testBodilessIsZeroBytes() throws { + let staged = try BodyStaging.stage(.none, parts: [], into: dir, fallbackBlob: nil) + XCTAssertEqual(FileIO.size(dir.appendingPathComponent(staged.relativePath)), 0) + XCTAssertNil(staged.contentType) + } + + func testFormBodyParsesBack() throws { + let photo = root.appendingPathComponent("photo.jpg") + writeFile(photo, "JPEGBYTES") + let staged = try BodyStaging.stage(.form([ + .init(name: "request", contentType: "application/json", string: #"{"id":"p1"}"#, path: nil, fileName: nil), + .init(name: "image", contentType: "image/jpeg", string: nil, path: "file://" + photo.path, fileName: nil), + ]), parts: [], into: dir, fallbackBlob: nil) + XCTAssertTrue(staged.forceContentType) + let ct = try XCTUnwrap(staged.contentType) + XCTAssertTrue(ct.hasPrefix("multipart/form-data; boundary=")) + let boundary = String(ct.dropFirst("multipart/form-data; boundary=".count)) + let text = try String(contentsOf: dir.appendingPathComponent(staged.relativePath)) + let sections = text.components(separatedBy: "--\(boundary)") + XCTAssertEqual(sections.count, 4) // preamble, two fields, closing + XCTAssertTrue(sections[1].contains(#"Content-Disposition: form-data; name="request""#)) + XCTAssertTrue(sections[1].contains("\r\n\r\n{\"id\":\"p1\"}\r\n")) + XCTAssertTrue(sections[2].contains(#"name="image"; filename="photo.jpg""#), "filename defaults to the last path component") + XCTAssertTrue(sections[2].contains("Content-Type: image/jpeg\r\n\r\nJPEGBYTES\r\n")) + XCTAssertEqual(sections[3], "--\r\n") + XCTAssertEqual(staged.totalBytes, Int64(text.utf8.count)) + } + + func testMissingFormFileThrowsBeforeAnyWrite() { + XCTAssertThrowsError(try BodyStaging.stage(.form([ + .init(name: "image", contentType: "image/jpeg", string: nil, path: "/nope/missing.jpg", fileName: nil), + ]), parts: [], into: dir, fallbackBlob: nil)) { error in + XCTAssertEqual(error as? StagingError, .fileMissing("/nope/missing.jpg")) + } + let files = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] + XCTAssertTrue(files.isEmpty) + } + + func testFileIsCopied() throws { + let src = root.appendingPathComponent("a.bin") + writeFile(src, bytes: 64) + let staged = try BodyStaging.stage(.file(path: src.path), parts: [], into: dir, fallbackBlob: nil) + XCTAssertEqual(staged.totalBytes, 64) + XCTAssertTrue(FileIO.exists(src), "a single file body is copied, not moved") + XCTAssertEqual(try Data(contentsOf: src), try Data(contentsOf: dir.appendingPathComponent(staged.relativePath))) + XCTAssertThrowsError(try BodyStaging.stage(.file(path: "/nope"), parts: [], into: dir, fallbackBlob: nil)) { + XCTAssertEqual($0 as? StagingError, .fileMissing("/nope")) + } + } + + func testPartsMoveTheSource() throws { + let src = root.appendingPathComponent("video.mp4") + writeFile(src, bytes: 20) + let staged = try BodyStaging.stage(.parts(file: src.path), parts: [part(0, 10), part(10, 20)], + into: dir, fallbackBlob: nil) + XCTAssertFalse(FileIO.exists(src), "a chunked file is moved") + XCTAssertTrue(staged.relativePath.hasPrefix("blob-")) + XCTAssertEqual(staged.totalBytes, 20) + XCTAssertFalse(staged.adopted) + } + + func testPartsTilingErrorLeavesTheSource() { + let src = root.appendingPathComponent("video.mp4") + writeFile(src, bytes: 20) + XCTAssertThrowsError(try BodyStaging.stage(.parts(file: src.path), parts: [part(0, 10)], into: dir, + fallbackBlob: nil)) { error in + guard case .invalid = error as? StagingError else { return XCTFail("expected invalid, got \(error)") } + } + XCTAssertTrue(FileIO.exists(src), "the check runs before the move") + } + + // A crash between the move and the entry save left the bytes as a blob. + func testPartsAdoptExistingBlobAfterACrash() throws { + writeFile(dir.appendingPathComponent("blob-crashed"), bytes: 20) + let staged = try BodyStaging.stage(.parts(file: root.appendingPathComponent("gone.mp4").path), + parts: [part(0, 20)], into: dir, fallbackBlob: "blob-crashed") + XCTAssertEqual(staged.relativePath, "blob-crashed") + XCTAssertTrue(staged.adopted) + XCTAssertThrowsError(try BodyStaging.stage(.parts(file: "/gone"), parts: [part(0, 20)], into: dir, + fallbackBlob: nil)) { + XCTAssertEqual($0 as? StagingError, .fileMissing("/gone")) + } + } + + func testFileURLAcceptsBothForms() { + XCTAssertEqual(BodyStaging.fileURL("/var/a b.txt").path, "/var/a b.txt") + XCTAssertEqual(BodyStaging.fileURL("file:///var/a%20b.txt").path, "/var/a b.txt") + XCTAssertEqual(BodyStaging.fileURL("file:///var/a b.txt").path, "/var/a b.txt") + } +} diff --git a/ios/Tests/CoordinatorChunkedTests.swift b/ios/Tests/CoordinatorChunkedTests.swift new file mode 100644 index 00000000..f88bc31e --- /dev/null +++ b/ios/Tests/CoordinatorChunkedTests.swift @@ -0,0 +1,498 @@ +import XCTest +@testable import RNBGUCore + +/// Chunked entries: window, accept, part failures, recreate, parking. +final class CoordinatorChunkedTests: XCTestCase { + private var h: Harness! + + override func setUp() { + h = Harness() + h.boot() + } + + override func tearDown() { + try? FileManager.default.removeItem(at: h.root) + } + + private func partTask(_ index: Int) -> FakeTask? { + h.transport.live.first { ChunkedEngine.parsePartDescription($0.taskDescription)?.part == index } + } + + func testWindowOfThreeAndCompletion() throws { + let src = h.root.appendingPathComponent("capture.mp4") + writeFile(src, bytes: 50) + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5, source: src)).get() + XCTAssertFalse(FileIO.exists(src), "moved into the library") + XCTAssertEqual(h.sink.stateNames, ["queued", "running"]) + XCTAssertEqual(h.transport.live.count, 3, "window of 3") + let first = try XCTUnwrap(partTask(0)) + XCTAssertEqual(first.request.httpMethod, "PUT") + XCTAssertEqual(first.header("Content-Range"), "0-9/50") + XCTAssertEqual(first.header("Content-Type"), "video/mp4", "descriptor headers under part headers") + XCTAssertNotNil(first.header("X-Request-Id")) + XCTAssertEqual(FileIO.size(first.file!), 10) + + h.complete(first) + XCTAssertEqual(h.transport.live.count, 3, "refilled") + XCTAssertNotNil(partTask(3)) + XCTAssertEqual(h.sink.progress.last?["bytesSent"] as? Int64, 10) + XCTAssertEqual(h.entry("cap")?.parts[0].accepted, true) + XCTAssertEqual(h.store.load("cap")?.parts[0].accepted, true, "accept is persisted") + + for i in 1...4 { h.complete(try XCTUnwrap(partTask(i))) } + XCTAssertEqual(h.entry("cap")?.state, .completed) + let settled = try XCTUnwrap(h.sink.settled.last) + XCTAssertEqual(settled["partIndex"] as? Int, 4) + XCTAssertEqual(settled["url"] as? String, "https://s3.test/part5") + XCTAssertEqual(settled["method"] as? String, "PUT") + XCTAssertEqual(settled["attempts"] as? Int, 5) + XCTAssertNil((settled["response"] as? [String: Any])?["status"], "no single response") + XCTAssertEqual(h.sink.progress.last?["bytesSent"] as? Int64, 50) + h.ack([settled["eventId"] as! String]) + XCTAssertFalse(FileIO.exists(h.store.dir("cap"))) + } + + func testPart404WithEmptyExemptIsTerminalAndKeepsBytes() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", extra: ["retry": ["terminalHttp": ["exempt": []]]])).get() + let second = try XCTUnwrap(partTask(1)) + let others = h.transport.live.filter { $0 !== second } + h.complete(second, status: 404, body: "NoSuchUpload") + let error = try XCTUnwrap(h.sink.settled.last?["error"] as? [String: Any]) + XCTAssertEqual(error["errorKind"] as? String, "http") + XCTAssertEqual(error["partIndex"] as? Int, 1) + XCTAssertEqual((error["response"] as? [String: Any])?["status"] as? Int, 404) + XCTAssertTrue(others.allSatisfy(\.cancelled), "the other parts stop") + let blob = try XCTUnwrap(h.entry("cap")?.bodyPath) + XCTAssertTrue(FileIO.exists(h.store.fileURL("cap", blob)), "bytes survive a non-completed terminal") + } + + // The part-404 recovery: a same-id enqueue with new parts over the moved bytes. + func testRecreateOverKeptBlobWhenTheSourceIsGone() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", extra: ["retry": ["terminalHttp": ["exempt": []]]])).get() + h.complete(try XCTUnwrap(partTask(0))) + h.complete(try XCTUnwrap(partTask(1)), status: 404) + let old = try XCTUnwrap(h.entry("cap")) + let gone = h.root.appendingPathComponent("deleted-by-the-caller.mp4") + _ = try h.enqueue(h.chunkedRaw(id: "cap", source: gone, urlPrefix: "https://s3.test/new")).get() + let e = try XCTUnwrap(h.entry("cap")) + XCTAssertEqual(e.bodyPath, old.bodyPath, "the blob is kept") + XCTAssertNotEqual(e.incarnation, old.incarnation) + XCTAssertEqual(e.generation, old.generation + 1) + XCTAssertTrue(e.parts.allSatisfy { !$0.accepted }) + XCTAssertEqual(e.state, .running) + XCTAssertEqual(h.transport.live.count, 3) + XCTAssertEqual(partTask(0)?.request.url?.absoluteString, "https://s3.test/new1") + } + + func testResumeWithSamePartsKeepsAcceptedAndStartsNothingTwice() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + h.complete(try XCTUnwrap(partTask(0))) + let created = h.transport.created.count + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5, source: h.root.appendingPathComponent("x"))).get() + XCTAssertEqual(h.transport.created.count, created, "a live resume starts no task") + XCTAssertEqual(h.entry("cap")?.parts[0].accepted, true) + } + + func testDifferentPartsWhileRunningReject() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap")).get() + guard case .failure(let e) = h.enqueue(h.chunkedRaw(id: "cap", urlPrefix: "https://other.test/")) else { + return XCTFail() + } + XCTAssertEqual(e.code, "E_RUNNING") + } + + func testTilingMismatchRejectsStorageAndKeepsTheSource() throws { + let src = h.root.appendingPathComponent("short.mp4") + writeFile(src, bytes: 25) + guard case .failure(let e) = h.enqueue(h.chunkedRaw(id: "cap", size: 30, source: src)) else { return XCTFail() } + XCTAssertEqual(e.code, "E_STORAGE") + XCTAssertTrue(FileIO.exists(src)) + } + + func testTransientPartRetryHoldsItsSlotWithADelayedTask() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + let first = try XCTUnwrap(partTask(0)) + h.complete(first, status: 500) + let retry = try XCTUnwrap(partTask(0)) + XCTAssertTrue(retry !== first) + XCTAssertNotNil(retry.beginAt) + XCTAssertEqual(h.transport.live.count, 3, "no extra part enters the window") + XCTAssertEqual(h.entry("cap")?.parts[0].rejections, 1) + XCTAssertEqual(h.entry("cap")?.state, .running) + } + + func testRowShowsNextAttemptAtWhenEveryPartWaits() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 30, parts: 3)).get() + h.complete(try XCTUnwrap(partTask(0)), status: 503) + XCTAssertNil(h.row("cap")?["nextAttemptAt"], "two parts still move") + h.clock += 100 + h.complete(try XCTUnwrap(partTask(1)), status: 503) + let states = h.sink.states.count + h.clock += 100 + h.complete(try XCTUnwrap(partTask(2)), status: 503) + let first = h.clock - 200 + 1_000 + XCTAssertEqual(h.row("cap")?["state"] as? String, "running") + XCTAssertEqual(h.row("cap")?["nextAttemptAt"] as? Double, first, "the earliest begin") + XCTAssertEqual(h.store.load("cap")?.nextAttemptAt, first) + XCTAssertEqual(h.sink.states.count, states + 1, "one state event") + // The first delayed part begins: the entry moves again. + let begun = try XCTUnwrap(partTask(0)) + XCTAssertNotNil(h.coordinator.taskWillBegin(key: begun.key, description: begun.taskDescription)) + XCTAssertNil(h.row("cap")?["nextAttemptAt"]) + } + + func testRelaunchRestoresNextAttemptAtFromTheDelayedPartTasks() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 30, parts: 3)).get() + for i in 0..<3 { h.complete(try XCTUnwrap(partTask(i)), status: 503) } + let waiting = h.transport.live + let fresh = Harness(root: h.root) + fresh.clock = h.clock + 200 + let survivors = waiting.map { + FakeTask(key: $0.key, description: $0.taskDescription, request: $0.request, beginAt: $0.beginAt) + } + fresh.relaunch(daemonTasks: survivors) + fresh.boot() + XCTAssertEqual(fresh.row("cap")?["nextAttemptAt"] as? Double, h.clock + 1_000) + // Progress from a part that began while the app was dead clears it. + fresh.coordinator.taskProgress(key: survivors[1].key, description: survivors[1].taskDescription, + sent: 1, expected: 10) + fresh.drain() + XCTAssertNil(fresh.row("cap")?["nextAttemptAt"]) + } + + func testFailedPartSaveCreatesNoTaskAndRefillsAfterABackoff() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + let dir = h.store.dir("cap") + setReadOnly(dir, true) + defer { setReadOnly(dir, false) } + let attempts = h.entry("cap")?.attempts + h.complete(try XCTUnwrap(partTask(0)), status: 500) + XCTAssertNil(partTask(0), "no task without the saved attempt") + XCTAssertEqual(h.transport.live.count, 2) + XCTAssertEqual(h.entry("cap")?.attempts, attempts) + setReadOnly(dir, false) + h.advance(1_000) + XCTAssertNotNil(partTask(0)) + XCTAssertEqual(h.store.load("cap")?.attempts, (attempts ?? 0) + 1) + } + + func testPart401ParksTheWholeEntryAndHeadersResumeIt() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + h.complete(try XCTUnwrap(partTask(0))) + let rejected = try XCTUnwrap(partTask(1)) + let others = h.transport.live.filter { $0 !== rejected } + h.complete(rejected, status: 401) + XCTAssertEqual(h.entry("cap")?.state, .awaitingAuth) + XCTAssertEqual(others.count, 2) + XCTAssertTrue(others.allSatisfy(\.cancelled), "the other in-flight parts stop") + XCTAssertTrue(h.transport.live.isEmpty) + h.updateHeaders(["Authorization": "fresh"]) + XCTAssertEqual(h.entry("cap")?.state, .running) + XCTAssertEqual(h.transport.live.count, 3) + XCTAssertTrue(h.transport.live.allSatisfy { $0.header("Authorization") == "fresh" }) + XCTAssertNil(partTask(0), "accepted parts are not sent again") + } + + func testPauseKeepsAcceptedPartsAndResumeRefills() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + h.complete(try XCTUnwrap(partTask(0))) + h.pause() + XCTAssertTrue(h.transport.live.isEmpty) + XCTAssertEqual(h.entry("cap")?.state, .paused) + h.resume() + XCTAssertEqual(h.entry("cap")?.state, .running) + XCTAssertEqual(h.transport.live.count, 3) + XCTAssertNil(partTask(0)) + } + + func testCancelLiveChunked() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + h.complete(try XCTUnwrap(partTask(0))) + let live = h.transport.live + h.cancel("cap") + XCTAssertTrue(live.allSatisfy(\.cancelled)) + XCTAssertEqual(h.sink.settled.last?["kind"] as? String, "cancelled") + // A late accept from a cancelled part still records the bytes but reports nothing. + let settled = h.sink.settled.count + h.complete(live[0]) + XCTAssertEqual(h.sink.settled.count, settled) + h.ack([h.sink.settled.last!["eventId"] as! String]) + XCTAssertFalse(FileIO.exists(h.store.dir("cap"))) + } + + func testLateCallbackFromAReplacedPlanIsDropped() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", extra: ["retry": ["terminalHttp": ["exempt": []]]])).get() + let stale = try XCTUnwrap(partTask(2)) + h.complete(try XCTUnwrap(partTask(0)), status: 404) + _ = try h.enqueue(h.chunkedRaw(id: "cap", urlPrefix: "https://s3.test/v2-")).get() + h.complete(stale) // from the old incarnation + XCTAssertEqual(h.entry("cap")?.parts[2].accepted, false) + } + + func testShortBlobSettlesFile() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 30, parts: 3)).get() + let blob = h.store.fileURL("cap", h.entry("cap")!.bodyPath!) + try Data(count: 12).write(to: blob) + h.complete(try XCTUnwrap(partTask(0))) + let error = try XCTUnwrap(h.sink.settled.last?["error"] as? [String: Any]) + XCTAssertEqual(error["errorKind"] as? String, "file") + } +} + +/// Relaunch reconciliation and the v9 import. +final class CoordinatorRelaunchTests: XCTestCase { + private var h: Harness! + + override func setUp() { + h = Harness() + } + + override func tearDown() { + try? FileManager.default.removeItem(at: h.root) + } + + func testNothingIssuesBeforeReconcileThenReconcileIssues() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertTrue(h.transport.created.isEmpty, "not ready: nothing issues") + XCTAssertEqual(h.row("a")?["state"] as? String, "queued", "getRequests works before reconcile") + h.boot() + XCTAssertEqual(h.transport.live.count, 1) + XCTAssertEqual(h.entry("a")?.state, .running) + } + + func testRelaunchAdoptsTheLiveTaskAndItsCompletionSettles() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + // New process: the daemon still holds the task. + let survivor = FakeTask(key: task.key, description: task.taskDescription, request: task.request, file: task.file) + let fresh = Harness(root: h.root) + fresh.relaunch(daemonTasks: [survivor]) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty, "adopted, not re-issued") + XCTAssertEqual(fresh.row("a")?["state"] as? String, "running") + fresh.complete(survivor) + XCTAssertEqual(fresh.entry("a")?.state, .completed) + XCTAssertEqual(fresh.sink.settled.count, 1) + } + + func testRunningWithNoTaskAndNoTaskMapKeyReissuesNow() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + h.map.removeKey(task.key) // as if the completion was handled, or the task never made it + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertEqual(fresh.transport.created.count, 1) + XCTAssertEqual(fresh.entry("a")?.attempts, 2) + } + + func testRunningWithAPendingCompletionWaitsForTheReplay() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty, "the TaskMap key says a completion may be pending") + // The daemon replays the completion that happened while we were dead. + fresh.complete(FakeTask(key: task.key, description: task.taskDescription, request: task.request)) + XCTAssertEqual(fresh.entry("a")?.state, .completed) + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertTrue(fresh.transport.created.isEmpty, "no duplicate request") + } + + func testRunningWithALostTaskReissuesAfterTheGraceAndPrunesItsKey() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let lost = h.transport.live[0] + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty) + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertEqual(fresh.transport.created.count, 1) + // Fake keys restart per process, so check by attempt, not by key. + XCTAssertTrue(fresh.map.keys(where: { $0.id == "a" && $0.attempt == 1 }).isEmpty, "\(lost.key) pruned") + // The next launch does not wait the grace again for the same lost task. + let third = Harness(root: h.root) + third.boot() + XCTAssertTrue(third.map.keys(where: { $0.attempt == 1 }).isEmpty) + } + + func testReconcileDropsKeysWhoseIdHasNoEntry() throws { + h.boot() + h.map.set(.init(id: "ghost", attempt: 1, generation: 1, purpose: .attempt), forKey: "any:77") + let live = FakeTask(key: "any:78", description: ChunkedEngine.taskDescription(id: "ghost2", attempt: 1, generation: 1)) + h.map.set(.init(id: "ghost2", attempt: 1, generation: 1, purpose: .attempt), forKey: live.key) + let fresh = Harness(root: h.root) + fresh.relaunch(daemonTasks: [live]) + fresh.boot() + XCTAssertNil(fresh.map.meta(forKey: "any:77")) + XCTAssertEqual(fresh.map.meta(forKey: live.key)?.purpose, .superseded, "a live task keeps its key for its cancel") + } + + // The delayed task never reached the daemon (a crash between the save and + // resume): no TaskMap key. It never ran, so it keeps its ordinal and id. + func testDelayedRetryThatNeverReachedTheDaemonKeepsItsWaitAndOrdinal() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(h.transport.live[0], status: 503) + let waiting = h.transport.live[0] + h.map.removeKey(waiting.key) + let fresh = Harness(root: h.root) + fresh.clock = h.clock + 400 + fresh.boot() + let task = try XCTUnwrap(fresh.transport.live.first) + XCTAssertEqual(task.beginAt, Date(timeIntervalSince1970: (h.clock + 1_000) / 1000)) + XCTAssertEqual(fresh.entry("a")?.attempts, 2) + XCTAssertEqual(task.header("X-Request-Id"), waiting.header("X-Request-Id")) + } + + // The key says the delayed task may have run while the app was dead: wait + // for its replay, then mint a new attempt so a late replay is stale. + func testQueuedEntryWithAPendingKeyWaitsTheGraceThenMintsANewAttempt() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(h.transport.live[0], status: 503) + let waiting = h.transport.live[0] + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty, "a replay may be pending") + fresh.advance(Double(QueueCoordinator.graceMs)) + let task = try XCTUnwrap(fresh.transport.live.first) + XCTAssertEqual(fresh.entry("a")?.attempts, 3) + XCTAssertNotEqual(task.header("X-Request-Id"), waiting.header("X-Request-Id")) + XCTAssertTrue(fresh.map.keys(where: { $0.id == "a" && $0.attempt == 2 }).isEmpty, "the lost task's key is pruned") + // A late replay of the lost attempt is dropped. + fresh.complete(FakeTask(key: waiting.key, description: waiting.taskDescription, request: waiting.request)) + XCTAssertEqual(fresh.entry("a")?.state, .running) + } + + func testUnownedAndStaleTasksAreCancelled() throws { + let v9 = FakeTask(key: "any:90", description: "bare-v9-id") + let orphan = FakeTask(key: "any:91", description: ChunkedEngine.taskDescription(id: "gone", attempt: 1, generation: 1)) + h.relaunch(daemonTasks: [v9, orphan]) + h.boot() + XCTAssertTrue(v9.cancelled) + XCTAssertTrue(orphan.cancelled) + XCTAssertEqual(h.map.meta(forKey: "any:90")?.purpose, .superseded) + h.complete(v9, error: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) + XCTAssertTrue(h.sink.attempts.isEmpty) + XCTAssertTrue(h.sink.settled.isEmpty) + } + + func testChunkedRelaunchAdoptsLivePartsAndCancelsDuplicates() throws { + h.boot() + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + let parts = h.transport.live + let fresh = Harness(root: h.root) + let survivors = parts.map { FakeTask(key: $0.key, description: $0.taskDescription, request: $0.request) } + let duplicate = FakeTask(key: "any:99", description: parts[0].taskDescription) + fresh.relaunch(daemonTasks: survivors + [duplicate]) + fresh.boot() + XCTAssertTrue(duplicate.cancelled, "never two tasks for one part") + XCTAssertTrue(fresh.transport.created.isEmpty, "the window is full with the adopted tasks") + fresh.complete(survivors[0]) + XCTAssertEqual(fresh.transport.created.count, 1, "refill after the adopted part completes") + } + + func testV9ImportMakesLegacyRowsAndKeepsDormantManifests() throws { + // v9 state on disk before the first v10 launch: a journal entry, a + // half-done chunked manifest with no journal entry, and v9 task metadata. + let root = makeTempDir() + let v9Store = QueueStore(root: root.appendingPathComponent("queue")) + let v9Journal = EventJournal(root: root.appendingPathComponent("events")) + let v9Event = JournaledEventV9(eventId: "ev1", id: "transfer-1", type: "completed", timestamp: 100) + try JSONEncoder().encode(v9Event).write(to: v9Journal.root.appendingPathComponent("ev1.json")) + let manifest = ChunkedManifestV9(id: "cap-1", parts: [ + .init(url: "https://s3.test/part1", headers: [:], start: 0, end: 10, accepted: true), + .init(url: "https://s3.test/part2", headers: [:], start: 10, end: 20, accepted: false), + .init(url: "https://s3.test/part3", headers: [:], start: 20, end: 30, accepted: false), + ], accept: [], expiresAt: h.expiresAt, wifiOnly: false, createdAt: 1, incarnation: "v9inc") + writeFile(v9Store.fileURL("cap-1", QueueStore.manifestName), + String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!) + writeFile(v9Store.fileURL("cap-1", "blob"), bytes: 30) + TaskMap(fileURL: root.appendingPathComponent("taskmap.json")).set(.init(id: "transfer-1", accept: []), forKey: "any:5") + + h = Harness(root: root) // first v10 launch: the import runs in init + XCTAssertNotNil(h.row("transfer-1"), "legacy rows are in the index before any reconcile") + h.boot() + let legacy = try XCTUnwrap(h.row("transfer-1")) + XCTAssertEqual(legacy["key"] as? String, "legacy") + XCTAssertEqual(legacy["state"] as? String, "completed") + XCTAssertTrue(legacy["vars"] is NSNull) + XCTAssertNil(h.row("cap-1"), "a dormant manifest is not a row") + XCTAssertTrue(h.sink.settled.isEmpty, "nothing is delivered") + XCTAssertTrue(h.journal.legacyEvents().isEmpty) + XCTAssertNil(h.map.meta(forKey: "any:5")) + XCTAssertTrue(h.store.isImported()) + + // Diana's re-send with the same parts resumes the v9 bytes. + let resend = h.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: h.root.appendingPathComponent("gone"), + urlPrefix: "https://s3.test/part") + _ = try h.enqueue(resend).get() + let e = try XCTUnwrap(h.entry("cap-1")) + XCTAssertEqual(e.parts.map(\.accepted), [true, false, false]) + XCTAssertEqual(e.incarnation, "v9inc") + XCTAssertEqual(e.bodyPath, "blob") + XCTAssertNil(h.store.loadV9Manifest("cap-1"), "adopted") + XCTAssertEqual(h.transport.live.count, 2, "only the unaccepted parts") + + // Diana cancels the legacy row: gone now. + h.cancel("transfer-1") + XCTAssertNil(h.row("transfer-1")) + } + + /// v9 state with a journaled outcome for a chunked id whose manifest and + /// blob are still on disk. + private func legacyChunked(type: String, accepted: [Bool]) throws -> Harness { + let root = makeTempDir() + let v9Store = QueueStore(root: root.appendingPathComponent("queue")) + let v9Journal = EventJournal(root: root.appendingPathComponent("events")) + let v9Event = JournaledEventV9(eventId: "ev1", id: "cap-1", type: type, timestamp: 100) + try JSONEncoder().encode(v9Event).write(to: v9Journal.root.appendingPathComponent("ev1.json")) + let manifest = ChunkedManifestV9(id: "cap-1", parts: (0..<3).map { + .init(url: "https://s3.test/part\($0 + 1)", headers: [:], start: Int64($0 * 10), + end: Int64($0 * 10 + 10), accepted: accepted[$0]) + }, accept: [], expiresAt: h.expiresAt, wifiOnly: false, createdAt: 1, incarnation: "v9inc") + writeFile(v9Store.fileURL("cap-1", QueueStore.manifestName), + String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!) + writeFile(v9Store.fileURL("cap-1", "blob"), bytes: 30) + let fresh = Harness(root: root) + fresh.boot() + return fresh + } + + func testLegacyCompletedRowAdoptsTheV9BytesOnASameIdEnqueue() throws { + let l = try legacyChunked(type: "completed", accepted: [true, true, false]) + defer { try? FileManager.default.removeItem(at: l.root) } + XCTAssertEqual(l.row("cap-1")?["state"] as? String, "completed") + let gone = l.root.appendingPathComponent("gone") + _ = try l.enqueue(l.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: gone)).get() + let e = try XCTUnwrap(l.entry("cap-1")) + XCTAssertFalse(e.legacy) + XCTAssertEqual(e.bodyPath, "blob") + XCTAssertEqual(e.parts.map(\.accepted), [true, true, false], "resumes, not E_FILE_MISSING") + XCTAssertEqual(l.transport.live.count, 1) + } + + func testLegacyErrorRowWithNewPartsKeepsTheV9Blob() throws { + let l = try legacyChunked(type: "error", accepted: [true, false, false]) + defer { try? FileManager.default.removeItem(at: l.root) } + let gone = l.root.appendingPathComponent("gone") + _ = try l.enqueue(l.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: gone, + urlPrefix: "https://s3.test/new")).get() + let e = try XCTUnwrap(l.entry("cap-1")) + XCTAssertEqual(e.bodyPath, "blob") + XCTAssertTrue(e.parts.allSatisfy { !$0.accepted }, "a new plan starts over on the kept bytes") + XCTAssertEqual(l.transport.live.count, 3) + } + + func testImportRunsOnce() throws { + h.boot() + let late = JournaledEventV9(eventId: "late", id: "t", type: "error", timestamp: 1) + try JSONEncoder().encode(late).write(to: h.journal.root.appendingPathComponent("late.json")) + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertNil(fresh.row("t"), "the marker stops a second import") + } +} diff --git a/ios/Tests/CoordinatorSimpleTests.swift b/ios/Tests/CoordinatorSimpleTests.swift new file mode 100644 index 00000000..d8a31608 --- /dev/null +++ b/ios/Tests/CoordinatorSimpleTests.swift @@ -0,0 +1,519 @@ +import XCTest +@testable import RNBGUCore + +/// State transitions of simple (one-body) entries, over the fake transport. +final class CoordinatorSimpleTests: XCTestCase { + private var h: Harness! + + override func setUp() { + h = Harness() + h.boot() + } + + override func tearDown() { + try? FileManager.default.removeItem(at: h.root) + } + + private func onlyTask(file: StaticString = #filePath, line: UInt = #line) -> FakeTask { + XCTAssertEqual(h.transport.live.count, 1, file: file, line: line) + return h.transport.live.last! + } + + // MARK: - Create and complete + + func testEnqueueWritesAheadThenIssues() throws { + XCTAssertEqual(try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "t1"])).get(), "a") + XCTAssertEqual(h.sink.stateNames, ["queued", "running"]) + let task = onlyTask() + XCTAssertEqual(task.request.httpMethod, "POST") + XCTAssertEqual(task.header("Authorization"), "t1") + XCTAssertEqual(task.header("Content-Type"), "application/json") + XCTAssertNotNil(task.header("X-Request-Id")) + XCTAssertEqual(try String(contentsOf: task.file!), #"{"x":1}"#) + let stored = try XCTUnwrap(h.store.load("a")) + XCTAssertEqual(stored.state, .running) + XCTAssertEqual(stored.attempts, 1) + XCTAssertEqual(stored.lastRequestId, task.header("X-Request-Id")) + XCTAssertEqual(h.map.meta(forKey: task.key)?.purpose, .attempt) + } + + func testExistingContentTypeInAnyCaseIsKept() throws { + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["content-type": "application/vnd.x+json"])).get() + XCTAssertEqual(onlyTask().header("Content-Type"), "application/vnd.x+json") + } + + func testCompletedJournalsThenEmitsAndAckForgets() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 201, body: #"{"ok":true}"#, headers: ["X-A": "1"]) + XCTAssertEqual(h.sink.stateNames.last, "completed") + XCTAssertEqual(h.sink.attempts.last?["outcome"] as? String, "completed") + let settled = try XCTUnwrap(h.sink.settled.last) + XCTAssertEqual(settled["kind"] as? String, "completed") + XCTAssertEqual(settled["deliveries"] as? Int, 1) + XCTAssertEqual(settled["url"] as? String, "https://api.test/x") + XCTAssertEqual(settled["method"] as? String, "POST") + XCTAssertEqual(settled["attempts"] as? Int, 1) + let response = try XCTUnwrap(settled["response"] as? [String: Any]) + XCTAssertEqual(response["status"] as? Int, 201) + XCTAssertEqual(response["body"] as? String, #"{"ok":true}"#) + let eventId = try XCTUnwrap(settled["eventId"] as? String) + XCTAssertNotNil(h.journal.load(eventId), "journaled") + XCTAssertEqual(h.row("a")?["state"] as? String, "completed", "row stays until the ack") + + h.ack([eventId, "unknown"]) + XCTAssertNil(h.row("a")) + XCTAssertFalse(FileIO.exists(h.store.dir("a")), "row and bytes go after the ack") + h.ack([eventId]) // idempotent + } + + func testUnacknowledgedCountsDeliveries() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask()) + XCTAssertEqual(h.unacknowledged().first?["deliveries"] as? Int, 2) + XCTAssertEqual(h.unacknowledged().first?["deliveries"] as? Int, 3) + } + + // MARK: - Same-id rules + + func testRule7CompletedUnackedReemitsWithoutRunning() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask()) + let first = h.sink.settled.count + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertEqual(h.sink.settled.count, first + 1) + XCTAssertEqual(h.sink.settled.last?["deliveries"] as? Int, 2) + XCTAssertEqual(h.sink.settled.last?["eventId"] as? String, h.sink.settled[first - 1]["eventId"] as? String) + XCTAssertTrue(h.transport.live.isEmpty, "no second run") + } + + func testRule3SameBodyWhileRunningReplacesHeadersAndVars() throws { + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "old"])).get() + var raw = h.dataRaw(id: "a", headers: ["Authorization": "new"]) + raw["vars"] = ["n": 2] + _ = try h.enqueue(raw).get() + XCTAssertEqual(h.transport.created.count, 1, "the in-flight task keeps its request") + XCTAssertEqual(h.entry("a")?.headers["Authorization"], "new") + XCTAssertEqual((h.row("a")?["vars"] as? [String: Int])?["n"], 2) + XCTAssertEqual(h.entry("a")?.attempts, 1, "a live resume keeps the attempt ordinal") + } + + func testRule5DifferentBodyWhileRunningRejects() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + guard case .failure(let e) = h.enqueue(h.dataRaw(id: "a", data: ["x": 2])) else { return XCTFail() } + XCTAssertEqual(e.code, "E_RUNNING") + XCTAssertEqual(h.entry("a")?.bodyFingerprint, h.store.load("a")?.bodyFingerprint, "entry untouched") + } + + func testRule4DifferentBodyWhileWaitingReplacesAndSupersedes() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 503) + let delayed = onlyTask() + XCTAssertNotNil(delayed.beginAt) + let oldBody = try XCTUnwrap(h.entry("a")?.bodyPath) + _ = try h.enqueue(h.dataRaw(id: "a", data: ["x": 2])).get() + XCTAssertTrue(delayed.cancelled) + XCTAssertEqual(h.map.meta(forKey: delayed.key)?.purpose, .superseded) + let e = try XCTUnwrap(h.entry("a")) + XCTAssertEqual(e.generation, 2) + XCTAssertEqual(e.attempts, 1) + XCTAssertFalse(FileIO.exists(h.store.fileURL("a", oldBody)), "the old body is deleted after the save") + let fresh = onlyTask() + XCTAssertEqual(try String(contentsOf: fresh.file!), #"{"x":2}"#) + // The superseded task's cancel callback produces nothing. + let attempts = h.sink.attempts.count + h.deliverCancel(delayed) + XCTAssertEqual(h.sink.attempts.count, attempts) + XCTAssertEqual(h.entry("a")?.state, .running) + } + + func testRule6CancelledUnackedReopensUnderAFreshGeneration() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.cancel("a") + let cancelled = try XCTUnwrap(h.sink.settled.last) + XCTAssertEqual(cancelled["cancelReason"] as? String, "user") + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertEqual(h.entry("a")?.generation, 2) + XCTAssertEqual(h.entry("a")?.state, .running) + h.ack([cancelled["eventId"] as! String]) + XCTAssertNotNil(h.row("a"), "the old generation's ack does not forget the reopened entry") + } + + func testErrorEntryReopensOnSameBody() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 400, body: "bad") + XCTAssertEqual(h.entry("a")?.state, .error) + let error = try XCTUnwrap(h.sink.settled.last?["error"] as? [String: Any]) + XCTAssertEqual(error["errorKind"] as? String, "http") + XCTAssertEqual((error["response"] as? [String: Any])?["status"] as? Int, 400) + h.ack([h.sink.settled.last!["eventId"] as! String]) + XCTAssertEqual(h.row("a")?["state"] as? String, "error", "an acked error keeps the row") + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertEqual(h.entry("a")?.state, .running) + XCTAssertEqual(h.entry("a")?.generation, 2) + } + + func testMissingFileRejectsFileMissingAndParseErrorRejectsStorage() { + guard case .failure(let missing) = h.enqueue(h.raw(id: "f", descriptor: [ + "url": "https://a.test", "file": "/does/not/exist"])) else { return XCTFail() } + XCTAssertEqual(missing.code, "E_FILE_MISSING") + XCTAssertNil(h.row("f")) + guard case .failure(let bad) = h.enqueue(["id": "b", "key": "k", "descriptor": ["url": "https://a.test"]]) + else { return XCTFail() } + XCTAssertEqual(bad.code, "E_STORAGE", "no expiresAt") + } + + // MARK: - Cancel + + func testCancelLiveThenSettled() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = onlyTask() + h.cancel("a") + XCTAssertTrue(task.cancelled) + XCTAssertEqual(h.sink.stateNames.last, "cancelled") + XCTAssertEqual(h.sink.settled.last?["kind"] as? String, "cancelled") + let attempts = h.sink.attempts.count + h.deliverCancel(task) + XCTAssertEqual(h.sink.attempts.count, attempts, "the library's own cancel is not an attempt") + XCTAssertEqual(h.sink.settled.count, 1, "one outcome") + h.ack([h.sink.settled.last!["eventId"] as! String]) + XCTAssertNil(h.row("a")) + } + + func testCancelSettledForgetsNowWithItsEvents() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 400) + h.cancel("a") + XCTAssertNil(h.row("a")) + XCTAssertFalse(FileIO.exists(h.store.dir("a"))) + XCTAssertTrue(h.journal.unacknowledged().isEmpty) + h.cancel("unknown") // no-op + } + + // MARK: - Retry, backoff, expiry + + func testTransientSchedulesADelayedTask() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 503) + XCTAssertEqual(h.sink.attempts.last?["outcome"] as? String, "error") + XCTAssertEqual(h.sink.attempts.last?["httpCode"] as? Int, 503) + let e = try XCTUnwrap(h.entry("a")) + XCTAssertEqual(e.state, .queued) + XCTAssertEqual(e.nextAttemptAt, h.clock + 1_000) + XCTAssertEqual(h.row("a")?["nextAttemptAt"] as? Double, h.clock + 1_000) + let retry = onlyTask() + XCTAssertEqual(retry.beginAt, Date(timeIntervalSince1970: (h.clock + 1_000) / 1000)) + XCTAssertEqual(e.attempts, 2) + h.complete(retry, status: 503) + XCTAssertEqual(h.entry("a")?.nextAttemptAt, h.clock + 2_000, "backoff doubles") + h.complete(onlyTask(), status: 200) + XCTAssertEqual(h.sink.settled.last?["attempts"] as? Int, 3) + } + + func testDelayedBeginMovesToRunningWithCurrentHeaders() throws { + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "old"])).get() + h.complete(onlyTask(), status: 500) + let delayed = onlyTask() + h.updateHeaders(["authorization": "fresh"]) + let request = try XCTUnwrap(h.coordinator.taskWillBegin(key: delayed.key, description: delayed.taskDescription)) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "fresh") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Request-Id"), delayed.header("X-Request-Id")) + XCTAssertEqual(h.entry("a")?.state, .running) + XCTAssertNil(h.entry("a")?.nextAttemptAt) + XCTAssertEqual(h.map.meta(forKey: delayed.key)?.headerGeneration, 1) + } + + func testDelayedBeginIsCancelledWhenTheEntryMovedOn() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 500) + let delayed = onlyTask() + h.pause() + XCTAssertNil(h.coordinator.taskWillBegin(key: delayed.key, description: delayed.taskDescription)) + } + + func testFirstProgressOfADelayedTaskMovesItToRunning() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 500) + let delayed = onlyTask() + h.coordinator.taskProgress(key: delayed.key, description: delayed.taskDescription, sent: 3, expected: 7) + h.drain() + XCTAssertEqual(h.entry("a")?.state, .running) + XCTAssertEqual(h.sink.progress.last?["bytesSent"] as? Int64, 3) + XCTAssertEqual(h.sink.progress.last?["totalBytes"] as? Int64, 7) + } + + func testSystemCancelIsAnAttemptAndARetry() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.deliverCancel(onlyTask()) + XCTAssertEqual(h.sink.attempts.last?["outcome"] as? String, "cancelled") + XCTAssertEqual(h.sink.attempts.last?["cancelReason"] as? String, "system") + XCTAssertTrue(h.sink.settled.isEmpty, "never a cancelled outcome") + XCTAssertEqual(h.entry("a")?.state, .queued) + XCTAssertNotNil(onlyTask().beginAt) + } + + func testBackoffPastExpiresAtSettlesExpired() throws { + _ = try h.enqueue(h.dataRaw(id: "a", extra: ["expiresAt": h.clock + 500])).get() + h.complete(onlyTask(), status: 503) + let error = try XCTUnwrap(h.sink.settled.last?["error"] as? [String: Any]) + XCTAssertEqual(error["errorKind"] as? String, "expired") + XCTAssertTrue(FileIO.exists(h.store.dir("a")), "bytes stay") + } + + func testExpiryTimerSettlesAWaitingEntry() throws { + _ = try h.enqueue(h.dataRaw(id: "a", extra: ["expiresAt": h.clock + 60_000])).get() + let task = onlyTask() + h.advance(60_200) + XCTAssertEqual(h.entry("a")?.state, .error) + XCTAssertTrue(task.cancelled) + XCTAssertEqual((h.sink.settled.last?["error"] as? [String: Any])?["errorKind"] as? String, "expired") + } + + func testExpiredAtIssue() throws { + h.pause() + _ = try h.enqueue(h.dataRaw(id: "a", extra: ["expiresAt": h.clock + 10])).get() + h.clock += 20 + h.resume() + XCTAssertEqual(h.entry("a")?.state, .error) + } + + func testFileMissingAtCompletionIsTerminal() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = onlyTask() + try FileManager.default.removeItem(at: task.file!) + h.complete(task, error: NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist)) + XCTAssertEqual((h.sink.settled.last?["error"] as? [String: Any])?["errorKind"] as? String, "file") + } + + func testUnreadableFileIsTransient() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNoPermissionsToReadFile)) + XCTAssertEqual(h.entry("a")?.state, .queued) + XCTAssertEqual(h.sink.attempts.last?["errorKind"] as? String, "file") + } + + func testAcceptRuleWithBodyIncludes() throws { + _ = try h.enqueue(h.dataRaw(id: "a", extra: ["accept": [["status": 409, "bodyIncludes": "already completed"]]])).get() + h.complete(onlyTask(), status: 409, body: "upload already completed") + XCTAssertEqual(h.entry("a")?.state, .completed) + } + + func testDefault404IsTransientAndExemptOverride() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 404) + XCTAssertEqual(h.entry("a")?.state, .queued) + h.coordinator.configure(["retry": ["terminalHttp": ["exempt": []]]]) + h.drain() + _ = try h.enqueue(h.dataRaw(id: "b")).get() + h.complete(h.transport.live.last!, status: 404) + XCTAssertEqual(h.entry("b")?.state, .error) + } + + // MARK: - Auth + + func testAuthParksAndUpdateHeadersResumes() throws { + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "expired"])).get() + _ = try h.enqueue(h.dataRaw(id: "b", headers: ["Authorization": "expired"])).get() + let tasks = h.transport.live + let statesBefore = h.sink.states.count + tasks.forEach { h.complete($0, status: 401) } + XCTAssertEqual(h.sink.states.count, statesBefore + 2, "one state event per parked entry") + XCTAssertEqual(h.entry("a")?.state, .awaitingAuth) + XCTAssertTrue(h.transport.live.isEmpty, "no retry while parked") + h.updateHeaders(["authorization": "fresh"]) + XCTAssertEqual(h.transport.live.count, 2) + XCTAssertTrue(h.transport.live.allSatisfy { $0.header("Authorization") == "fresh" }) + XCTAssertEqual(h.entry("a")?.headers, ["authorization": "fresh"], "the old spelling is replaced") + XCTAssertEqual(h.entry("a")?.state, .running) + } + + func testAuthUnderAnOlderGenerationReissuesAtOnce() throws { + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "old"])).get() + let first = onlyTask() + h.updateHeaders(["Authorization": "new"]) + h.complete(first, status: 401) + XCTAssertEqual(h.entry("a")?.state, .running, "not parked") + let second = onlyTask() + XCTAssertEqual(second.header("Authorization"), "new") + XCTAssertNil(second.beginAt, "no backoff") + } + + // MARK: - Pause, resume, wifi + + func testPauseProducesNoOutcomeAndResumeReissues() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = onlyTask() + h.pause() + XCTAssertTrue(task.cancelled) + XCTAssertEqual(h.map.meta(forKey: task.key)?.purpose, .pause) + XCTAssertEqual(h.entry("a")?.state, .paused) + h.deliverCancel(task) + XCTAssertTrue(h.sink.settled.isEmpty) + XCTAssertEqual(h.sink.attempts.count, 0) + // A new enqueue while paused is created paused and issues nothing. + _ = try h.enqueue(h.dataRaw(id: "b")).get() + XCTAssertEqual(h.entry("b")?.state, .paused) + XCTAssertTrue(h.transport.live.isEmpty) + h.resume() + XCTAssertEqual(h.transport.live.count, 2) + XCTAssertEqual(h.entry("a")?.attempts, 2) + } + + func testPausedSettingSurvivesRelaunch() throws { + h.pause() + h.relaunch() + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertEqual(h.entry("a")?.state, .paused) + } + + func testPauseKeepsAuthParkingAcrossResume() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 403) + h.pause() + XCTAssertEqual(h.entry("a")?.state, .paused) + h.resume() + XCTAssertEqual(h.entry("a")?.state, .awaitingAuth) + XCTAssertTrue(h.transport.live.isEmpty) + } + + func testAcceptedCompletionRacingAPauseSettles() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = onlyTask() + h.pause() + h.complete(task, status: 200) // the response landed before the cancel took effect + XCTAssertEqual(h.entry("a")?.state, .completed) + } + + func testWifiOnlyPicksTheSessionAndMovesAWaitingRetry() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertFalse(onlyTask().wifiOnly) + h.complete(onlyTask(), status: 503) + let waiting = onlyTask() + h.setWifiOnly(true) + XCTAssertTrue(waiting.cancelled) + XCTAssertTrue(onlyTask().wifiOnly) + XCTAssertNotNil(onlyTask().beginAt, "the wait carries over") + _ = try h.enqueue(h.dataRaw(id: "b")).get() + XCTAssertTrue(h.transport.live.last!.wifiOnly) + } + + func testWifiToggleKeepsTheWaitingAttemptOrdinalAndBackoff() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 503) + let waiting = onlyTask() + XCTAssertEqual(h.entry("a")?.attempts, 2) + h.setWifiOnly(true) + h.setWifiOnly(false) + XCTAssertEqual(h.entry("a")?.attempts, 2, "no HTTP attempt ran") + XCTAssertEqual(h.store.load("a")?.attempts, 2) + XCTAssertEqual(onlyTask().header("X-Request-Id"), waiting.header("X-Request-Id")) + h.complete(onlyTask(), status: 503) + XCTAssertEqual(h.entry("a")?.nextAttemptAt, h.clock + 2_000, "the exponent follows real attempts") + } + + func testSupersededTaskThatBeginsLateIsCancelled() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 503) + let waiting = onlyTask() + h.setWifiOnly(true) + XCTAssertNil(h.coordinator.taskWillBegin(key: waiting.key, description: waiting.taskDescription), + "same ordinal, but replaced") + XCTAssertEqual(h.entry("a")?.state, .queued) + } + + // MARK: - Write-ahead failures + + func testFailedAttemptSaveCreatesNoTaskAndIssuesAfterABackoff() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let dir = h.store.dir("a") + setReadOnly(dir, true) + defer { setReadOnly(dir, false) } + let created = h.transport.created.count + h.complete(onlyTask(), status: 503) + XCTAssertEqual(h.transport.created.count, created, "no task without the saved attempt") + XCTAssertEqual(h.entry("a")?.state, .queued) + XCTAssertEqual(h.entry("a")?.attempts, 1, "the index matches the disk") + XCTAssertEqual(h.store.load("a")?.attempts, 1) + setReadOnly(dir, false) + h.advance(1_000) + let retry = onlyTask() + XCTAssertEqual(h.store.load("a")?.attempts, 2) + XCTAssertEqual(h.store.load("a")?.lastRequestId, retry.header("X-Request-Id")) + } + + // MARK: - Outcomes whose journal file is missing + + func testFailedJournalWriteStillEmitsAndTheAckForgets() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + setReadOnly(h.journal.root, true) + defer { setReadOnly(h.journal.root, false) } + h.complete(onlyTask()) + let settled = try XCTUnwrap(h.sink.settled.last) + let eventId = try XCTUnwrap(settled["eventId"] as? String) + XCTAssertNil(h.journal.load(eventId)) + XCTAssertEqual(settled["deliveries"] as? Int, 1) + XCTAssertEqual(h.unacknowledged().first?["eventId"] as? String, eventId, "kept in memory") + h.ack([eventId]) + XCTAssertNil(h.row("a")) + XCTAssertFalse(FileIO.exists(h.store.dir("a"))) + } + + func testFailedJournalWriteIsRetriedUntilItLands() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + setReadOnly(h.journal.root, true) + h.complete(onlyTask()) + let eventId = try XCTUnwrap(h.sink.settled.last?["eventId"] as? String) + h.advance(Double(QueueCoordinator.journalRetryMs)) // still read-only: waits twice as long + XCTAssertNil(h.journal.load(eventId)) + setReadOnly(h.journal.root, false) + h.advance(Double(QueueCoordinator.journalRetryMs * 2)) + XCTAssertEqual(h.journal.load(eventId)?.deliveries, 1) + XCTAssertTrue(h.coordinator.pendingJournal.isEmpty) + } + + func testRelaunchForgetsSettledRowsWhoseOutcomeFileIsGone() throws { + _ = try h.enqueue(h.dataRaw(id: "done")).get() + h.complete(onlyTask()) + _ = try h.enqueue(h.dataRaw(id: "gone")).get() + h.cancel("gone") + _ = try h.enqueue(h.dataRaw(id: "bad")).get() + h.complete(h.transport.live.last!, status: 400) + // A crash between the ack's delete and the forget, for each row. + h.journal.ack(h.sink.settled.compactMap { $0["eventId"] as? String }) + h.relaunch() + h.boot() + XCTAssertNil(h.row("done")) + XCTAssertFalse(FileIO.exists(h.store.dir("done")), "its bytes go too") + XCTAssertNil(h.row("gone")) + XCTAssertEqual(h.row("bad")?["state"] as? String, "error", "an error row stays until cancel()") + } + + func testAckOfAPrunedEventStillForgetsTheRow() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask()) + let eventId = try XCTUnwrap(h.sink.settled.last?["eventId"] as? String) + h.journal.ack([eventId]) // the file is gone, as a prune would leave it + h.ack([eventId]) + XCTAssertNil(h.row("a")) + } + + func testDataNullSendsTheJSONNullBody() throws { + _ = try h.enqueue(h.dataRaw(id: "a", data: NSNull())).get() + let task = onlyTask() + XCTAssertEqual(try String(contentsOf: task.file!), "null") + XCTAssertEqual(task.header("Content-Type"), "application/json") + } + + // MARK: - Rows + + func testGetRequestsRows() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let row = try XCTUnwrap(h.row("a")) + XCTAssertEqual(row["key"] as? String, "k") + XCTAssertEqual(row["state"] as? String, "running") + XCTAssertEqual(row["attempts"] as? Int, 1) + XCTAssertNil(row["nextAttemptAt"]) + XCTAssertEqual((row["vars"] as? [String: Int])?["n"], 1) + XCTAssertEqual(row["totalBytes"] as? Int64, 7) + } +} diff --git a/ios/Tests/QueueEntryTests.swift b/ios/Tests/QueueEntryTests.swift new file mode 100644 index 00000000..ac583515 --- /dev/null +++ b/ios/Tests/QueueEntryTests.swift @@ -0,0 +1,231 @@ +import XCTest +@testable import RNBGUCore + +final class EnqueueParserTests: XCTestCase { + private func raw(_ descriptor: [String: Any], vars: Any = ["a": 1]) -> [String: Any] { + var d = descriptor + if d["expiresAt"] == nil { d["expiresAt"] = 2_000_000_000_000.0 } + return ["id": "id-1", "key": "k", "vars": vars, "descriptor": d] + } + + func testDataBodyDefaultsToPost() throws { + let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "data": ["b": 2, "a": 1]])) + XCTAssertEqual(p.method, "POST") + guard case .data(let json) = p.body else { return XCTFail("expected data") } + XCTAssertEqual(json, #"{"a":1,"b":2}"#) + XCTAssertEqual(p.varsJSON, #"{"a":1}"#) + } + + func testBodilessDescriptor() throws { + let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "method": "DELETE"])) + guard case .none = p.body else { return XCTFail("expected none") } + XCTAssertEqual(p.method, "DELETE") + XCTAssertEqual(p.fingerprint, "none") + } + + func testNullVarsAndNSNullFieldsAreAbsent() throws { + let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "file": NSNull(), "form": NSNull()], + vars: NSNull())) + XCTAssertEqual(p.varsJSON, "null") + guard case .none = p.body else { return XCTFail("NSNull must read as absent") } + } + + func testDataNullIsAJSONNullBody() throws { + let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "data": NSNull(), "file": NSNull()])) + guard case .data(let json) = p.body else { return XCTFail("data: null is a body") } + XCTAssertEqual(json, "null") + XCTAssertEqual(p.fingerprint, "data:" + JSONText.sha256("null")) + XCTAssertThrowsError(try EnqueueParser.parse(raw(["url": "https://a.test/x", "data": NSNull(), + "file": "/tmp/a"])), "two body kinds") + } + + func testFormAndFile() throws { + let form = try EnqueueParser.parse(raw(["url": "https://a.test/x", "form": [ + ["name": "request", "contentType": "application/json", "string": "{}"], + ["name": "image", "contentType": "image/jpeg", "path": "/tmp/a.jpg"], + ]])) + guard case .form(let fields) = form.body else { return XCTFail("expected form") } + XCTAssertEqual(fields.count, 2) + XCTAssertEqual(fields[1].path, "/tmp/a.jpg") + + let file = try EnqueueParser.parse(raw(["url": "https://a.test/x", "file": "file:///tmp/a.bin"])) + guard case .file(let path) = file.body else { return XCTFail("expected file") } + XCTAssertEqual(path, "file:///tmp/a.bin") + XCTAssertEqual(file.fingerprint, "file:file:///tmp/a.bin") + } + + func testMissingUrlWithoutPartsThrows() { + XCTAssertThrowsError(try EnqueueParser.parse(raw(["data": ["a": 1]]))) + } + + func testPartsNeedNoUrlAndValidateRanges() throws { + let ok = try EnqueueParser.parse(raw(["file": "/tmp/f", "parts": [ + ["url": "https://s3.test/1", "range": ["start": 0, "end": 10]], + ]])) + XCTAssertNil(ok.url) + XCTAssertEqual(ok.parts.count, 1) + XCTAssertThrowsError(try EnqueueParser.parse(raw(["file": "/tmp/f", "parts": [ + ["url": "https://s3.test/1", "range": ["start": 5, "end": 5]], + ]]))) + XCTAssertThrowsError(try EnqueueParser.parse(raw(["parts": [ + ["url": "https://s3.test/1", "range": ["start": 0, "end": 5]], + ]])), "parts requires file") + } + + func testTwoBodyKindsThrow() { + XCTAssertThrowsError(try EnqueueParser.parse(raw(["url": "https://a.test", "data": [:], "file": "/tmp/a"]))) + } + + func testHeadersKeepStringsAndNumbersOnly() { + let h = EnqueueParser.headers(["A": "x", "B": 3, "C": NSNull(), "D": ["nested": 1]]) + XCTAssertEqual(h, ["A": "x", "B": "3"]) + } + + func testDataFingerprintIgnoresKeyOrder() throws { + let a = try EnqueueParser.parse(raw(["url": "https://a.test", "data": ["x": 1, "y": ["b": 1, "a": 2]]])) + let b = try EnqueueParser.parse(raw(["url": "https://a.test", "data": ["y": ["a": 2, "b": 1], "x": 1]])) + let c = try EnqueueParser.parse(raw(["url": "https://a.test", "data": ["x": 2]])) + XCTAssertEqual(a.fingerprint, b.fingerprint) + XCTAssertNotEqual(a.fingerprint, c.fingerprint) + } + + func testFormFingerprintComparesFieldsAsSent() throws { + let f1: [[String: Any]] = [["name": "a", "contentType": "t", "path": "/p1"]] + let f2: [[String: Any]] = [["name": "a", "contentType": "t", "path": "/p2"]] + let a = try EnqueueParser.parse(raw(["url": "https://a.test", "form": f1])) + let b = try EnqueueParser.parse(raw(["url": "https://a.test", "form": f1])) + let c = try EnqueueParser.parse(raw(["url": "https://a.test", "form": f2])) + XCTAssertEqual(a.fingerprint, b.fingerprint) + XCTAssertNotEqual(a.fingerprint, c.fingerprint) + } + + func testPartsFingerprintIgnoresFilePath() throws { + let parts: [[String: Any]] = [["url": "https://s3.test/1", "range": ["start": 0, "end": 4]]] + let a = try EnqueueParser.parse(raw(["file": "/tmp/one", "parts": parts])) + let b = try EnqueueParser.parse(raw(["file": "/tmp/two", "parts": parts])) + let c = try EnqueueParser.parse(raw(["file": "/tmp/one", "parts": [ + ["url": "https://s3.test/other", "range": ["start": 0, "end": 4]]]])) + XCTAssertEqual(a.fingerprint, b.fingerprint) + XCTAssertNotEqual(a.fingerprint, c.fingerprint) + } + + func testRetryOverrideParse() throws { + let p = try EnqueueParser.parse(raw(["url": "https://a.test", "retry": ["terminalHttp": ["exempt": []]]])) + XCTAssertEqual(p.retry, RetryOverride(exempt: [])) + XCTAssertEqual(RetryPolicy.resolve([nil, p.retry]).exempt, []) + XCTAssertEqual(RetryPolicy.resolve([nil, p.retry]).baseMs, 1_000) + } +} + +final class QueueEntryTests: XCTestCase { + private func parsed(_ descriptor: [String: Any], id: String = "e1", vars: Any = ["v": 1]) -> ParsedEnqueue { + var d = descriptor + if d["expiresAt"] == nil { d["expiresAt"] = 5_000.0 } + return try! EnqueueParser.parse(["id": id, "key": "k", "vars": vars, "descriptor": d]) + } + + private let staged = StagedBody(kind: .parts, relativePath: "blob-1", contentType: nil, + forceContentType: false, totalBytes: 20, adopted: false) + + private func chunked() -> QueueEntry { + let p = parsed(["file": "/f", "parts": [ + ["url": "https://s3.test/1", "range": ["start": 0, "end": 10]], + ["url": "https://s3.test/2", "range": ["start": 10, "end": 20]], + ]]) + return QueueEntry.created(from: p, staged: staged, headerGeneration: 3, paused: false, now: 100) + } + + func testCreatedDefaults() { + let e = chunked() + XCTAssertEqual(e.state, .queued) + XCTAssertEqual(e.generation, 1) + XCTAssertEqual(e.headerGeneration, 3) + XCTAssertEqual(e.totalBytes, 20) + XCTAssertEqual(e.bodyPath, "blob-1") + let paused = QueueEntry.created(from: parsed(["url": "https://a.test"]), staged: staged, + headerGeneration: 0, paused: true, now: 1) + XCTAssertEqual(paused.state, .paused) + } + + func testResumedKeepsAcceptedAndGeneration() { + var e = chunked().withPartAccepted(0) + e.parts[1].rejections = 4 + e.attempts = 7 + e.generation = 2 + let incoming = parsed(["file": "/f", "headers": ["Authorization": "new"], "expiresAt": 9_000.0, "parts": [ + ["url": "https://s3.test/1", "range": ["start": 0, "end": 10]], + ["url": "https://s3.test/2", "headers": ["X": "y"], "range": ["start": 10, "end": 20]], + ]], vars: ["v": 2]) + let reset = e.resumed(with: incoming, resetBudget: true, now: 200) + XCTAssertTrue(reset.parts[0].accepted) + XCTAssertEqual(reset.parts[1].rejections, 0) + XCTAssertEqual(reset.parts[1].headers, ["X": "y"]) + XCTAssertEqual(reset.attempts, 0) + XCTAssertEqual(reset.generation, 2) + XCTAssertEqual(reset.expiresAt, 9_000) + XCTAssertEqual(reset.headers, ["Authorization": "new"]) + XCTAssertEqual(reset.varsJSON, #"{"v":2}"#) + XCTAssertEqual(reset.createdAt, e.createdAt) + let kept = e.resumed(with: incoming, resetBudget: false, now: 200) + XCTAssertEqual(kept.attempts, 7) + XCTAssertEqual(kept.parts[1].rejections, 4) + } + + func testReplacedBumpsGenerationAndIncarnation() { + var e = chunked().withPartAccepted(0) + e.state = .error + e.settledEventId = "ev" + e.attempts = 5 + let next = e.replaced(with: parsed(["url": "https://a.test/new", "data": ["z": 1]]), + staged: StagedBody(kind: .data, relativePath: "body-2", contentType: "application/json", + forceContentType: false, totalBytes: 7, adopted: false), + now: 300) + XCTAssertEqual(next.generation, e.generation + 1) + XCTAssertNotEqual(next.incarnation, e.incarnation) + XCTAssertEqual(next.state, .queued) + XCTAssertEqual(next.attempts, 0) + XCTAssertNil(next.settledEventId) + XCTAssertEqual(next.bodyKind, .data) + XCTAssertTrue(next.parts.isEmpty) + XCTAssertEqual(next.createdAt, e.createdAt) + } + + func testRowOmitsNilNextAttemptAtAndCarriesVars() { + var e = chunked() + var row = e.row(vars: ["v": 1]) + XCTAssertNil(row["nextAttemptAt"]) + XCTAssertEqual((row["vars"] as? [String: Int])?["v"], 1) + XCTAssertEqual(row["state"] as? String, "queued") + e.nextAttemptAt = 1234 + e.state = .awaitingAuth + row = e.row(vars: NSNull()) + XCTAssertEqual(row["nextAttemptAt"] as? Double, 1234) + XCTAssertEqual(row["state"] as? String, "awaiting-auth") + XCTAssertTrue(row["vars"] is NSNull) + } + + func testTilesExactly() { + func part(_ s: Int64, _ e: Int64) -> QueueEntry.Part { + QueueEntry.Part(url: "u", headers: [:], start: s, end: e, accepted: false, rejections: 0) + } + XCTAssertTrue(QueueEntry.tilesExactly([part(0, 5), part(5, 10)], size: 10)) + XCTAssertTrue(QueueEntry.tilesExactly([part(5, 10), part(0, 5)], size: 10), "order-independent") + XCTAssertFalse(QueueEntry.tilesExactly([part(0, 4), part(5, 10)], size: 10), "gap") + XCTAssertFalse(QueueEntry.tilesExactly([part(0, 6), part(5, 10)], size: 10), "overlap") + XCTAssertFalse(QueueEntry.tilesExactly([part(0, 5), part(5, 11)], size: 10), "past end") + XCTAssertFalse(QueueEntry.tilesExactly([part(0, 5)], size: 10), "short") + XCTAssertFalse(QueueEntry.tilesExactly([], size: 0)) + } + + func testHeaderMergeIsCaseInsensitive() { + let merged = HeaderMerge.merge(["authorization": "old", "A": "1"], ["Authorization": "new"]) + XCTAssertEqual(merged, ["Authorization": "new", "A": "1"]) + XCTAssertEqual(HeaderMerge.value("content-type", in: ["Content-Type": "x"]), "x") + } + + func testEntryRoundTripsThroughJSON() throws { + let e = chunked().withPartAccepted(1) + let back = try JSONDecoder().decode(QueueEntry.self, from: try QueueStore.encode(e)) + XCTAssertEqual(back, e) + } +} diff --git a/ios/Tests/QueueStoreTests.swift b/ios/Tests/QueueStoreTests.swift new file mode 100644 index 00000000..7dbf8eca --- /dev/null +++ b/ios/Tests/QueueStoreTests.swift @@ -0,0 +1,153 @@ +import XCTest +@testable import RNBGUCore + +final class QueueStoreTests: XCTestCase { + private var root: URL! + private var store: QueueStore! + + override func setUp() { + root = makeTempDir() + store = QueueStore(root: root) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: root) + } + + private func entry(_ id: String, state: QueueEntry.State = .queued, body: String? = "body-a") -> QueueEntry { + QueueEntry( + id: id, key: "k", varsJSON: "null", descriptorJSON: "{}", url: "https://a.test", method: "POST", + accept: [], retry: nil, bodyKind: .data, bodyPath: body, bodyContentType: "application/json", + forceContentType: false, bodyFingerprint: "f", parts: [], incarnation: "inc-1", headers: [:], + headerGeneration: 0, state: state, authParked: false, generation: 1, attempts: 0, bytesSent: 0, + totalBytes: 0, expiresAt: 10, nextAttemptAt: nil, settledEventId: nil, lastRequestId: nil, + lastUrl: nil, lastPartIndex: nil, legacy: false, createdAt: 1, updatedAt: 1) + } + + func testSaveLoadRoundTrip() throws { + let e = entry("a/b:c") // a hostile id + try store.save(e) + XCTAssertEqual(store.load("a/b:c"), e) + XCTAssertEqual(store.all(), [e]) + XCTAssertFalse(store.dir("a/b:c").lastPathComponent.contains("/")) + } + + func testAllSkipsCorruptEntryAndTmp() throws { + try store.save(entry("good")) + writeFile(store.fileURL("corrupt", QueueStore.entryName), "{ not json") + writeFile(store.fileURL("tmponly", QueueStore.entryName + ".tmp"), "{}") + XCTAssertEqual(store.all().map(\.id), ["good"]) + XCTAssertNil(store.load("corrupt")) + XCTAssertNil(store.load("tmponly"), "a .tmp with no entry.json reads as absent") + } + + // Crash mid-write: a half-written tmp next to a valid entry. + func testCrashMidWriteKeepsTheLastGoodEntry() throws { + let good = entry("x") + try store.save(good) + let tmp = store.fileURL("x", QueueStore.entryName + ".tmp") + writeFile(tmp, #"{"id":"x","key":"#) // truncated JSON + XCTAssertEqual(store.load("x"), good, "load never reads the tmp") + var next = good + next.state = .running + try store.save(next) + XCTAssertEqual(store.load("x"), next) + XCTAssertFalse(FileIO.exists(tmp), "the next save replaces the tmp") + } + + func testRemoveDeletesTheDirectory() throws { + try store.save(entry("r")) + writeFile(store.fileURL("r", "body-a"), "b") + store.remove("r") + XCTAssertFalse(FileIO.exists(store.dir("r"))) + XCTAssertNil(store.load("r")) + } + + func testSweepDeletesUnreferencedFiles() throws { + let e = entry("s", body: "body-new") + try store.save(e) + for name in ["body-new", "body-old", "blob-old", "entry.json.tmp", "part-0.inc-1.0-5", "part-0.inc-0.0-5"] { + writeFile(store.fileURL("s", name), "x") + } + store.sweep(e) + let left = Set(try FileManager.default.contentsOfDirectory(atPath: store.dir("s").path)) + XCTAssertEqual(left, ["entry.json", "body-new", "part-0.inc-1.0-5"]) + } + + func testAdoptableBlobOnlyWithoutEntry() throws { + writeFile(store.fileURL("c", "blob-123"), "bytes") + XCTAssertEqual(store.adoptableBlob("c"), "blob-123") + try store.save(entry("c")) + XCTAssertNil(store.adoptableBlob("c")) + } + + func testSettingsRoundTripAndDefault() throws { + XCTAssertEqual(store.loadSettings(), QueueSettings()) + var s = QueueSettings() + s.wifiOnly = true + s.headerGeneration = 4 + s.retry = RetryOverride(baseMs: 5) + try store.saveSettings(s) + XCTAssertEqual(QueueStore(root: root).loadSettings(), s) + } + + func testImportMarker() throws { + XCTAssertFalse(store.isImported()) + try store.markImported() + XCTAssertTrue(store.isImported()) + } + + func testWritePartFileTmpRenameAndReuse() throws { + writeFile(store.fileURL("p", "blob"), bytes: 100) + let url = try store.writePartFile(id: "p", blob: "blob", index: 1, start: 10, end: 30, incarnation: "i1") + XCTAssertEqual(FileIO.size(url), 20) + let bytes = try Data(contentsOf: url) + XCTAssertEqual(bytes.first, UInt8(10 % 251)) + // Reuse by identity: the same call returns the same file untouched. + let mtime = try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date + let again = try store.writePartFile(id: "p", blob: "blob", index: 1, start: 10, end: 30, incarnation: "i1") + XCTAssertEqual(again, url) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date, mtime) + // Another incarnation sweeps the stale file of the same index. + let other = try store.writePartFile(id: "p", blob: "blob", index: 1, start: 10, end: 30, incarnation: "i2") + XCTAssertFalse(FileIO.exists(url)) + XCTAssertTrue(FileIO.exists(other)) + store.removePartFile("p", 1) + XCTAssertFalse(FileIO.exists(other)) + } + + func testWritePartFileShortBlobThrows() { + writeFile(store.fileURL("p", "blob"), bytes: 10) + XCTAssertThrowsError(try store.writePartFile(id: "p", blob: "blob", index: 0, start: 0, end: 20, incarnation: "i")) + } + + func testDormantManifestReadAndRemove() throws { + let manifest = ChunkedManifestV9( + id: "v9", parts: [.init(url: "https://s3.test/1", headers: [:], start: 0, end: 5, accepted: true)], + accept: [], expiresAt: 10, wifiOnly: false, createdAt: 1, incarnation: "old") + writeFile(store.fileURL("v9", QueueStore.manifestName), + String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!) + XCTAssertEqual(store.loadV9Manifest("v9"), manifest) + XCTAssertEqual(store.allDormantManifests(), [manifest]) + XCTAssertEqual(store.allV9Manifests()["v9"], manifest) + try store.save(entry("v9")) + XCTAssertTrue(store.allDormantManifests().isEmpty, "an entry.json makes it not dormant") + store.removeV9Manifest("v9") + XCTAssertNil(store.loadV9Manifest("v9")) + } + + func testV9ManifestFileDecodes() { + // The exact shape a v9 build wrote (stalled, rejections, accepted flags). + let json = """ + {"id":"cap","parts":[{"url":"https://s3.test/1","headers":{"Content-Range":"0-4/10"},"start":0,"end":5,\ + "accepted":true,"rejections":2},{"url":"https://s3.test/2","headers":{},"start":5,"end":10,"accepted":false}],\ + "accept":[{"status":409,"bodyIncludes":"already completed"}],"expiresAt":123,"wifiOnly":false,\ + "createdAt":1,"stalled":true,"incarnation":"inc"} + """ + writeFile(store.fileURL("cap", QueueStore.manifestName), json) + let m = store.loadV9Manifest("cap") + XCTAssertEqual(m?.acceptedBytes, 5) + XCTAssertEqual(m?.totalBytes, 10) + XCTAssertEqual(m?.incarnation, "inc") + } +} diff --git a/ios/Tests/RetryClassifierTests.swift b/ios/Tests/RetryClassifierTests.swift new file mode 100644 index 00000000..00bda393 --- /dev/null +++ b/ios/Tests/RetryClassifierTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import RNBGUCore + +final class RetryClassifierTests: XCTestCase { + private func classify(_ status: Int? = nil, body: String? = nil, error: NSError? = nil, + accept: [UploadOutcome.AcceptRule] = [], exempt: [Int] = [404], + part: Bool = false, fileExists: Bool = true, now: Double = 0, + expiresAt: Double = 100) -> RetryClassifier.Class { + var policy = RetryPolicy.defaults + policy.exempt = exempt + return RetryClassifier.classify(.init( + statusCode: status, body: body, error: error, accept: accept, policy: policy, + isChunkedPart: part, fileExists: fileExists, now: now, expiresAt: expiresAt)) + } + + func testTable() { + XCTAssertEqual(classify(200), .accepted) + XCTAssertEqual(classify(204), .accepted) + XCTAssertEqual(classify(409, body: "upload already completed", + accept: [.init(status: 409, bodyIncludes: "already completed")]), .accepted) + XCTAssertEqual(classify(409, body: "conflict", accept: [.init(status: 409, bodyIncludes: "already completed")]), + .terminalHttp) + XCTAssertEqual(classify(401), .auth) + XCTAssertEqual(classify(403), .auth) + XCTAssertEqual(classify(408), .transient) + XCTAssertEqual(classify(429), .transient) + XCTAssertEqual(classify(500), .transient) + XCTAssertEqual(classify(503), .transient) + XCTAssertEqual(classify(404), .transient, "404 is exempt by default") + XCTAssertEqual(classify(404, exempt: [], part: true), .terminalHttp, "the chunked part-404 case") + XCTAssertEqual(classify(400), .terminalHttp) + XCTAssertEqual(classify(422), .terminalHttp) + XCTAssertEqual(classify(304), .terminalHttp) + } + + func testErrors() { + XCTAssertEqual(classify(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet)), + .transient) + XCTAssertEqual(classify(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)), .transient) + let fileError = NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist) + XCTAssertEqual(classify(error: fileError, fileExists: true), .fileUnreadable) + XCTAssertEqual(classify(error: fileError, fileExists: false), .fileMissing) + XCTAssertEqual(classify(error: NSError(domain: "Other", code: 1)), .transient) + } + + func testExpiredWinsOverEverythingButAccepted() { + XCTAssertEqual(classify(200, now: 100, expiresAt: 100), .accepted) + XCTAssertEqual(classify(503, now: 100, expiresAt: 100), .expired) + XCTAssertEqual(classify(401, now: 101, expiresAt: 100), .expired) + XCTAssertEqual(classify(400, now: 101, expiresAt: 100), .expired) + XCTAssertEqual(classify(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut), + now: 200, expiresAt: 100), .expired) + } + + func testBackoff() { + let p = RetryPolicy.defaults + XCTAssertEqual(RetryClassifier.backoffMs(attempt: 1, policy: p, random: { 0.5 }), 1_000) + XCTAssertEqual(RetryClassifier.backoffMs(attempt: 2, policy: p, random: { 0.5 }), 2_000) + XCTAssertEqual(RetryClassifier.backoffMs(attempt: 3, policy: p, random: { 0.5 }), 4_000) + XCTAssertEqual(RetryClassifier.backoffMs(attempt: 40, policy: p, random: { 0.5 }), 7_200_000, "capped at max") + XCTAssertEqual(RetryClassifier.backoffMs(attempt: 1, policy: p, random: { 0 }), 800, "jitter low bound") + XCTAssertEqual(RetryClassifier.backoffMs(attempt: 1, policy: p, random: { 1 }), 1_200, "jitter high bound") + XCTAssertEqual(RetryClassifier.backoffMs(attempt: 0, policy: p, random: { 0.5 }), 1_000, "attempt < 1 clamps") + var noJitter = p + noJitter.jitter = 0 + XCTAssertEqual(RetryClassifier.backoffMs(attempt: 1000, policy: noJitter, random: { 0.9 }), 7_200_000) + } + + func testErrorKind() { + XCTAssertEqual(RetryClassifier.errorKind(for: NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError)), "file") + XCTAssertEqual(RetryClassifier.errorKind(for: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)), "network") + XCTAssertEqual(RetryClassifier.errorKind(for: NSError(domain: "X", code: 1)), "unknown") + } + + func testPolicyResolveLayers() { + let resolved = RetryPolicy.resolve([RetryOverride(baseMs: 10, exempt: [404, 409]), RetryOverride(baseMs: 20)]) + XCTAssertEqual(resolved, RetryPolicy(baseMs: 20, maxMs: 7_200_000, jitter: 0.2, exempt: [404, 409])) + } +} diff --git a/ios/Tests/SupportComponentTests.swift b/ios/Tests/SupportComponentTests.swift new file mode 100644 index 00000000..00472877 --- /dev/null +++ b/ios/Tests/SupportComponentTests.swift @@ -0,0 +1,309 @@ +import XCTest +@testable import RNBGUCore + +private func sampleEntry(_ id: String, createdAt: Double, vars: String = #"{"n":1}"#) -> QueueEntry { + QueueEntry( + id: id, key: "k", varsJSON: vars, descriptorJSON: "{}", url: "https://a.test", method: "POST", + accept: [], retry: nil, bodyKind: .none, bodyPath: "body-1", bodyContentType: nil, + forceContentType: false, bodyFingerprint: "none", parts: [], incarnation: "i", headers: [:], + headerGeneration: 0, state: .queued, authParked: false, generation: 1, attempts: 0, bytesSent: 0, + totalBytes: 0, expiresAt: 10, nextAttemptAt: nil, settledEventId: nil, lastRequestId: nil, + lastUrl: nil, lastPartIndex: nil, legacy: false, createdAt: createdAt, updatedAt: createdAt) +} + +final class RequestIndexTests: XCTestCase { + func testLoadUpsertRemoveAndOrder() { + let index = RequestIndex() + index.load([sampleEntry("b", createdAt: 2), sampleEntry("a", createdAt: 1)]) + XCTAssertEqual(index.rows().map { $0["id"] as? String }, ["a", "b"]) + var b = sampleEntry("b", createdAt: 2) + b.state = .running + index.upsert(b) + XCTAssertEqual(index.entry("b")?.state, .running) + XCTAssertEqual(index.count, 2) + index.remove("a") + XCTAssertEqual(index.rows().count, 1) + XCTAssertNil(index.entry("a")) + } + + func testVarsDecodedOnceAndRefreshedOnChange() { + let index = RequestIndex() + index.upsert(sampleEntry("a", createdAt: 1)) + let first = index.row("a")?["vars"] as AnyObject + var same = sampleEntry("a", createdAt: 1) + same.attempts = 3 + index.upsert(same) + XCTAssertTrue(first === index.row("a")?["vars"] as AnyObject, "same vars text reuses the decoded object") + index.upsert(sampleEntry("a", createdAt: 1, vars: #"{"n":2}"#)) + XCTAssertEqual((index.row("a")?["vars"] as? [String: Int])?["n"], 2) + } +} + +final class EventJournalTests: XCTestCase { + private var root: URL! + private var journal: EventJournal! + + override func setUp() { + root = makeTempDir() + journal = EventJournal(root: root) + } + + override func tearDown() { + try? FileManager.default.removeItem(at: root) + } + + private func event(_ eventId: String, id: String = "e", at: Double = 1, + kind: JournaledEvent.Kind = .completed) -> JournaledEvent { + JournaledEvent(eventId: eventId, id: id, key: "k", varsJSON: #"{"a":1}"#, at: at, attempts: 2, + requestId: "r", deliveries: 0, bytesSent: 5, totalBytes: 5, url: "https://a.test", + method: "POST", partIndex: nil, generation: 1, kind: kind) + } + + func testAppendAndMarkDelivered() { + XCTAssertTrue(journal.append(event("1"))) + XCTAssertEqual(journal.load("1")?.deliveries, 0) + XCTAssertEqual(journal.markDelivered(["1", "missing"]).map(\.deliveries), [1]) + XCTAssertEqual(journal.markDelivered(["1"]).first?.deliveries, 2) + XCTAssertEqual(journal.load("1")?.deliveries, 2, "persisted") + } + + func testUnacknowledgedSortedAndSkipsV9() throws { + journal.append(event("late", at: 5)) + journal.append(event("early", at: 1)) + let v9 = JournaledEventV9(eventId: "old", id: "u", type: "completed", timestamp: 0) + try JSONEncoder().encode(v9).write(to: root.appendingPathComponent("old.json")) + XCTAssertEqual(journal.unacknowledged().map(\.eventId), ["early", "late"]) + XCTAssertEqual(journal.legacyEvents(), [v9]) + } + + func testPruneKeepsEventsThatRowsName() { + let small = EventJournal(root: root.appendingPathComponent("small"), maxEntries: 2) + XCTAssertTrue(small.append(event("1"), keeping: ["1"])) + XCTAssertTrue(small.append(event("2"), keeping: ["1"])) + XCTAssertTrue(small.append(event("3"), keeping: ["1"])) + XCTAssertNotNil(small.load("1"), "named by a row") + XCTAssertNil(small.load("2"), "the oldest unnamed file goes") + XCTAssertNotNil(small.load("3"), "the new event is never pruned") + XCTAssertTrue(small.append(event("4"), keeping: ["1", "3"])) + XCTAssertEqual(small.unacknowledged().map(\.eventId), ["1", "3", "4"], "all named: over the cap") + } + + func testAckIsIdempotent() { + journal.append(event("1")) + journal.ack(["1", "unknown"]) + journal.ack(["1"]) + XCTAssertNil(journal.load("1")) + } + + func testRemoveForId() { + journal.append(event("1", id: "a")) + journal.append(event("2", id: "b")) + journal.removeForId("a") + XCTAssertEqual(journal.unacknowledged().map(\.eventId), ["2"]) + } + + func testBodyCapAtOneMegabyte() { + let big = Data(repeating: UInt8(ascii: "a"), count: EventJournal.maxBodyBytes + 10) + let (body, truncated) = EventJournal.decodeBody(big) + XCTAssertTrue(truncated) + XCTAssertEqual(body.utf8.count, EventJournal.maxBodyBytes) + let small = EventJournal.decodeBody(Data("hi".utf8)) + XCTAssertEqual(small.body, "hi") + XCTAssertFalse(small.truncated) + // A cut inside a multi-byte character backs off to a whole one. + let multi = Data("aé".utf8) // 1 + 2 bytes + let cut = EventJournal.decodeBody(multi, cap: 2) + XCTAssertEqual(cut.body, "a") + XCTAssertTrue(cut.truncated) + } + + func testResponseBufferCaps() { + var buffer = ResponseBuffer() + buffer.append(Data(repeating: 1, count: 6), cap: 10) + buffer.append(Data(repeating: 2, count: 6), cap: 10) + XCTAssertEqual(buffer.data.count, 10) + XCTAssertTrue(buffer.truncated) + XCTAssertTrue(buffer.decoded().truncated) + } + + func testBridgedFlattensEachKind() { + var completed = event("c") + completed.response = RawResponseRecord(status: 201, headers: ["h": "v"], body: "{}", bodyTruncated: false) + let c = completed.bridged + XCTAssertEqual(c["kind"] as? String, "completed") + XCTAssertEqual(c["state"] as? String, "completed") + XCTAssertEqual((c["response"] as? [String: Any])?["status"] as? Int, 201) + XCTAssertEqual((c["vars"] as? [String: Int])?["a"], 1) + XCTAssertEqual(c["requestId"] as? String, "r") + XCTAssertNil(c["partIndex"]) + + var chunked = event("cc") + chunked.response = RawResponseRecord(bodyTruncated: false) + let cc = chunked.bridged["response"] as? [String: Any] + XCTAssertNil(cc?["status"], "a chunked completion has no status") + XCTAssertEqual(cc?["bodyTruncated"] as? Bool, false) + + var error = event("e", kind: .error) + error.error = OutcomeErrorRecord(errorKind: "http", message: "HTTP 404", + response: RawResponseRecord(status: 404, bodyTruncated: false), partIndex: 2) + error.partIndex = 2 + let e = error.bridged + XCTAssertEqual((e["error"] as? [String: Any])?["errorKind"] as? String, "http") + XCTAssertEqual((e["error"] as? [String: Any])?["partIndex"] as? Int, 2) + XCTAssertEqual(e["partIndex"] as? Int, 2) + XCTAssertNil(e["response"]) + + var cancelled = event("x", kind: .cancelled) + cancelled.cancelReason = "user" + XCTAssertEqual(cancelled.bridged["cancelReason"] as? String, "user") + + let nullVars = JournaledEvent(eventId: "n", id: "e", key: "k", varsJSON: "null", at: 1, attempts: 0, + deliveries: 1, bytesSent: 0, totalBytes: 0, url: "", method: "POST", + generation: 1, kind: .cancelled) + XCTAssertTrue(nullVars.bridged["vars"] is NSNull) + } +} + +final class TaskMapTests: XCTestCase { + func testRoundTripOfNewFieldsAndPurpose() { + let url = makeTempDir().appendingPathComponent("map.json") + let map = TaskMap(fileURL: url) + map.set(.init(id: "a", partIndex: 1, incarnation: "i", attempt: 3, requestId: "r", headerGeneration: 2, + generation: 4, purpose: .attempt), forKey: "s:1") + map.setPurpose(.pause, forKey: "s:1", id: "a") + map.setHeaderGeneration(5, forKey: "s:1") + let reread = TaskMap(fileURL: url) + XCTAssertEqual(reread.meta(forKey: "s:1"), + .init(id: "a", partIndex: 1, incarnation: "i", attempt: 3, requestId: "r", headerGeneration: 5, + generation: 4, purpose: .pause)) + reread.removeKey("s:1") + XCTAssertNil(TaskMap(fileURL: url).meta(forKey: "s:1")) + } + + func testV9EntryDecodes() throws { + let url = makeTempDir().appendingPathComponent("map.json") + try Data(#"{"s:9":{"id":"old","acceptStatus":[409]},"s:10":{"id":"new","purpose":"future"}}"#.utf8).write(to: url) + let map = TaskMap(fileURL: url) + XCTAssertEqual(map.meta(forKey: "s:9")?.accept, [.init(status: 409, bodyIncludes: nil)]) + XCTAssertNil(map.meta(forKey: "s:9")?.generation) + XCTAssertNil(map.meta(forKey: "s:10")?.purpose, "an unknown purpose reads as nil") + map.removeAll { _, meta in meta.generation == nil } + XCTAssertTrue(map.keys { _ in true }.isEmpty) + } + + func testOwnerResolution() { + let desc = ChunkedEngine.taskDescription(id: "a:b/c", attempt: 2, generation: 3) + XCTAssertEqual(TaskOwner.resolve(description: desc, meta: nil), .request(id: "a:b/c", generation: 3, attempt: 2)) + XCTAssertEqual(TaskOwner.resolve(description: nil, meta: .init(id: "m", generation: 1, purpose: nil)), nil, + "a meta without attempt has no v10 owner") + XCTAssertEqual(TaskOwner.resolve(description: nil, meta: .init(id: "m", attempt: 1, generation: 1)), + .request(id: "m", generation: 1, attempt: 1)) + XCTAssertEqual(TaskOwner.resolve(description: "legacy-bare-id", meta: .init(id: "legacy-bare-id")), nil) + XCTAssertEqual(TaskOwner.resolve(description: nil, meta: .init(id: "p", partIndex: 2, incarnation: "i")), + .part(id: "p", part: 2, incarnation: "i")) + } +} + +final class ChunkedEngineTests: XCTestCase { + func testWindowAndOneTaskPerPart() { + XCTAssertEqual(ChunkedEngine.indexesToEnqueue(pending: [0, 1, 2, 3, 4], inFlight: []), [0, 1, 2]) + XCTAssertEqual(ChunkedEngine.indexesToEnqueue(pending: [0, 1, 2, 3, 4], inFlight: [0, 1]), [2]) + XCTAssertEqual(ChunkedEngine.indexesToEnqueue(pending: [1, 2, 3], inFlight: [0, 1, 2]), []) + XCTAssertEqual(ChunkedEngine.indexesToEnqueue(pending: [3, 4], inFlight: [3]), [4], "never an in-flight index") + } + + func testDescriptionsSurviveHostileIds() { + let id = #"cap:1/"x"\n"# + let part = ChunkedEngine.taskDescription(id: id, part: 7, incarnation: "inc") + XCTAssertEqual(ChunkedEngine.parsePartDescription(part)?.id, id) + XCTAssertEqual(ChunkedEngine.parsePartDescription(part)?.part, 7) + XCTAssertNil(ChunkedEngine.parseRequestDescription(part)) + let req = ChunkedEngine.taskDescription(id: id, attempt: 4, generation: 2) + XCTAssertEqual(ChunkedEngine.parseRequestDescription(req)?.id, id) + XCTAssertEqual(ChunkedEngine.parseRequestDescription(req)?.attempt, 4) + XCTAssertNil(ChunkedEngine.parsePartDescription(req)) + XCTAssertNil(ChunkedEngine.parsePartDescription("bare-v9-id")) + } +} + +final class LegacyImportTests: XCTestCase { + private func manifest(_ id: String) -> ChunkedManifestV9 { + ChunkedManifestV9(id: id, parts: [ + .init(url: "https://s3.test/1", headers: [:], start: 0, end: 5, accepted: true), + .init(url: "https://s3.test/2", headers: [:], start: 5, end: 12, accepted: false), + ], accept: [], expiresAt: 99, wifiOnly: false, createdAt: 1, incarnation: "inc") + } + + func testJournalOnly() { + let rows = LegacyImport.plan(events: [ + JournaledEventV9(eventId: "1", id: "t1", type: "error", timestamp: 10), + JournaledEventV9(eventId: "2", id: "t1", type: "completed", timestamp: 20), + JournaledEventV9(eventId: "3", id: "t2", type: "cancelled", timestamp: 5), + ], manifests: [:]) + XCTAssertEqual(rows.map(\.id), ["t2", "t1"]) + XCTAssertEqual(rows.map(\.state), [.cancelled, .completed], "the latest v9 outcome wins") + XCTAssertTrue(rows.allSatisfy { $0.legacy && $0.key == "legacy" && $0.varsJSON == "null" }) + XCTAssertNil(rows[0].bodyPath) + } + + func testJournalAndManifest() { + let rows = LegacyImport.plan(events: [JournaledEventV9(eventId: "1", id: "cap", type: "error", timestamp: 3)], + manifests: ["cap": manifest("cap")]) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows[0].bytesSent, 5) + XCTAssertEqual(rows[0].totalBytes, 12) + XCTAssertEqual(rows[0].bodyPath, "blob") + } + + func testManifestOnlyStaysDormant() { + XCTAssertTrue(LegacyImport.plan(events: [], manifests: ["cap": manifest("cap")]).isEmpty) + } +} + +final class ProgressThrottleTests: XCTestCase { + func testIntervals() { + let t = ProgressThrottle() + t.isForeground = true + XCTAssertTrue(t.shouldEmit("a", now: 0)) + XCTAssertFalse(t.shouldEmit("a", now: 999)) + XCTAssertTrue(t.shouldEmit("a", now: 1_000)) + XCTAssertTrue(t.shouldEmit("b", now: 1_001), "per id") + t.isForeground = false + XCTAssertFalse(t.shouldEmit("a", now: 2_500)) + XCTAssertTrue(t.shouldEmit("a", now: 601_000)) + t.reset("a") + XCTAssertTrue(t.shouldEmit("a", now: 601_001)) + } +} + +final class AttemptEventTests: XCTestCase { + private func input(status: Int? = 200, error: NSError? = nil, accepted: Bool = true, + systemCancel: Bool = false, body: String? = "ok") -> AttemptEvent.Input { + AttemptEvent.Input(id: "i", key: "k", requestId: "r", attempt: 1, url: "https://a.test", method: "PUT", + partIndex: 2, statusCode: status, headers: ["h": "v"], body: body, error: error, + accepted: accepted, systemCancel: systemCancel, at: 5) + } + + func testOutcomes() { + let ok = AttemptEvent.build(input()) + XCTAssertEqual(ok["outcome"] as? String, "completed") + XCTAssertEqual(ok["httpCode"] as? Int, 200) + XCTAssertEqual(ok["partIndex"] as? Int, 2) + let http = AttemptEvent.build(input(status: 400, accepted: false)) + XCTAssertEqual(http["outcome"] as? String, "error") + XCTAssertEqual(http["errorKind"] as? String, "http") + let net = AttemptEvent.build(input(status: nil, error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut), + accepted: false)) + XCTAssertEqual(net["errorKind"] as? String, "network") + XCTAssertNil(net["httpCode"]) + let cancel = AttemptEvent.build(input(status: nil, accepted: false, systemCancel: true)) + XCTAssertEqual(cancel["outcome"] as? String, "cancelled") + XCTAssertEqual(cancel["cancelReason"] as? String, "system") + } + + func testBodyCappedAtFourKilobytes() { + let e = AttemptEvent.build(input(body: String(repeating: "x", count: 5_000))) + XCTAssertEqual((e["responseBody"] as? String)?.count, 4_096) + XCTAssertEqual(e["responseBodyTruncated"] as? Bool, true) + } +} diff --git a/ios/Tests/TestSupport.swift b/ios/Tests/TestSupport.swift new file mode 100644 index 00000000..2c28d34a --- /dev/null +++ b/ios/Tests/TestSupport.swift @@ -0,0 +1,246 @@ +import Foundation +import XCTest +@testable import RNBGUCore + +/// A fresh directory per test, removed afterwards. +func makeTempDir(_ name: String = #function) -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("rnbgu-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try! FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir +} + +/// Makes a directory read-only (writes into it fail) or writable again. +/// Tests restore it before tearDown deletes the tree. +func setReadOnly(_ dir: URL, _ readOnly: Bool) { + try! FileManager.default.setAttributes([.posixPermissions: readOnly ? 0o555 : 0o755], ofItemAtPath: dir.path) +} + +func writeFile(_ url: URL, _ text: String) { + try! FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try! Data(text.utf8).write(to: url) +} + +func writeFile(_ url: URL, bytes: Int) { + try! FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try! Data((0.. String? { request.value(forHTTPHeaderField: name) } +} + +final class FakeTransport: Transport { + /// Tasks the daemon held before this process started. + var daemonTasks: [FakeTask] = [] + private(set) var created: [FakeTask] = [] + private var next = 1 + + func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String, + beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask { + let task = FakeTask(key: "\(wifiOnly ? "wifi" : "any"):\(next)", description: description, + request: request, file: file, beginAt: beginAt, wifiOnly: wifiOnly) + next += 1 + beforeResume(task.key) + created.append(task) + return task + } + + func allTasks(_ completion: @escaping ([UploadTask]) -> Void) { + completion(daemonTasks + created) + } + + var live: [FakeTask] { created.filter(\.isLive) } +} + +final class FakeSink: EventSink { + var states: [[String: Any]] = [] + var progress: [[String: Any]] = [] + var attempts: [[String: Any]] = [] + var settled: [[String: Any]] = [] + + func emitState(_ body: [String: Any]) { states.append(body) } + func emitProgress(_ body: [String: Any]) { progress.append(body) } + func emitAttempt(_ body: [String: Any]) { attempts.append(body) } + func emitSettled(_ body: [String: Any]) { settled.append(body) } + + var stateNames: [String] { states.compactMap { $0["state"] as? String } } +} + +/// A coordinator over temp stores and fakes. Time and timers are manual. +final class Harness { + let root: URL + let store: QueueStore + let journal: EventJournal + let taskMap: TaskMap + let transport = FakeTransport() + let sink = FakeSink() + var clock: Double = 1_700_000_000_000 + var timers: [(delayMs: Int, block: () -> Void)] = [] + private(set) var coordinator: QueueCoordinator! + let queue = DispatchQueue(label: "test.queue") + + init(root: URL = makeTempDir()) { + self.root = root + store = QueueStore(root: root.appendingPathComponent("queue")) + journal = EventJournal(root: root.appendingPathComponent("events")) + taskMap = TaskMap(fileURL: root.appendingPathComponent("taskmap.json")) + relaunch() + } + + /// A new process over the same disk: the index reloads from the store. + func relaunch(daemonTasks: [FakeTask] = []) { + transport.daemonTasks = daemonTasks + timers = [] + coordinator = QueueCoordinator( + store: store, journal: journal, taskMap: TaskMap(fileURL: taskMap.fileURL), + transport: transport, sink: sink, queue: queue, now: { [unowned self] in self.clock }, + random: { 0.5 }, schedule: { [unowned self] ms, block in self.timers.append((ms, block)) }) + } + + var map: TaskMap { coordinator.taskMap } + + /// Runs the relaunch reconcile to completion. + func boot() { + coordinator.reconcileAll {} + drain() + drain() + } + + func drain() { queue.sync {} } + + /// Fires every timer that is due within `ms` (advancing the clock). + func advance(_ ms: Double) { + clock += ms + let due = timers + timers = [] + for t in due { + if Double(t.delayMs) <= ms { queue.sync { t.block() } } else { timers.append((t.delayMs - Int(ms), t.block)) } + } + } + + @discardableResult + func enqueue(_ raw: [String: Any]) -> Result { + var result: Result? + coordinator.enqueue(raw, resolve: { result = .success($0) }, + reject: { result = .failure(EnqueueError(code: $0, message: $1)) }) + drain() + return result! + } + + func cancel(_ id: String) { + coordinator.cancel(id) {} + drain() + } + + func ack(_ eventIds: [String]) { + coordinator.ack(eventIds) {} + drain() + } + + func pause() { + coordinator.pause(resolve: {}, reject: { _, _ in XCTFail("pause rejected") }) + drain() + } + + func resume() { + coordinator.resume(resolve: {}, reject: { _, _ in XCTFail("resume rejected") }) + drain() + } + + func updateHeaders(_ patch: [String: Any]) { + coordinator.updateHeaders(patch, resolve: {}, reject: { _, _ in XCTFail("updateHeaders rejected") }) + drain() + } + + func setWifiOnly(_ on: Bool) { + coordinator.setWifiOnly(on, resolve: {}, reject: { _, _ in XCTFail("setWifiOnly rejected") }) + drain() + } + + func unacknowledged() -> [[String: Any]] { + var out: [[String: Any]] = [] + coordinator.unacknowledgedEvents { out = $0 } + drain() + return out + } + + /// Delivers a completion for `task`, as didCompleteWithError would. + func complete(_ task: FakeTask, status: Int? = 200, body: String = "", headers: [String: String] = [:], + error: NSError? = nil) { + task.isLive = false + coordinator.taskCompleted(TaskCompletion( + key: task.key, description: task.taskDescription, url: task.request.url?.absoluteString, + statusCode: error == nil ? status : nil, headers: headers, body: error == nil ? body : nil, + bodyTruncated: false, error: error)) + } + + /// Delivers the NSURLErrorCancelled callback of a task the test cancelled. + func deliverCancel(_ task: FakeTask) { + complete(task, error: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) + } + + func entry(_ id: String) -> QueueEntry? { coordinator.index.entry(id) } + func row(_ id: String) -> [String: Any]? { coordinator.rows().first { $0["id"] as? String == id } } + + // MARK: - Builders + + var expiresAt: Double { clock + 14 * 24 * 3_600_000 } + + func raw(id: String, key: String = "k", vars: Any = ["n": 1], descriptor: [String: Any]) -> [String: Any] { + var d = descriptor + if d["expiresAt"] == nil { d["expiresAt"] = expiresAt } + return ["id": id, "key": key, "vars": vars, "descriptor": d] + } + + func dataRaw(id: String, data: Any = ["x": 1], url: String = "https://api.test/x", + headers: [String: Any] = [:], extra: [String: Any] = [:]) -> [String: Any] { + var d: [String: Any] = ["url": url, "data": data, "headers": headers] + for (k, v) in extra { d[k] = v } + return raw(id: id, descriptor: d) + } + + /// A chunked descriptor over a fresh source file of `size` bytes cut into + /// `parts` equal parts. + func chunkedRaw(id: String, size: Int = 30, parts: Int = 3, source: URL? = nil, + urlPrefix: String = "https://s3.test/part", extra: [String: Any] = [:]) -> [String: Any] { + let file = source ?? root.appendingPathComponent("src-\(UUID().uuidString)") + if source == nil { writeFile(file, bytes: size) } + let step = size / parts + let list: [[String: Any]] = (0..:", the TaskMap key. + var key: String { get } + var taskDescription: String? { get } + /// Running or suspended. A completed or canceling task is not live: its + /// delegate callback settles it. + var isLive: Bool { get } + /// earliestBeginDate: set on a delayed retry. + var beginAt: Date? { get } + func cancel() +} + +protocol Transport: AnyObject { + /// Creates an upload task, sets its description and begin date, calls + /// `beforeResume` with its key (the caller writes the TaskMap there), then + /// resumes it. + func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String, + beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask + + /// Every task of both sessions. The completion may run on any queue. + func allTasks(_ completion: @escaping ([UploadTask]) -> Void) +} + +/// Live events for JS. Best-effort: JS may be dead, and every terminal is +/// journaled before emitSettled. +protocol EventSink: AnyObject { + func emitState(_ body: [String: Any]) + func emitProgress(_ body: [String: Any]) + func emitAttempt(_ body: [String: Any]) + func emitSettled(_ body: [String: Any]) +} + +/// What didCompleteWithError reports, with the response body already capped. +struct TaskCompletion { + let key: String + let description: String? + let url: String? + let statusCode: Int? + let headers: [String: String] + let body: String? + let bodyTruncated: Bool + let error: NSError? +} + +/// Who a task belongs to. From taskDescription first, TaskMap second. +enum TaskOwner: Equatable { + case request(id: String, generation: Int, attempt: Int) + case part(id: String, part: Int, incarnation: String?) + + var id: String { + switch self { + case .request(let id, _, _), .part(let id, _, _): return id + } + } + + static func resolve(description: String?, meta: TaskMap.Meta?) -> TaskOwner? { + if let p = ChunkedEngine.parsePartDescription(description) { + return .part(id: p.id, part: p.part, incarnation: p.incarnation) + } + if let r = ChunkedEngine.parseRequestDescription(description) { + return .request(id: r.id, generation: r.generation, attempt: r.attempt) + } + guard let meta else { return nil } + if let part = meta.partIndex { return .part(id: meta.id, part: part, incarnation: meta.incarnation) } + if let generation = meta.generation, let attempt = meta.attempt { + return .request(id: meta.id, generation: generation, attempt: attempt) + } + // A v9 simple task: a bare id with no generation. No v10 owner. + return nil + } +} + +/// A task's response body while it streams in, capped at the settled body +/// cap. Bytes past the cap are dropped and flagged, so a huge error page +/// cannot grow memory without bound. +struct ResponseBuffer { + private(set) var data = Data() + private(set) var truncated = false + + mutating func append(_ chunk: Data, cap: Int = EventJournal.maxBodyBytes) { + let room = cap - data.count + if chunk.count <= room { + data.append(chunk) + } else { + if room > 0 { data.append(chunk.prefix(room)) } + truncated = true + } + } + + /// The body as text, and whether the cap cut it. + func decoded() -> (body: String, truncated: Bool) { + EventJournal.decodeBody(data, truncated: truncated) + } +} diff --git a/react-native-background-upload.podspec b/react-native-background-upload.podspec index be0e6424..a3d6696a 100644 --- a/react-native-background-upload.podspec +++ b/react-native-background-upload.podspec @@ -15,6 +15,9 @@ Pod::Spec.new do |s| } s.source_files = "ios/**/*.{h,m,mm,swift}" + # ios/Package.swift and ios/Tests are the host-side unit tests (`swift test`). + # They and SwiftPM's build output must not compile into the pod. + s.exclude_files = ["ios/Package.swift", "ios/Tests/**", "ios/.build/**", "ios/.swiftpm/**"] # RNFileUploader.h imports the codegen spec header, which is Obj-C++ only. Keep # it out of the public umbrella so a consumer's plain Obj-C # `@import react_native_background_upload;` still compiles — that import is how From 5fd1dcaff7a41de51b46f644cee89b0bbda2d230 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 16:23:37 -0400 Subject: [PATCH 15/22] iOS: apply the native slices review Reliability: reconcile applies unacked same-generation records to a live row before the task loop, so a failed entry save after the journal write cannot re-issue a settled request. deliveries counts only deliveries that reached a JS listener: journaled at 0 with no listener, not emitted live. A chunked part with a pending replay holds its slot for the grace period. A completion that lands before reconcile still advances the attempt ordinal. A part-file build error settles error/file only when the blob is missing or short; otherwise it refills after backoff. The background completion handler releases as soon as the awaited replay lands, and the grace timer only closes the wait it opened. Contract: vars and data arrive as JSON text; NSNull handling for data is gone; E_INVALID covers non-http(s) URLs, bad header names or values, and GET with a body. url and method are part of the body fingerprint. attempts reset only on reopen. A paused entry past expiresAt settles at resume; the expiry check runs after classification. A same-id mutate on a waiting retry retries now. updateHeaders patches part headers. Rows carry live bytesSent; legacy rows report 0/0. Attempt events are completed or error. Simplification: dead fields removed (TaskMap.Meta.accept, isChunkedPart, fileUnreadable, lifetimeMs, descriptorJSON, allDormantManifests). Tests: 170 (was 138), including relaunch and pending-replay cases. Co-Authored-By: Claude Fable 5.1 --- ios/BodyStaging.swift | 4 +- ios/ChunkedCoordinator.swift | 77 +++- ios/ChunkedManifestV9.swift | 11 +- ios/EnqueueParser.swift | 98 +++-- ios/Events.swift | 14 +- ios/JSONText.swift | 9 +- ios/LegacyImport.swift | 10 +- ios/QueueCoordinator+Enqueue.swift | 23 +- ios/QueueCoordinator+Outcomes.swift | 64 ++- ios/QueueCoordinator+Reconcile.swift | 77 +++- ios/QueueCoordinator+Simple.swift | 42 +- ios/QueueCoordinator.swift | 64 ++- ios/QueueEntry.swift | 22 +- ios/QueueSettings.swift | 6 +- ios/QueueStore.swift | 10 - ios/RNBackgroundUpload.swift | 26 +- ios/RequestIndex.swift | 11 + ios/RetryClassifier.swift | 18 +- ios/TaskMap.swift | 28 +- ios/Tests/CoordinatorChunkedTests.swift | 323 +++------------ ios/Tests/CoordinatorRelaunchTests.swift | 483 +++++++++++++++++++++++ ios/Tests/CoordinatorSimpleTests.swift | 185 ++++++++- ios/Tests/QueueEntryTests.swift | 119 ++++-- ios/Tests/QueueStoreTests.swift | 9 +- ios/Tests/RetryClassifierTests.swift | 18 +- ios/Tests/SupportComponentTests.swift | 31 +- ios/Tests/TestSupport.swift | 58 ++- ios/Transport.swift | 7 + 28 files changed, 1340 insertions(+), 507 deletions(-) create mode 100644 ios/Tests/CoordinatorRelaunchTests.swift diff --git a/ios/BodyStaging.swift b/ios/BodyStaging.swift index d800e97a..73656426 100644 --- a/ios/BodyStaging.swift +++ b/ios/BodyStaging.swift @@ -17,8 +17,8 @@ struct StagedBody: Equatable { enum StagingError: Error, Equatable { /// A source file is gone. Rejects E_FILE_MISSING. case fileMissing(String) - /// The parts do not tile the file. Rejects E_STORAGE; JS validates, so this - /// is a size mismatch between the plan and the real file. + /// The parts do not tile the file. Rejects E_INVALID; JS validates the + /// plan, so this is a size mismatch between the plan and the real file. case invalid(String) case io(String) } diff --git a/ios/ChunkedCoordinator.swift b/ios/ChunkedCoordinator.swift index 07472c04..1aca7410 100644 --- a/ios/ChunkedCoordinator.swift +++ b/ios/ChunkedCoordinator.swift @@ -76,7 +76,18 @@ final class ChunkedCoordinator { t.task.cancel() } } - inFlight[id] = live + // A pending part with a TaskMap key but no live task finished while the + // app was dead, and its completion may replay now. Hold its slot for the + // grace, so no second PUT of that part starts, and its part file stays. + var held: [Int: String] = [:] + for key in q.taskMap.keys(where: { $0.id == id && $0.incarnation == e.incarnation + && ($0.purpose ?? .attempt) == .attempt }) { + guard let i = q.taskMap.meta(forKey: key)?.partIndex, e.parts.indices.contains(i), + !e.parts[i].accepted, live[i] == nil, held[i] == nil else { continue } + held[i] = key + } + inFlight[id] = live.merging(held) { current, _ in current } + if !held.isEmpty { holdForReplay(id, held) } // Part files of accepted parts with no live task are orphans. for i in e.parts.indices where e.parts[i].accepted && live[i] == nil { q.store.removePartFile(id, i) @@ -85,6 +96,22 @@ final class ChunkedCoordinator { updateWait(id) } + /// When the grace ends (every held replay came, or the timer fired), a + /// held slot whose replay never came is released: its key is pruned and + /// the part is sent again. + private func holdForReplay(_ id: String, _ held: [Int: String]) { + q.openGrace("part:" + id, keys: Set(held.values)) { [weak self] in + guard let self else { return } + var released = false + for (i, key) in held where self.inFlight[id]?[i] == key { + self.inFlight[id]?[i] = nil + self.q.taskMap.removeKey(key) + released = true + } + if released { self.refill(id) } + } + } + // MARK: - Delegate hooks func partCompleted(id: String, part: Int, incarnation: String?, key: String, meta: TaskMap.Meta?, @@ -112,8 +139,11 @@ final class ChunkedCoordinator { if cancelled, meta?.purpose == .pause || meta?.purpose == .superseded { return } let accepted = c.error == nil && c.statusCode.map { UploadOutcome.isAccepted($0, body: c.body, accept: e.accept) } == true - q.emitAttempt(e, requestId: meta?.requestId, attempt: meta?.attempt ?? e.attempts, completion: c, - partIndex: part, accepted: accepted, systemCancel: cancelled) + // A system cancel is not an attempt: no event. It retries below. + if !cancelled { + q.emitAttempt(e, requestId: meta?.requestId, attempt: meta?.attempt ?? e.attempts, completion: c, + partIndex: part, accepted: accepted) + } e.lastRequestId = meta?.requestId ?? e.lastRequestId e.lastUrl = e.parts[part].url e.lastPartIndex = part @@ -121,6 +151,14 @@ final class ChunkedCoordinator { // Accept first, whatever the entry's state: the server holds these bytes // now. Losing the flag would re-send a part the server already has. if accepted { + // A replay that lands after its slot was given to a new task: that + // task is a duplicate PUT, and it reads the part file. Stop it first. + if !owned, let other = inFlight[id]?[part] { + q.cancelTask(other, purpose: .superseded) + inFlight[id]?[part] = nil + partSent[id]?[part] = nil + partBeginAt[id]?[part] = nil + } e = e.withPartAccepted(part) q.commit(e, emit: false) q.store.removePartFile(id, part) @@ -150,11 +188,11 @@ final class ChunkedCoordinator { let blobExists = e.bodyPath.map { FileIO.exists(q.store.fileURL(id, $0)) } ?? false let verdict = RetryClassifier.classify(RetryClassifier.Input( statusCode: c.statusCode, body: c.body, error: c.error, accept: e.accept, policy: q.policy(e), - isChunkedPart: true, fileExists: blobExists, now: q.now(), expiresAt: e.expiresAt)) + fileExists: blobExists, now: q.now(), expiresAt: e.expiresAt)) switch verdict { case .accepted: break // handled above - case .transient, .fileUnreadable: + case .transient: retryPart(e, part) case .auth: if let g = meta?.headerGeneration, g < q.settings.headerGeneration { @@ -253,20 +291,25 @@ final class ChunkedCoordinator { errorKind: "unknown", message: "part \(index) url is not valid", partIndex: index))) return false } + let blob = e.bodyPath ?? ChunkedManifestV9.blobName let file: URL do { file = try q.store.writePartFile( - id: id, blob: e.bodyPath ?? ChunkedManifestV9.blobName, index: index, start: part.start, - end: part.end, incarnation: e.incarnation) + id: id, blob: blob, index: index, start: part.start, end: part.end, incarnation: e.incarnation) } catch { - q.settle(id, .fileError("cannot build part \(index): \(error.localizedDescription)", partIndex: index)) + // Only a missing or short blob can never succeed. Any other failure + // (a full disk, protected data) may pass: build the part again later. + let blobSize = FileIO.size(q.store.fileURL(id, blob)) ?? 0 + if blobSize < part.end { + q.settle(id, .fileError("cannot build part \(index): \(error.localizedDescription)", partIndex: index)) + } else { + refillLater(e, part: part, delayMs: delayMs) + } return false } e.attempts += 1 guard q.commitAhead(e, emit: false) else { - let backoff = RetryClassifier.backoffMs( - attempt: max(part.rejections, 1), policy: q.policy(e), random: q.random) - q.schedule(max(delayMs ?? 0, backoff)) { [weak self] in self?.refill(id) } + refillLater(e, part: part, delayMs: delayMs) return false } @@ -291,6 +334,15 @@ final class ChunkedCoordinator { return true } + /// No task was made for a part (a failed save or part file). Fill the + /// window again after the wait it asked for, or a backoff, whichever is + /// longer. + private func refillLater(_ e: QueueEntry, part: QueueEntry.Part, delayMs: Int?) { + let backoff = RetryClassifier.backoffMs( + attempt: max(part.rejections, 1), policy: q.policy(e), random: q.random) + q.schedule(max(delayMs ?? 0, backoff)) { [weak self] in self?.refill(e.id) } + } + /// A transient part failure: the next task is created now with a /// backoff delay, so it holds the part's window slot and the daemon starts /// it on time even while the app is dead. @@ -321,9 +373,12 @@ final class ChunkedCoordinator { q.commit(e) } + /// Byte-weighted: accepted parts plus what the live part tasks sent. The + /// row carries the same value. private func emitProgress(_ e: QueueEntry) { guard e.totalBytes > 0 else { return } let sent = min(e.acceptedBytes + (partSent[e.id]?.values.reduce(0, +) ?? 0), e.totalBytes) + q.index.setBytes(e.id, sent) q.emitProgress(e.id, sent: sent, total: e.totalBytes) } } diff --git a/ios/ChunkedManifestV9.swift b/ios/ChunkedManifestV9.swift index c7cad595..b7bac607 100644 --- a/ios/ChunkedManifestV9.swift +++ b/ios/ChunkedManifestV9.swift @@ -2,26 +2,21 @@ import Foundation /// The v9 chunked manifest (`manifest.json`), kept only to read the files a /// v9 build left behind. Decode only; v10 never writes it. A same-id enqueue -/// with the same parts adopts its accepted flags, incarnation and blob. +/// with the same parts adopts its accepted flags, incarnation and blob. The +/// legacy row reads expiresAt and the byte counts. Other v9 keys are ignored. struct ChunkedManifestV9: Codable, Equatable { struct Part: Codable, Equatable { let url: String - var headers: [String: String] let start: Int64 let end: Int64 - var accepted: Bool = false - var rejections: Int? + var accepted: Bool var size: Int64 { end - start } } let id: String var parts: [Part] - var accept: [UploadOutcome.AcceptRule] var expiresAt: Double - var wifiOnly: Bool - let createdAt: Double - var stalled: Bool = false var incarnation: String /// v9 wrote the moved bytes at this fixed name. diff --git a/ios/EnqueueParser.swift b/ios/EnqueueParser.swift index 956d015f..6e5474d5 100644 --- a/ios/EnqueueParser.swift +++ b/ios/EnqueueParser.swift @@ -1,7 +1,7 @@ import Foundation -/// What enqueue() received, validated. JS validates first, so a throw here -/// is a bug worth surfacing (it rejects E_STORAGE), not a user error. +/// What enqueue() received, validated. A throw here rejects E_INVALID: input +/// native cannot send. struct ParsedEnqueue { enum Body { case none @@ -22,7 +22,6 @@ struct ParsedEnqueue { let id: String let key: String let varsJSON: String - let descriptorJSON: String let url: String? let method: String let headers: [String: String] @@ -43,26 +42,21 @@ struct ParseError: LocalizedError { enum EnqueueParser { static let methods: Set = ["POST", "PUT", "PATCH", "DELETE", "GET"] - /// Parses the bridged `{ id, key, vars, descriptor }`. NSNull counts as - /// absent for every field but `data`, where it is the JSON body `null` - /// (the JS contract accepts any JSON value). The bridge drops a JS - /// `undefined` before native code sees it. + /// Parses the bridged `{ id, key, varsJson, descriptor }`. `varsJson` and + /// `descriptor.dataJson` are JSON text, because React Native on iOS drops + /// object keys whose value is null. dataJson "null" is the JSON body null. + /// NSNull counts as absent for the other optional fields. static func parse(_ raw: [String: Any]) throws -> ParsedEnqueue { guard let id = raw["id"] as? String, !id.isEmpty else { throw ParseError(message: "missing 'id'") } guard let key = raw["key"] as? String, !key.isEmpty else { throw ParseError(message: "missing 'key'") } guard let d = raw["descriptor"] as? [String: Any] else { throw ParseError(message: "missing 'descriptor'") } - guard let varsJSON = JSONText.encode(present(raw["vars"])) else { - throw ParseError(message: "'vars' is not JSON") - } - // The data body lives in the staged file. Keeping it here too would - // double it in entry.json, which is rewritten on every transition. - var described = d - described["data"] = nil - guard let descriptorJSON = JSONText.encode(described) else { - throw ParseError(message: "'descriptor' is not JSON") + guard let varsJSON = raw["varsJson"] as? String, JSONText.parse(varsJSON) != nil else { + throw ParseError(message: "'varsJson' must be JSON text") } + // An object `data` would have lost its null-valued keys on the way here. + guard d["data"] == nil else { throw ParseError(message: "'data' must cross as 'dataJson'") } let method = ((present(d["method"]) as? String) ?? "POST").uppercased() guard methods.contains(method) else { throw ParseError(message: "unknown method '\(method)'") } guard let expiresAt = (present(d["expiresAt"]) as? NSNumber)?.doubleValue else { @@ -73,10 +67,10 @@ enum EnqueueParser { if let url { try requireURL(url, "url") } var kinds: [ParsedEnqueue.Body] = [] - if d["data"] is NSNull { - kinds.append(.data(json: "null")) - } else if let data = d["data"] { - guard let json = JSONText.encode(data) else { throw ParseError(message: "'data' is not JSON") } + if let text = present(d["dataJson"]) { + guard let json = text as? String, JSONText.parse(json) != nil else { + throw ParseError(message: "'dataJson' must be JSON text") + } kinds.append(.data(json: json)) } if let form = present(d["form"]) { kinds.append(.form(try parseForm(form))) } @@ -92,41 +86,74 @@ enum EnqueueParser { guard kinds.count <= 1 else { throw ParseError(message: "more than one body kind") } guard url != nil || !parts.isEmpty else { throw ParseError(message: "'url' is required unless 'parts' is set") } let body = kinds.first ?? .none + if method == "GET", !kinds.isEmpty { throw ParseError(message: "a GET request cannot have a body") } return ParsedEnqueue( - id: id, key: key, varsJSON: varsJSON, descriptorJSON: descriptorJSON, url: url, - method: method, headers: headers(present(d["headers"])), body: body, parts: parts, + id: id, key: key, varsJSON: varsJSON, url: url, + method: method, headers: try headers(present(d["headers"])), body: body, parts: parts, accept: UploadOutcome.parseAcceptRules(present(d["accept"])), expiresAt: expiresAt, retry: RetryOverride.parse(present(d["retry"])), - fingerprint: fingerprint(body, parts: parts)) + fingerprint: fingerprint(body, parts: parts, url: url, method: method)) } /// Strings and numbers become headers. Anything else is skipped, never /// interpolated onto the wire ("" in an Authorization header). - static func headers(_ raw: Any?) -> [String: String] { + /// Throws on a name that is not an HTTP token, or a value with CR, LF or + /// NUL: URLRequest drops those without a word. The message names the + /// header, never its value. + static func headers(_ raw: Any?, field: String = "headers") throws -> [String: String] { guard let headers = raw as? [String: Any] else { return [:] } var result: [String: String] = [:] for (k, v) in headers { + let value: String if let s = v as? String { - result[k] = s + value = s } else if let n = v as? NSNumber { - result[k] = n.stringValue + value = n.stringValue + } else { + continue + } + guard isToken(k) else { throw ParseError(message: "'\(field)' has an invalid header name") } + guard !hasLineBreakOrNUL(value) else { + throw ParseError(message: "'\(field)' header '\(k)' has a line break or NUL in its value") } + result[k] = value } return result } - /// Body identity. data: canonical JSON, so key order does not matter. + // RFC 9110 token: the characters a header name may use. + private static let tokenChars = CharacterSet(charactersIn: "!#$%&'*+-.^_`|~") + .union(CharacterSet(charactersIn: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")) + + // By scalar: "\r\n" is one Character, so a Character compare misses it. + private static func hasLineBreakOrNUL(_ s: String) -> Bool { + s.unicodeScalars.contains { $0 == "\r" || $0 == "\n" || $0 == "\0" } + } + + private static func isToken(_ name: String) -> Bool { + !name.isEmpty && name.unicodeScalars.allSatisfy { tokenChars.contains($0) } + } + + /// Body identity, and the url and method: a different url or method is a + /// different body. data: canonical JSON, so key order does not matter. /// form: the fields as sent; paths compare as strings, because the caller /// may have deleted the source after the first mutate() resolved. file: the /// path as sent. parts: url and range of each part; the `file` path is not /// part of it, because the moved blob is the body. - static func fingerprint(_ body: ParsedEnqueue.Body, parts: [QueueEntry.Part]) -> String { + static func fingerprint(_ body: ParsedEnqueue.Body, parts: [QueueEntry.Part], url: String?, + method: String) -> String { + bodyFingerprint(body, parts: parts) + "|" + JSONText.sha256(method + " " + (url ?? "")) + } + + private static func bodyFingerprint(_ body: ParsedEnqueue.Body, parts: [QueueEntry.Part]) -> String { switch body { case .none: return "none" case .data(let json): - return "data:" + JSONText.sha256(json) + // Canonical text, so key order does not matter. + let canonical = JSONText.parse(json).flatMap { JSONText.encode($0) } ?? json + return "data:" + JSONText.sha256(canonical) case .form(let fields): let text = fields.map { f in [f.name, f.contentType, f.string.map { "s:" + $0 } ?? "", f.path.map { "p:" + $0 } ?? "", @@ -144,9 +171,12 @@ enum EnqueueParser { value is NSNull ? nil : value } + /// http or https with a host. The message leaves the URL out: its query + /// may hold a token. private static func requireURL(_ s: String, _ field: String) throws { - guard let u = URL(string: s), u.scheme != nil, u.host != nil else { - throw ParseError(message: "'\(field)' is not a valid URL") + guard let u = URL(string: s), let scheme = u.scheme?.lowercased(), + scheme == "http" || scheme == "https", let host = u.host, !host.isEmpty else { + throw ParseError(message: "'\(field)' must be an http or https URL") } } @@ -158,6 +188,10 @@ enum EnqueueParser { guard let name = f["name"] as? String, let contentType = f["contentType"] as? String else { throw ParseError(message: "'form[\(i)]' needs name and contentType") } + // The content type goes into the multipart part header as it is. + guard !hasLineBreakOrNUL(contentType) else { + throw ParseError(message: "'form[\(i)].contentType' has a line break") + } let string = present(f["string"]) as? String let path = present(f["path"]) as? String guard (string == nil) != (path == nil) else { @@ -182,7 +216,7 @@ enum EnqueueParser { start >= 0, start < end else { throw ParseError(message: "invalid 'parts[\(i)].range'") } - return QueueEntry.Part(url: url, headers: headers(present(p["headers"])), start: start, + return QueueEntry.Part(url: url, headers: try headers(present(p["headers"]), field: "parts[\(i)].headers"), start: start, end: end, accepted: false, rejections: 0) } } diff --git a/ios/Events.swift b/ios/Events.swift index 66afe57f..fbc45921 100644 --- a/ios/Events.swift +++ b/ios/Events.swift @@ -1,10 +1,10 @@ import Foundation /// Builds the live `attempt` event: one HTTP attempt before the library -/// interprets it for retry. `outcome` follows the v8 taxonomy: 'completed' -/// only for a 2xx or a matching accept rule; any other response is 'error' -/// with errorKind 'http'; a transport failure is 'error' with its kind; a -/// cancel the library did not ask for is 'cancelled' with 'system'. +/// interprets it for retry. `outcome` is 'completed' for a 2xx or a matching +/// accept rule. Any other response is 'error' with errorKind 'http'. A +/// transport failure is 'error' with its kind. A cancel of any kind (pause, +/// cancel, supersede, the system) is not an attempt: the caller emits none. enum AttemptEvent { struct Input { var id: String @@ -19,7 +19,6 @@ enum AttemptEvent { var body: String? var error: NSError? var accepted: Bool - var systemCancel: Bool var at: Double } @@ -36,10 +35,7 @@ enum AttemptEvent { m["responseBody"] = body ?? "" m["responseBodyTruncated"] = truncated } - if i.systemCancel { - m["outcome"] = "cancelled" - m["cancelReason"] = "system" - } else if let error = i.error { + if let error = i.error { m["outcome"] = "error" m["errorKind"] = RetryClassifier.errorKind(for: error) m["errorMessage"] = error.localizedDescription diff --git a/ios/JSONText.swift b/ios/JSONText.swift index c9ed7f7e..a6624d81 100644 --- a/ios/JSONText.swift +++ b/ios/JSONText.swift @@ -22,8 +22,13 @@ enum JSONText { /// The bridged object for stored text. NSNull (JS null) when the text is /// "null" or does not parse. static func decode(_ text: String) -> Any { - (try? JSONSerialization.jsonObject(with: Data(text.utf8), options: [.fragmentsAllowed])) - ?? NSNull() + parse(text) ?? NSNull() + } + + /// The parsed value, or nil when the text is not JSON. "null" parses to + /// NSNull, which is a value. + static func parse(_ text: String) -> Any? { + try? JSONSerialization.jsonObject(with: Data(text.utf8), options: [.fragmentsAllowed]) } static func sha256(_ text: String) -> String { diff --git a/ios/LegacyImport.swift b/ios/LegacyImport.swift index 9a290e18..3003b22d 100644 --- a/ios/LegacyImport.swift +++ b/ios/LegacyImport.swift @@ -4,9 +4,9 @@ import Foundation /// /// Each v9 journal entry becomes a read-only settled row with key "legacy" /// and id = the v9 upload id. Nothing is delivered: Diana reads the rows, -/// marks those transfers terminal, and cancels them. When the id also has a -/// v9 chunked manifest, the row reports its bytes, and the blob stays in the -/// directory until cancel(id). +/// marks those transfers terminal, and cancels them. A legacy row reports +/// 0/0 bytes, as on Android. When the id also has a v9 chunked manifest, the +/// blob stays in the directory until cancel(id) or a same-id enqueue. /// /// A manifest with no journal entry makes no row. It stays dormant until a /// same-id enqueue adopts it (a legacy row would be cancelled by Diana, and @@ -25,13 +25,13 @@ enum LegacyImport { guard let state = state(e.type) else { return nil } let manifest = manifests[e.id] return QueueEntry( - id: e.id, key: key, varsJSON: "null", descriptorJSON: "{}", url: nil, method: "POST", + id: e.id, key: key, varsJSON: "null", url: nil, method: "POST", accept: [], retry: nil, bodyKind: .none, bodyPath: manifest == nil ? nil : ChunkedManifestV9.blobName, bodyContentType: nil, forceContentType: false, bodyFingerprint: fingerprint, parts: [], incarnation: manifest?.incarnation ?? UUID().uuidString, headers: [:], headerGeneration: 0, state: state, authParked: false, generation: 1, attempts: 0, - bytesSent: manifest?.acceptedBytes ?? 0, totalBytes: manifest?.totalBytes ?? 0, + bytesSent: 0, totalBytes: 0, expiresAt: manifest?.expiresAt ?? e.timestamp, nextAttemptAt: nil, settledEventId: nil, lastRequestId: nil, lastUrl: nil, lastPartIndex: e.partIndex, legacy: true, createdAt: e.timestamp, updatedAt: e.timestamp) diff --git a/ios/QueueCoordinator+Enqueue.swift b/ios/QueueCoordinator+Enqueue.swift index 7d0be80d..3a852eaf 100644 --- a/ios/QueueCoordinator+Enqueue.swift +++ b/ios/QueueCoordinator+Enqueue.swift @@ -12,7 +12,7 @@ extension QueueCoordinator { do { p = try EnqueueParser.parse(raw) } catch { - throw EnqueueError.storage("enqueue: \(error.localizedDescription)") + throw EnqueueError.invalid("enqueue: \(error.localizedDescription)") } if let existing = index.entry(p.id) { if existing.legacy { @@ -79,8 +79,10 @@ extension QueueCoordinator { if existing.bodyFingerprint == p.fingerprint && !existing.legacy { switch existing.state { case .completed: - // Rule 7: re-emit the journaled outcome. Do not run again. - if let eventId = existing.settledEventId, let event = redeliver(eventId) { + // Rule 7: re-emit the journaled outcome. Do not run again. With no + // listener yet, the drain delivers it. + if sink?.canDeliver() == true, let eventId = existing.settledEventId, + let event = redeliver(eventId) { sink?.emitSettled(event.bridged) } return (p.id, false) @@ -101,8 +103,9 @@ extension QueueCoordinator { return (p.id, !paused) case .awaitingAuth: - // Fresh headers came with the call: leave the parking spot. - var n = existing.resumed(with: p, resetBudget: true, now: now()) + // Fresh headers came with the call: leave the parking spot. Same + // generation, so attempts keep counting. + var n = existing.resumed(with: p, resetBudget: false, now: now()) n.authParked = false n.state = paused ? .paused : .queued try saveOrThrow(n) @@ -117,6 +120,12 @@ extension QueueCoordinator { try saveOrThrow(n) publish(n) armExpiry(n) + if ready, !paused, n.state == .queued, !n.isChunked, let at = n.nextAttemptAt, at > now() { + // A simple retry waiting out its backoff: the caller asks again, + // so retry now. The waiting attempt never ran: keep its ordinal. + cancelTasks(n.id, purpose: .superseded) + issue(n.id, delayMs: nil, advanceAttempt: false) + } return (p.id, false) } } @@ -174,7 +183,9 @@ extension QueueCoordinator { return try body() } catch StagingError.fileMissing(let path) { throw EnqueueError.fileMissing(path) - } catch StagingError.invalid(let message), StagingError.io(let message) { + } catch StagingError.invalid(let message) { + throw EnqueueError.invalid("enqueue: \(message)") + } catch StagingError.io(let message) { throw EnqueueError.storage("enqueue: \(message)") } } diff --git a/ios/QueueCoordinator+Outcomes.swift b/ios/QueueCoordinator+Outcomes.swift index 777d290c..914b5281 100644 --- a/ios/QueueCoordinator+Outcomes.swift +++ b/ios/QueueCoordinator+Outcomes.swift @@ -10,7 +10,8 @@ extension QueueCoordinator { /// The one terminal path. Cancels the entry's remaining tasks, emits the /// trailing progress, journals the outcome, saves the settled row, emits - /// `state`, then emits `settled` with deliveries 1. + /// `state`, then emits `settled` with deliveries 1 when a listener exists. + /// With no listener the outcome stays at deliveries 0 for the drain. /// /// A failed journal write still settles and emits: the request already /// ran, so a retry would send it twice, and a user cancel must not run @@ -22,7 +23,8 @@ extension QueueCoordinator { chunked.stop(id) switch outcome { case .completed: e.bytesSent = e.totalBytes - default: e.bytesSent = e.isChunked ? e.acceptedBytes : (lastSent[id] ?? e.bytesSent) + // A simple entry keeps the live bytes of its last attempt. + default: if e.isChunked { e.bytesSent = e.acceptedBytes } } emitProgress(id, sent: e.bytesSent, total: e.totalBytes) @@ -52,20 +54,59 @@ extension QueueCoordinator { e.nextAttemptAt = nil e.authParked = false commit(e) - var delivered: JournaledEvent + // Checked after the append. A drain that ran before this line already + // set the flag; one that runs after reads the journal and counts it. + let live = sink?.canDeliver() == true if journaled { - delivered = journal.markDelivered([event.eventId]).first ?? event + if live { + var delivered = journal.markDelivered([event.eventId]).first ?? event + delivered.deliveries = max(delivered.deliveries, 1) + sink?.emitSettled(delivered.bridged) + } } else { - event.deliveries = 1 + event.deliveries = live ? 1 : 0 pendingJournal[event.eventId] = event retryJournal(event.eventId, delayMs: Self.journalRetryMs) - delivered = event + if live { sink?.emitSettled(event.bridged) } } - delivered.deliveries = max(delivered.deliveries, 1) - sink?.emitSettled(delivered.bridged) disarmExpiry(id) throttle.reset(id) - lastSent[id] = nil + } + + /// A settle whose journal write landed but whose entry.json save failed + /// leaves a live row on disk for an outcome that already happened. This + /// moves the row to that outcome, with no emit: the journal drain + /// delivers it. Returns the settled row, or nil when `event` is not the + /// outcome of this row's generation. + @discardableResult + func applyJournaled(_ event: JournaledEvent, to e: QueueEntry) -> QueueEntry? { + guard e.isLive, !e.legacy, event.id == e.id, event.generation == e.generation else { return nil } + var n = e + switch event.kind { + case .completed: n.state = .completed + case .error: n.state = .error + case .cancelled: n.state = .cancelled + } + n.settledEventId = event.eventId + n.bytesSent = event.bytesSent + n.nextAttemptAt = nil + n.authParked = false + cancelTasks(e.id, purpose: .superseded) + chunked.stop(e.id) + disarmExpiry(e.id) + commit(n, emit: false) + return n + } + + /// Relaunch, before any task is matched: every live row whose own + /// generation already has an unacked outcome takes that outcome, so it is + /// never sent again. + func repairLostSettles() { + let byId = Dictionary(grouping: journal.unacknowledged(), by: \.id) + for e in index.entries() where e.isLive && !e.legacy { + guard let event = byId[e.id]?.last(where: { $0.generation == e.generation }) else { continue } + applyJournaled(event, to: e) + } } /// Deletes the row and the bytes. `dropEvents` also deletes the id's @@ -81,7 +122,6 @@ extension QueueCoordinator { } disarmExpiry(id) throttle.reset(id) - lastSent[id] = nil } /// One ack. The event comes from the journal, or from memory when its @@ -91,8 +131,10 @@ extension QueueCoordinator { let event = journal.load(eventId) ?? pendingJournal[eventId] pendingJournal[eventId] = nil journal.ack([eventId]) - let owner = event.flatMap { index.entry($0.id) } + var owner = event.flatMap { index.entry($0.id) } ?? index.entries().first { $0.settledEventId == eventId } + // An ack can run before the relaunch repair: settle a live row first. + if let event, let e = owner, let settled = applyJournaled(event, to: e) { owner = settled } guard let e = owner, e.isSettled, !e.legacy, e.state != .error else { return } if let event { guard event.kind != .error, event.generation == e.generation else { return } diff --git a/ios/QueueCoordinator+Reconcile.swift b/ios/QueueCoordinator+Reconcile.swift index 15bda599..206ed7b4 100644 --- a/ios/QueueCoordinator+Reconcile.swift +++ b/ios/QueueCoordinator+Reconcile.swift @@ -8,22 +8,73 @@ extension QueueCoordinator { /// daemon may still replay before it re-issues. static let graceMs = 10_000 - /// `completion` runs on the queue once every entry has its tasks again. - /// The caller holds the background completion handlers until then, so the - /// system cannot suspend the app before the refill enqueues new tasks. + /// `completion` runs on the queue once every entry has its tasks again and + /// every grace wait has ended. The caller holds the background completion + /// handlers until then, so the system cannot suspend the app before the + /// refill enqueues new tasks, or in the middle of a grace wait. func reconcileAll(completion: @escaping () -> Void) { queue.async { self.transport.allTasks { tasks in self.queue.async { self.reconcile(tasks) - completion() + self.whenGraceEnds(completion) } } } } + /// One open grace wait: the TaskMap keys whose completion may still + /// replay, and what to do when the wait ends. + struct Grace { + let token: UUID + var keys: Set + let resolve: () -> Void + } + + /// Runs `block` now when no grace wait is open, or when the last one ends. + func whenGraceEnds(_ block: @escaping () -> Void) { + if graces.isEmpty { + block() + } else { + afterGrace.append(block) + } + } + + /// Opens a grace wait for `keys`. It ends when the completion of every key + /// was handled, or after `graceMs`, whichever comes first. Then `resolve` + /// runs. Does nothing when a wait with this name is open. + func openGrace(_ name: String, keys: Set, resolve: @escaping () -> Void) { + guard graces[name] == nil else { return } + let token = UUID() + graces[name] = Grace(token: token, keys: keys, resolve: resolve) + schedule(Self.graceMs) { [weak self] in self?.endGrace(name, token: token) } + } + + /// A completion was handled for `key`. A wait with no key left ends now, + /// so the background completion handler does not wait out the timer. + func replayHandled(_ key: String) { + for (name, grace) in graces where grace.keys.contains(key) { + graces[name]?.keys.remove(key) + if graces[name]?.keys.isEmpty == true { endGrace(name, token: grace.token) } + } + } + + /// Closes one wait, runs its resolve, and releases the held blocks when no + /// wait is open. The token stops the timer of a wait that already ended + /// from closing a newer wait with the same name. + private func endGrace(_ name: String, token: UUID) { + guard let grace = graces[name], grace.token == token else { return } + graces[name] = nil + grace.resolve() + guard graces.isEmpty else { return } + let blocks = afterGrace + afterGrace = [] + blocks.forEach { $0() } + } + func reconcile(_ tasks: [UploadTask]) { ready = true + repairLostSettles() sweepOrphanedOutcomes() var simpleLive: Set = [] var partTasks: [String: [ChunkedCoordinator.LiveTask]] = [:] @@ -70,25 +121,21 @@ extension QueueCoordinator { /// it was lost. The TaskMap tells them apart: its key goes when a /// completion is handled. private func withoutTask(_ e: QueueEntry) { - let pending = !taskMap.keys(where: { + let pending = taskMap.keys(where: { $0.id == e.id && $0.generation == e.generation && $0.attempt == e.attempts - }).isEmpty - guard pending else { + }) + guard !pending.isEmpty else { // No task was ever made for a waiting attempt: it never ran, so it // keeps its ordinal and request id. reissue(e, advanceAttempt: false) return } - guard !graceChecks.contains(e.id) else { return } - graceChecks.insert(e.id) - schedule(Self.graceMs) { [weak self] in - guard let self else { return } - self.graceChecks.remove(e.id) - guard let current = self.index.entry(e.id), current.state == e.state, + openGrace(e.id, keys: Set(pending)) { [weak self] in + guard let self, let current = self.index.entry(e.id), current.state == e.state, current.generation == e.generation, current.attempts == e.attempts, !self.liveTasks.values.contains(where: { $0.id == e.id }) else { return } - // No replay came: the task is lost. Its key would send every later - // launch through this wait again. + // No replay moved the entry: the task is lost. Its key would send + // every later launch through this wait again. self.taskMap.removeAll { _, m in m.id == e.id && m.generation == e.generation && m.attempt == e.attempts } diff --git a/ios/QueueCoordinator+Simple.swift b/ios/QueueCoordinator+Simple.swift index 2ebc7ea2..511a25ae 100644 --- a/ios/QueueCoordinator+Simple.swift +++ b/ios/QueueCoordinator+Simple.swift @@ -23,8 +23,17 @@ extension QueueCoordinator { } guard ready else { // Reconcile has not matched the daemon's tasks yet. Keep the queued - // state (and the wait) on disk; reconcile issues it. - if let delayMs { e.nextAttemptAt = t + Double(delayMs) } + // state and the wait on disk; reconcile issues it. A wait follows an + // attempt that ran (a completion that landed before reconcile), so + // mint its successor now: reconcile keeps a waiting attempt's ordinal, + // and must not reuse the one that ran. + if let delayMs { + if advanceAttempt { + e.attempts += 1 + e.lastRequestId = UUID().uuidString + } + e.nextAttemptAt = t + Double(delayMs) + } commit(e) return } @@ -44,7 +53,10 @@ extension QueueCoordinator { let before = e let reuse = !advanceAttempt && e.nextAttemptAt != nil && e.attempts > 0 && e.lastRequestId != nil let requestId = reuse ? e.lastRequestId! : UUID().uuidString - if !reuse { e.attempts += 1 } + if !reuse { + e.attempts += 1 + e.bytesSent = 0 // a new attempt sends from byte 0 + } e.lastRequestId = requestId e.lastUrl = urlString e.lastPartIndex = nil @@ -61,7 +73,7 @@ extension QueueCoordinator { } let meta = TaskMap.Meta( - id: id, accept: e.accept, attempt: e.attempts, requestId: requestId, + id: id, attempt: e.attempts, requestId: requestId, headerGeneration: settings.headerGeneration, generation: e.generation, purpose: .attempt) let task = transport.upload( buildRequest(e, url: url, requestId: requestId), fromFile: body, wifiOnly: settings.wifiOnly, @@ -100,6 +112,9 @@ extension QueueCoordinator { let meta = taskMap.meta(forKey: c.key) taskMap.removeKey(c.key) liveTasks[c.key] = nil + // Runs after the completion moved the entry, so a grace that ends here + // sees the new state. + defer { replayHandled(c.key) } guard let owner = TaskOwner.resolve(description: c.description, meta: meta) else { return } if case .part(let id, let part, let incarnation) = owner { chunked.partCompleted(id: id, part: part, incarnation: incarnation, key: c.key, meta: meta, @@ -118,8 +133,12 @@ extension QueueCoordinator { // A replaced task that finished before its cancel took effect. Its // replacement drives the entry, unless this one landed. if meta?.purpose == .superseded && !accepted { return } - emitAttempt(e, requestId: meta?.requestId ?? e.lastRequestId, attempt: attempt, completion: c, - partIndex: nil, accepted: accepted, systemCancel: cancelled) + // A cancel the library did not ask for (the system, a force-quit) is not + // an attempt: no event. It retries below. + if !cancelled { + emitAttempt(e, requestId: meta?.requestId ?? e.lastRequestId, attempt: attempt, completion: c, + partIndex: nil, accepted: accepted) + } guard e.state == .running || e.state == .queued else { // A pause raced this completion. An accepted response did land, so @@ -127,8 +146,7 @@ extension QueueCoordinator { if accepted && e.state == .paused { settle(id, .completed(response(c))) } return } - // A cancel the library did not ask for (the system, a force-quit) is a - // transient failure, never a 'cancelled' outcome. + // A system cancel is a transient failure, never a 'cancelled' outcome. if cancelled { scheduleRetry(e) return @@ -136,11 +154,11 @@ extension QueueCoordinator { let fileExists = store.bodyURL(e).map(FileIO.exists) ?? false let verdict = RetryClassifier.classify(RetryClassifier.Input( statusCode: c.statusCode, body: c.body, error: c.error, accept: e.accept, policy: policy(e), - isChunkedPart: false, fileExists: fileExists, now: now(), expiresAt: e.expiresAt)) + fileExists: fileExists, now: now(), expiresAt: e.expiresAt)) switch verdict { case .accepted: settle(id, .completed(response(c))) - case .transient, .fileUnreadable: + case .transient: scheduleRetry(e) case .auth: if let g = meta?.headerGeneration, g < settings.headerGeneration { @@ -216,7 +234,7 @@ extension QueueCoordinator { e.nextAttemptAt = nil self.commit(e) } - self.lastSent[id] = sent + self.index.setBytes(id, sent) self.emitProgress(id, sent: sent, total: expected > 0 ? expected : e.totalBytes) } } @@ -247,6 +265,8 @@ extension QueueCoordinator { n.authParked = true n.nextAttemptAt = nil commit(n) + // The timer may have passed while the entry ran. + armExpiry(n) } func response(_ c: TaskCompletion) -> RawResponseRecord { diff --git a/ios/QueueCoordinator.swift b/ios/QueueCoordinator.swift index 2b9990f5..1b38b5e0 100644 --- a/ios/QueueCoordinator.swift +++ b/ios/QueueCoordinator.swift @@ -6,6 +6,8 @@ struct EnqueueError: Error { let message: String static func storage(_ message: String) -> EnqueueError { EnqueueError(code: "E_STORAGE", message: message) } + /// Input native cannot send: a bad URL scheme, header, body kind or tiling. + static func invalid(_ message: String) -> EnqueueError { EnqueueError(code: "E_INVALID", message: message) } static func running(_ id: String) -> EnqueueError { EnqueueError(code: "E_RUNNING", message: "enqueue: '\(id)' is running; a different body is accepted once it stops") } @@ -67,9 +69,11 @@ final class QueueCoordinator { /// Every task this process created or adopted, by TaskMap key. var liveTasks: [String: (id: String, task: UploadTask)] = [:] var expiryTokens: [String: UUID] = [:] - var graceChecks: Set = [] - /// Last bytesSent reported for a simple entry, for the trailing edge. - var lastSent: [String: Int64] = [:] + /// Open grace waits (a completion that may still replay), by name. While + /// any is open, `afterGrace` holds the background completion handler + /// release. + var graces: [String: Grace] = [:] + var afterGrace: [() -> Void] = [] /// Outcomes whose journal write failed, by eventId. They were emitted /// live; a timer retries the write while the entry still names them. var pendingJournal: [String: JournaledEvent] = [:] @@ -160,6 +164,11 @@ final class QueueCoordinator { return } for e in self.index.entries() where e.state == .paused && !e.legacy { + // A pause does not expire an entry; the resume does. + if self.now() >= e.expiresAt { + self.settle(e.id, .expired) + continue + } var n = e n.state = e.authParked ? .awaitingAuth : .queued self.commit(n) @@ -214,7 +223,13 @@ final class QueueCoordinator { func updateHeaders(_ patch: [String: Any], resolve: @escaping () -> Void, reject: @escaping (String, String) -> Void) { queue.async { - let headers = EnqueueParser.headers(patch) + let headers: [String: String] + do { + headers = try EnqueueParser.headers(patch, field: "patch") + } catch { + reject("E_INVALID", "updateHeaders: \(error.localizedDescription)") + return + } var next = self.settings next.headerGeneration += 1 guard self.saveSettings(next) else { @@ -224,6 +239,10 @@ final class QueueCoordinator { for e in self.index.entries() where !e.legacy { var n = e n.headers = HeaderMerge.merge(e.headers, headers) + // A part's own header of the same name would win over the entry's. + for i in n.parts.indices { + n.parts[i].headers = HeaderMerge.replaceExisting(n.parts[i].headers, headers) + } n.headerGeneration = self.settings.headerGeneration if n.state == .awaitingAuth { n.authParked = false @@ -248,6 +267,9 @@ final class QueueCoordinator { /// Every unacked outcome, oldest first. Each return counts as a delivery. func unacknowledgedEvents(resolve: @escaping ([[String: Any]]) -> Void) { queue.async { + // JS drains after it attaches its listener. From here on, a settle is + // emitted live. + self.sink?.listenerReady() resolve(self.unacknowledgedLocked().map(\.bridged)) } } @@ -305,12 +327,18 @@ final class QueueCoordinator { /// Records why, then cancels every task this process holds for `id`. func cancelTasks(_ id: String, purpose: TaskMap.Purpose) { for (key, owner) in liveTasks where owner.id == id { - taskMap.setPurpose(purpose, forKey: key, id: id) - owner.task.cancel() - liveTasks[key] = nil + cancelTask(key, purpose: purpose) } } + /// One task, by TaskMap key. + func cancelTask(_ key: String, purpose: TaskMap.Purpose) { + guard let owner = liveTasks[key] else { return } + taskMap.setPurpose(purpose, forKey: key, id: owner.id) + owner.task.cancel() + liveTasks[key] = nil + } + func emitProgress(_ id: String, sent: Int64, total: Int64) { sink?.emitProgress(["id": id, "bytesSent": sent, "totalBytes": total]) } @@ -336,22 +364,27 @@ final class QueueCoordinator { return request } + /// One HTTP attempt that ended with a response or a transport error. A + /// cancel of any kind is not an attempt and never gets here. func emitAttempt(_ e: QueueEntry, requestId: String?, attempt: Int, completion c: TaskCompletion, - partIndex: Int?, accepted: Bool, systemCancel: Bool) { + partIndex: Int?, accepted: Bool) { let url = c.url ?? partIndex.flatMap { e.parts.indices.contains($0) ? e.parts[$0].url : nil } ?? e.url ?? "" sink?.emitAttempt(AttemptEvent.build(AttemptEvent.Input( id: e.id, key: e.key, requestId: requestId ?? "", attempt: attempt, url: url, method: e.method, partIndex: partIndex, statusCode: c.statusCode, headers: c.headers, - body: c.body, error: systemCancel ? nil : c.error, accepted: accepted, - systemCancel: systemCancel, at: now()))) + body: c.body, error: c.error, accepted: accepted, at: now()))) } // MARK: - Expiry /// One in-process timer per live entry at expiresAt + 100 ms. A later arm /// replaces the token, so a resume that moved expiresAt makes the old timer - /// a no-op. Long waits re-arm daily. + /// a no-op. Long waits re-arm daily. It settles a queued or awaiting-auth + /// entry. It leaves a paused one to resume(), and a running one to the + /// result of its attempt: a real response keeps its own error kind, and a + /// transient one becomes 'expired' (scheduleRetry, retryPart). An entry + /// that parks after that is armed again by park(). func armExpiry(_ e: QueueEntry) { guard e.isLive, !e.legacy else { return } let token = UUID() @@ -361,10 +394,13 @@ final class QueueCoordinator { guard let self, self.expiryTokens[e.id] == token else { return } self.expiryTokens[e.id] = nil guard let current = self.index.entry(e.id), current.isLive, !current.legacy else { return } - if self.now() >= current.expiresAt { - self.settle(current.id, .expired) - } else { + guard self.now() >= current.expiresAt else { self.armExpiry(current) + return + } + switch current.state { + case .paused, .running: return + default: self.settle(current.id, .expired) } } } diff --git a/ios/QueueEntry.swift b/ios/QueueEntry.swift index f829461e..7ebc2437 100644 --- a/ios/QueueEntry.swift +++ b/ios/QueueEntry.swift @@ -31,7 +31,6 @@ struct QueueEntry: Codable, Equatable { let id: String var key: String var varsJSON: String - var descriptorJSON: String /// nil only for a chunked entry whose descriptor has no url. var url: String? var method: String @@ -127,8 +126,7 @@ extension QueueEntry { static func created(from p: ParsedEnqueue, staged: StagedBody, headerGeneration: Int, paused: Bool, now: Double, createdAt: Double? = nil) -> QueueEntry { QueueEntry( - id: p.id, key: p.key, varsJSON: p.varsJSON, descriptorJSON: p.descriptorJSON, - url: p.url, method: p.method, accept: p.accept, retry: p.retry, + id: p.id, key: p.key, varsJSON: p.varsJSON, url: p.url, method: p.method, accept: p.accept, retry: p.retry, bodyKind: staged.kind, bodyPath: staged.relativePath, bodyContentType: staged.contentType, forceContentType: staged.forceContentType, bodyFingerprint: p.fingerprint, parts: p.parts, incarnation: UUID().uuidString, @@ -140,16 +138,14 @@ extension QueueEntry { } /// Rule 3, same body: the new vars, headers, expiresAt and descriptor - /// fields replace the stored ones. The body, accepted parts, generation and - /// createdAt stay. `resetBudget` (a reopen) also resets attempts and part - /// rejections, because a resume brings fresh headers. + /// fields replace the stored ones. The body, url, method, accepted parts, + /// generation and createdAt stay (a different url or method is a different + /// body). `resetBudget` (a reopen, which starts a new generation) also + /// resets attempts and part rejections: attempts count one generation. func resumed(with p: ParsedEnqueue, resetBudget: Bool, now: Double) -> QueueEntry { var next = self next.key = p.key next.varsJSON = p.varsJSON - next.descriptorJSON = p.descriptorJSON - next.url = p.url - next.method = p.method next.headers = p.headers next.expiresAt = p.expiresAt next.accept = p.accept @@ -207,6 +203,14 @@ enum HeaderMerge { return result } + /// Replaces only the names `base` already carries, in any case. A part's + /// own header (say Authorization) takes the patched value; a name the + /// part does not carry is left to the entry headers. + static func replaceExisting(_ base: [String: String], _ patch: [String: String]) -> [String: String] { + let carried = Set(base.keys.map { $0.lowercased() }) + return merge(base, patch.filter { carried.contains($0.key.lowercased()) }) + } + static func value(_ name: String, in headers: [String: String]) -> String? { let lower = name.lowercased() return headers.first { $0.key.lowercased() == lower }?.value diff --git a/ios/QueueSettings.swift b/ios/QueueSettings.swift index 4e85ce7b..6249af68 100644 --- a/ios/QueueSettings.swift +++ b/ios/QueueSettings.swift @@ -53,7 +53,6 @@ struct QueueSettings: Codable, Equatable { /// issued under; a 401/403 from an older value re-issues instead of parking. var headerGeneration = 0 var retry: RetryOverride? - var lifetimeMs: Double? init() {} @@ -65,12 +64,11 @@ struct QueueSettings: Codable, Equatable { paused = try c.decodeIfPresent(Bool.self, forKey: .paused) ?? false headerGeneration = try c.decodeIfPresent(Int.self, forKey: .headerGeneration) ?? 0 retry = try c.decodeIfPresent(RetryOverride.self, forKey: .retry) - lifetimeMs = try c.decodeIfPresent(Double.self, forKey: .lifetimeMs) } - /// configure(options). The Android notification keys are ignored on iOS. + /// configure(options). iOS reads `retry` only: JS turns lifetimeMs into + /// each entry's expiresAt, and the Android notification keys are Android's. mutating func apply(configure options: [String: Any]) { retry = RetryOverride.parse(options["retry"]) - lifetimeMs = (options["lifetimeMs"] as? NSNumber)?.doubleValue } } diff --git a/ios/QueueStore.swift b/ios/QueueStore.swift index c926f4de..07d76f23 100644 --- a/ios/QueueStore.swift +++ b/ios/QueueStore.swift @@ -154,16 +154,6 @@ final class QueueStore { } } - /// Manifests with no entry.json next to them: dormant until a same-id - /// enqueue adopts them. Not rows, never scheduled. - func allDormantManifests() -> [ChunkedManifestV9] { - queue.sync { - subdirectories() - .filter { !FileIO.exists($0.appendingPathComponent(Self.entryName)) } - .compactMap { Self.readManifest($0.appendingPathComponent(Self.manifestName)) } - } - } - func removeV9Manifest(_ id: String) { queue.sync { _ = try? FileManager.default.removeItem(at: fileURL(id, Self.manifestName)) } } diff --git a/ios/RNBackgroundUpload.swift b/ios/RNBackgroundUpload.swift index 5ab80863..9728ea17 100644 --- a/ios/RNBackgroundUpload.swift +++ b/ios/RNBackgroundUpload.swift @@ -43,6 +43,10 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { // lock that guards the delegate. private static let delegateLock = NSLock() private static weak var eventDelegate: RNFileUploaderEventDelegate? + // true from the registered module's first journal drain (its JS listener + // is attached by then) until that module goes away. Guarded by + // delegateLock. A settle with no listener is journaled at deliveries 0. + private static var listening = false // AppDelegate stores the system completion handler here per session id. private static let bgHandlerLock = NSLock() @@ -68,6 +72,7 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { transport.createSessions(delegate: self) observeAppState() // Claim the completion-handler deferral BEFORE the reconcile is queued. + // The release waits for the reconcile and for every grace wait it opens. // A relaunch reaches here inside the init of `shared`, and the AppDelegate // hook finishes that init before it stores the handler, so the claim // always precedes any drain. @@ -82,6 +87,8 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { _ = shared delegateLock.lock() eventDelegate = delegate + // A new module has no JS listener until its own drain. + listening = false delegateLock.unlock() } @@ -92,7 +99,10 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { @objc public static func clearEventDelegate(_ delegate: RNFileUploaderEventDelegate) { delegateLock.lock() defer { delegateLock.unlock() } - if eventDelegate === delegate { eventDelegate = nil } + if eventDelegate === delegate { + eventDelegate = nil + listening = false + } } fileprivate static var currentDelegate: RNFileUploaderEventDelegate? { @@ -101,6 +111,18 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { return eventDelegate } + fileprivate static var canDeliver: Bool { + delegateLock.lock() + defer { delegateLock.unlock() } + return listening && eventDelegate != nil + } + + fileprivate static func markListening() { + delegateLock.lock() + defer { delegateLock.unlock() } + if eventDelegate != nil { listening = true } + } + // MARK: - Module methods (called from the TurboModule shell) // Each is a one-line forward. The coordinator hops onto its own queue and @@ -293,6 +315,8 @@ private final class DelegateSink: EventSink { func emitProgress(_ body: [String: Any]) { RNBackgroundUpload.currentDelegate?.emitProgress(body) } func emitAttempt(_ body: [String: Any]) { RNBackgroundUpload.currentDelegate?.emitAttempt(body) } func emitSettled(_ body: [String: Any]) { RNBackgroundUpload.currentDelegate?.emitSettled(body) } + func canDeliver() -> Bool { RNBackgroundUpload.canDeliver } + func listenerReady() { RNBackgroundUpload.markListening() } } /// The two background sessions: one that may use cellular, one Wi-Fi only. diff --git a/ios/RequestIndex.swift b/ios/RequestIndex.swift index 63257493..e7b7a220 100644 --- a/ios/RequestIndex.swift +++ b/ios/RequestIndex.swift @@ -31,6 +31,17 @@ final class RequestIndex { } } + /// Live progress while an entry runs: memory only, no save, no `state` + /// event. The next commit of the entry saves it. + func setBytes(_ id: String, _ bytesSent: Int64) { + lock.lock() + defer { lock.unlock() } + guard let item = items[id] else { return } + var entry = item.entry + entry.bytesSent = bytesSent + items[id] = Item(entry: entry, vars: item.vars) + } + func remove(_ id: String) { lock.lock() defer { lock.unlock() } diff --git a/ios/RetryClassifier.swift b/ios/RetryClassifier.swift index 9fb7edef..7412fa6c 100644 --- a/ios/RetryClassifier.swift +++ b/ios/RetryClassifier.swift @@ -10,8 +10,6 @@ enum RetryClassifier { case terminalHttp /// The payload is gone. A retry can never succeed. case fileMissing - /// The payload exists but cannot be read now (iOS before first unlock). - case fileUnreadable case expired } @@ -21,10 +19,6 @@ enum RetryClassifier { var error: NSError? var accept: [UploadOutcome.AcceptRule] var policy: RetryPolicy - /// Changes nothing: a chunked definition sets `exempt: []`, so the - /// terminal row applies through the policy. It is here so a test can pin - /// the part-404 case. - var isChunkedPart: Bool var fileExists: Bool var now: Double var expiresAt: Double @@ -32,14 +26,22 @@ enum RetryClassifier { /// A cancellation (NSURLErrorCancelled) never reaches here: the caller /// handles it from the task's recorded purpose. + /// Past expiresAt only a transient result becomes `expired`; a real + /// response keeps its own class. static func classify(_ i: Input) -> Class { + let verdict = classifyResult(i) + return verdict == .transient && i.now >= i.expiresAt ? .expired : verdict + } + + private static func classifyResult(_ i: Input) -> Class { if i.error == nil, let code = i.statusCode, UploadOutcome.isAccepted(code, body: i.body, accept: i.accept) { return .accepted } - if i.now >= i.expiresAt { return .expired } if let error = i.error { - if errorKind(for: error) == "file" { return i.fileExists ? .fileUnreadable : .fileMissing } + // A file that exists but cannot be read now (iOS before first unlock) + // is transient. + if errorKind(for: error) == "file" { return i.fileExists ? .transient : .fileMissing } return .transient } guard let code = i.statusCode else { return .transient } diff --git a/ios/TaskMap.swift b/ios/TaskMap.swift index 49bacc12..130e950b 100644 --- a/ios/TaskMap.swift +++ b/ios/TaskMap.swift @@ -22,7 +22,6 @@ final class TaskMap { struct Meta: Codable, Equatable { let id: String - var accept: [UploadOutcome.AcceptRule]? var partIndex: Int? var incarnation: String? var attempt: Int? @@ -31,11 +30,10 @@ final class TaskMap { var generation: Int? var purpose: Purpose? - init(id: String, accept: [UploadOutcome.AcceptRule]? = nil, partIndex: Int? = nil, - incarnation: String? = nil, attempt: Int? = nil, requestId: String? = nil, - headerGeneration: Int? = nil, generation: Int? = nil, purpose: Purpose? = nil) { + init(id: String, partIndex: Int? = nil, incarnation: String? = nil, attempt: Int? = nil, + requestId: String? = nil, headerGeneration: Int? = nil, generation: Int? = nil, + purpose: Purpose? = nil) { self.id = id - self.accept = accept self.partIndex = partIndex self.incarnation = incarnation self.attempt = attempt @@ -46,9 +44,7 @@ final class TaskMap { } private enum CodingKeys: String, CodingKey { - case id, accept, partIndex, incarnation, attempt, requestId, headerGeneration, generation, purpose - // Builds before v9 persisted `acceptStatus: [Int]`. Read, never written. - case acceptStatus + case id, partIndex, incarnation, attempt, requestId, headerGeneration, generation, purpose } init(from decoder: Decoder) throws { @@ -62,22 +58,6 @@ final class TaskMap { generation = try c.decodeIfPresent(Int.self, forKey: .generation) // An unknown purpose from a newer build reads as nil. purpose = (try? c.decodeIfPresent(String.self, forKey: .purpose)).flatMap { Purpose(rawValue: $0) } - accept = try c.decodeIfPresent([UploadOutcome.AcceptRule].self, forKey: .accept) - ?? c.decodeIfPresent([Int].self, forKey: .acceptStatus)? - .map { UploadOutcome.AcceptRule(status: $0, bodyIncludes: nil) } - } - - func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - try c.encode(id, forKey: .id) - try c.encodeIfPresent(accept, forKey: .accept) - try c.encodeIfPresent(partIndex, forKey: .partIndex) - try c.encodeIfPresent(incarnation, forKey: .incarnation) - try c.encodeIfPresent(attempt, forKey: .attempt) - try c.encodeIfPresent(requestId, forKey: .requestId) - try c.encodeIfPresent(headerGeneration, forKey: .headerGeneration) - try c.encodeIfPresent(generation, forKey: .generation) - try c.encodeIfPresent(purpose, forKey: .purpose) } } diff --git a/ios/Tests/CoordinatorChunkedTests.swift b/ios/Tests/CoordinatorChunkedTests.swift index f88bc31e..747493ba 100644 --- a/ios/Tests/CoordinatorChunkedTests.swift +++ b/ios/Tests/CoordinatorChunkedTests.swift @@ -101,11 +101,11 @@ final class CoordinatorChunkedTests: XCTestCase { XCTAssertEqual(e.code, "E_RUNNING") } - func testTilingMismatchRejectsStorageAndKeepsTheSource() throws { + func testTilingMismatchRejectsInvalidAndKeepsTheSource() throws { let src = h.root.appendingPathComponent("short.mp4") writeFile(src, bytes: 25) guard case .failure(let e) = h.enqueue(h.chunkedRaw(id: "cap", size: 30, source: src)) else { return XCTFail() } - XCTAssertEqual(e.code, "E_STORAGE") + XCTAssertEqual(e.code, "E_INVALID") XCTAssertTrue(FileIO.exists(src)) } @@ -176,6 +176,22 @@ final class CoordinatorChunkedTests: XCTestCase { XCTAssertEqual(h.store.load("cap")?.attempts, (attempts ?? 0) + 1) } + func testPartFileBuildFailureWithAnIntactBlobRefillsLater() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + let e = try XCTUnwrap(h.entry("cap")) + // A non-empty directory where part 3's file goes: the rename fails, as + // on a full disk. The blob is whole. + let blocker = h.store.partFileURL("cap", 3, incarnation: e.incarnation, start: 30, end: 40) + writeFile(blocker.appendingPathComponent("x"), "x") + h.complete(try XCTUnwrap(partTask(0))) + XCTAssertTrue(h.sink.settled.isEmpty, "not a file terminal") + XCTAssertEqual(h.entry("cap")?.state, .running) + XCTAssertNil(partTask(3)) + try FileManager.default.removeItem(at: blocker) + h.advance(1_000) + XCTAssertNotNil(partTask(3), "built and sent after the backoff") + } + func testPart401ParksTheWholeEntryAndHeadersResumeIt() throws { _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() h.complete(try XCTUnwrap(partTask(0))) @@ -193,6 +209,51 @@ final class CoordinatorChunkedTests: XCTestCase { XCTAssertNil(partTask(0), "accepted parts are not sent again") } + func testPart401UnderAnOlderGenerationReissuesThePartAtOnce() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + let first = try XCTUnwrap(partTask(1)) + h.updateHeaders(["Authorization": "fresh"]) + h.complete(first, status: 401) + XCTAssertEqual(h.entry("cap")?.state, .running, "not parked") + let again = try XCTUnwrap(partTask(1)) + XCTAssertTrue(again !== first) + XCTAssertNil(again.beginAt, "no backoff") + XCTAssertEqual(again.header("Authorization"), "fresh") + XCTAssertEqual(h.transport.live.count, 3) + } + + func testUpdateHeadersReplacesAHeaderAPartCarries() throws { + var raw = h.chunkedRaw(id: "cap", size: 30, parts: 3) + var d = raw["descriptor"] as! [String: Any] + d["parts"] = (d["parts"] as! [[String: Any]]).map { p in + var p = p + var headers = p["headers"] as! [String: Any] + headers["authorization"] = "part-old" + p["headers"] = headers + return p + } + raw["descriptor"] = d + _ = try h.enqueue(raw).get() + h.complete(try XCTUnwrap(partTask(0)), status: 401) + XCTAssertEqual(h.entry("cap")?.state, .awaitingAuth) + h.updateHeaders(["Authorization": "fresh", "X-New": "1"]) + XCTAssertEqual(h.transport.live.count, 3) + XCTAssertTrue(h.transport.live.allSatisfy { $0.header("Authorization") == "fresh" }, "the part's own header too") + XCTAssertEqual(h.entry("cap")?.parts[0].headers["Authorization"], "fresh") + XCTAssertNil(h.entry("cap")?.parts[0].headers["authorization"], "one spelling") + XCTAssertNil(h.entry("cap")?.parts[0].headers["X-New"], "a name the part lacks stays on the entry") + XCTAssertNotNil(h.entry("cap")?.parts[0].headers["Content-Range"]) + } + + func testRowCarriesByteWeightedLiveProgress() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + h.complete(try XCTUnwrap(partTask(0))) + let running = try XCTUnwrap(partTask(1)) + h.coordinator.taskProgress(key: running.key, description: running.taskDescription, sent: 4, expected: 10) + h.drain() + XCTAssertEqual(h.row("cap")?["bytesSent"] as? Int64, 14) + } + func testPauseKeepsAcceptedPartsAndResumeRefills() throws { _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() h.complete(try XCTUnwrap(partTask(0))) @@ -238,261 +299,3 @@ final class CoordinatorChunkedTests: XCTestCase { XCTAssertEqual(error["errorKind"] as? String, "file") } } - -/// Relaunch reconciliation and the v9 import. -final class CoordinatorRelaunchTests: XCTestCase { - private var h: Harness! - - override func setUp() { - h = Harness() - } - - override func tearDown() { - try? FileManager.default.removeItem(at: h.root) - } - - func testNothingIssuesBeforeReconcileThenReconcileIssues() throws { - _ = try h.enqueue(h.dataRaw(id: "a")).get() - XCTAssertTrue(h.transport.created.isEmpty, "not ready: nothing issues") - XCTAssertEqual(h.row("a")?["state"] as? String, "queued", "getRequests works before reconcile") - h.boot() - XCTAssertEqual(h.transport.live.count, 1) - XCTAssertEqual(h.entry("a")?.state, .running) - } - - func testRelaunchAdoptsTheLiveTaskAndItsCompletionSettles() throws { - h.boot() - _ = try h.enqueue(h.dataRaw(id: "a")).get() - let task = h.transport.live[0] - // New process: the daemon still holds the task. - let survivor = FakeTask(key: task.key, description: task.taskDescription, request: task.request, file: task.file) - let fresh = Harness(root: h.root) - fresh.relaunch(daemonTasks: [survivor]) - fresh.boot() - XCTAssertTrue(fresh.transport.created.isEmpty, "adopted, not re-issued") - XCTAssertEqual(fresh.row("a")?["state"] as? String, "running") - fresh.complete(survivor) - XCTAssertEqual(fresh.entry("a")?.state, .completed) - XCTAssertEqual(fresh.sink.settled.count, 1) - } - - func testRunningWithNoTaskAndNoTaskMapKeyReissuesNow() throws { - h.boot() - _ = try h.enqueue(h.dataRaw(id: "a")).get() - let task = h.transport.live[0] - h.map.removeKey(task.key) // as if the completion was handled, or the task never made it - let fresh = Harness(root: h.root) - fresh.boot() - XCTAssertEqual(fresh.transport.created.count, 1) - XCTAssertEqual(fresh.entry("a")?.attempts, 2) - } - - func testRunningWithAPendingCompletionWaitsForTheReplay() throws { - h.boot() - _ = try h.enqueue(h.dataRaw(id: "a")).get() - let task = h.transport.live[0] - let fresh = Harness(root: h.root) - fresh.boot() - XCTAssertTrue(fresh.transport.created.isEmpty, "the TaskMap key says a completion may be pending") - // The daemon replays the completion that happened while we were dead. - fresh.complete(FakeTask(key: task.key, description: task.taskDescription, request: task.request)) - XCTAssertEqual(fresh.entry("a")?.state, .completed) - fresh.advance(Double(QueueCoordinator.graceMs)) - XCTAssertTrue(fresh.transport.created.isEmpty, "no duplicate request") - } - - func testRunningWithALostTaskReissuesAfterTheGraceAndPrunesItsKey() throws { - h.boot() - _ = try h.enqueue(h.dataRaw(id: "a")).get() - let lost = h.transport.live[0] - let fresh = Harness(root: h.root) - fresh.boot() - XCTAssertTrue(fresh.transport.created.isEmpty) - fresh.advance(Double(QueueCoordinator.graceMs)) - XCTAssertEqual(fresh.transport.created.count, 1) - // Fake keys restart per process, so check by attempt, not by key. - XCTAssertTrue(fresh.map.keys(where: { $0.id == "a" && $0.attempt == 1 }).isEmpty, "\(lost.key) pruned") - // The next launch does not wait the grace again for the same lost task. - let third = Harness(root: h.root) - third.boot() - XCTAssertTrue(third.map.keys(where: { $0.attempt == 1 }).isEmpty) - } - - func testReconcileDropsKeysWhoseIdHasNoEntry() throws { - h.boot() - h.map.set(.init(id: "ghost", attempt: 1, generation: 1, purpose: .attempt), forKey: "any:77") - let live = FakeTask(key: "any:78", description: ChunkedEngine.taskDescription(id: "ghost2", attempt: 1, generation: 1)) - h.map.set(.init(id: "ghost2", attempt: 1, generation: 1, purpose: .attempt), forKey: live.key) - let fresh = Harness(root: h.root) - fresh.relaunch(daemonTasks: [live]) - fresh.boot() - XCTAssertNil(fresh.map.meta(forKey: "any:77")) - XCTAssertEqual(fresh.map.meta(forKey: live.key)?.purpose, .superseded, "a live task keeps its key for its cancel") - } - - // The delayed task never reached the daemon (a crash between the save and - // resume): no TaskMap key. It never ran, so it keeps its ordinal and id. - func testDelayedRetryThatNeverReachedTheDaemonKeepsItsWaitAndOrdinal() throws { - h.boot() - _ = try h.enqueue(h.dataRaw(id: "a")).get() - h.complete(h.transport.live[0], status: 503) - let waiting = h.transport.live[0] - h.map.removeKey(waiting.key) - let fresh = Harness(root: h.root) - fresh.clock = h.clock + 400 - fresh.boot() - let task = try XCTUnwrap(fresh.transport.live.first) - XCTAssertEqual(task.beginAt, Date(timeIntervalSince1970: (h.clock + 1_000) / 1000)) - XCTAssertEqual(fresh.entry("a")?.attempts, 2) - XCTAssertEqual(task.header("X-Request-Id"), waiting.header("X-Request-Id")) - } - - // The key says the delayed task may have run while the app was dead: wait - // for its replay, then mint a new attempt so a late replay is stale. - func testQueuedEntryWithAPendingKeyWaitsTheGraceThenMintsANewAttempt() throws { - h.boot() - _ = try h.enqueue(h.dataRaw(id: "a")).get() - h.complete(h.transport.live[0], status: 503) - let waiting = h.transport.live[0] - let fresh = Harness(root: h.root) - fresh.boot() - XCTAssertTrue(fresh.transport.created.isEmpty, "a replay may be pending") - fresh.advance(Double(QueueCoordinator.graceMs)) - let task = try XCTUnwrap(fresh.transport.live.first) - XCTAssertEqual(fresh.entry("a")?.attempts, 3) - XCTAssertNotEqual(task.header("X-Request-Id"), waiting.header("X-Request-Id")) - XCTAssertTrue(fresh.map.keys(where: { $0.id == "a" && $0.attempt == 2 }).isEmpty, "the lost task's key is pruned") - // A late replay of the lost attempt is dropped. - fresh.complete(FakeTask(key: waiting.key, description: waiting.taskDescription, request: waiting.request)) - XCTAssertEqual(fresh.entry("a")?.state, .running) - } - - func testUnownedAndStaleTasksAreCancelled() throws { - let v9 = FakeTask(key: "any:90", description: "bare-v9-id") - let orphan = FakeTask(key: "any:91", description: ChunkedEngine.taskDescription(id: "gone", attempt: 1, generation: 1)) - h.relaunch(daemonTasks: [v9, orphan]) - h.boot() - XCTAssertTrue(v9.cancelled) - XCTAssertTrue(orphan.cancelled) - XCTAssertEqual(h.map.meta(forKey: "any:90")?.purpose, .superseded) - h.complete(v9, error: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) - XCTAssertTrue(h.sink.attempts.isEmpty) - XCTAssertTrue(h.sink.settled.isEmpty) - } - - func testChunkedRelaunchAdoptsLivePartsAndCancelsDuplicates() throws { - h.boot() - _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() - let parts = h.transport.live - let fresh = Harness(root: h.root) - let survivors = parts.map { FakeTask(key: $0.key, description: $0.taskDescription, request: $0.request) } - let duplicate = FakeTask(key: "any:99", description: parts[0].taskDescription) - fresh.relaunch(daemonTasks: survivors + [duplicate]) - fresh.boot() - XCTAssertTrue(duplicate.cancelled, "never two tasks for one part") - XCTAssertTrue(fresh.transport.created.isEmpty, "the window is full with the adopted tasks") - fresh.complete(survivors[0]) - XCTAssertEqual(fresh.transport.created.count, 1, "refill after the adopted part completes") - } - - func testV9ImportMakesLegacyRowsAndKeepsDormantManifests() throws { - // v9 state on disk before the first v10 launch: a journal entry, a - // half-done chunked manifest with no journal entry, and v9 task metadata. - let root = makeTempDir() - let v9Store = QueueStore(root: root.appendingPathComponent("queue")) - let v9Journal = EventJournal(root: root.appendingPathComponent("events")) - let v9Event = JournaledEventV9(eventId: "ev1", id: "transfer-1", type: "completed", timestamp: 100) - try JSONEncoder().encode(v9Event).write(to: v9Journal.root.appendingPathComponent("ev1.json")) - let manifest = ChunkedManifestV9(id: "cap-1", parts: [ - .init(url: "https://s3.test/part1", headers: [:], start: 0, end: 10, accepted: true), - .init(url: "https://s3.test/part2", headers: [:], start: 10, end: 20, accepted: false), - .init(url: "https://s3.test/part3", headers: [:], start: 20, end: 30, accepted: false), - ], accept: [], expiresAt: h.expiresAt, wifiOnly: false, createdAt: 1, incarnation: "v9inc") - writeFile(v9Store.fileURL("cap-1", QueueStore.manifestName), - String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!) - writeFile(v9Store.fileURL("cap-1", "blob"), bytes: 30) - TaskMap(fileURL: root.appendingPathComponent("taskmap.json")).set(.init(id: "transfer-1", accept: []), forKey: "any:5") - - h = Harness(root: root) // first v10 launch: the import runs in init - XCTAssertNotNil(h.row("transfer-1"), "legacy rows are in the index before any reconcile") - h.boot() - let legacy = try XCTUnwrap(h.row("transfer-1")) - XCTAssertEqual(legacy["key"] as? String, "legacy") - XCTAssertEqual(legacy["state"] as? String, "completed") - XCTAssertTrue(legacy["vars"] is NSNull) - XCTAssertNil(h.row("cap-1"), "a dormant manifest is not a row") - XCTAssertTrue(h.sink.settled.isEmpty, "nothing is delivered") - XCTAssertTrue(h.journal.legacyEvents().isEmpty) - XCTAssertNil(h.map.meta(forKey: "any:5")) - XCTAssertTrue(h.store.isImported()) - - // Diana's re-send with the same parts resumes the v9 bytes. - let resend = h.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: h.root.appendingPathComponent("gone"), - urlPrefix: "https://s3.test/part") - _ = try h.enqueue(resend).get() - let e = try XCTUnwrap(h.entry("cap-1")) - XCTAssertEqual(e.parts.map(\.accepted), [true, false, false]) - XCTAssertEqual(e.incarnation, "v9inc") - XCTAssertEqual(e.bodyPath, "blob") - XCTAssertNil(h.store.loadV9Manifest("cap-1"), "adopted") - XCTAssertEqual(h.transport.live.count, 2, "only the unaccepted parts") - - // Diana cancels the legacy row: gone now. - h.cancel("transfer-1") - XCTAssertNil(h.row("transfer-1")) - } - - /// v9 state with a journaled outcome for a chunked id whose manifest and - /// blob are still on disk. - private func legacyChunked(type: String, accepted: [Bool]) throws -> Harness { - let root = makeTempDir() - let v9Store = QueueStore(root: root.appendingPathComponent("queue")) - let v9Journal = EventJournal(root: root.appendingPathComponent("events")) - let v9Event = JournaledEventV9(eventId: "ev1", id: "cap-1", type: type, timestamp: 100) - try JSONEncoder().encode(v9Event).write(to: v9Journal.root.appendingPathComponent("ev1.json")) - let manifest = ChunkedManifestV9(id: "cap-1", parts: (0..<3).map { - .init(url: "https://s3.test/part\($0 + 1)", headers: [:], start: Int64($0 * 10), - end: Int64($0 * 10 + 10), accepted: accepted[$0]) - }, accept: [], expiresAt: h.expiresAt, wifiOnly: false, createdAt: 1, incarnation: "v9inc") - writeFile(v9Store.fileURL("cap-1", QueueStore.manifestName), - String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!) - writeFile(v9Store.fileURL("cap-1", "blob"), bytes: 30) - let fresh = Harness(root: root) - fresh.boot() - return fresh - } - - func testLegacyCompletedRowAdoptsTheV9BytesOnASameIdEnqueue() throws { - let l = try legacyChunked(type: "completed", accepted: [true, true, false]) - defer { try? FileManager.default.removeItem(at: l.root) } - XCTAssertEqual(l.row("cap-1")?["state"] as? String, "completed") - let gone = l.root.appendingPathComponent("gone") - _ = try l.enqueue(l.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: gone)).get() - let e = try XCTUnwrap(l.entry("cap-1")) - XCTAssertFalse(e.legacy) - XCTAssertEqual(e.bodyPath, "blob") - XCTAssertEqual(e.parts.map(\.accepted), [true, true, false], "resumes, not E_FILE_MISSING") - XCTAssertEqual(l.transport.live.count, 1) - } - - func testLegacyErrorRowWithNewPartsKeepsTheV9Blob() throws { - let l = try legacyChunked(type: "error", accepted: [true, false, false]) - defer { try? FileManager.default.removeItem(at: l.root) } - let gone = l.root.appendingPathComponent("gone") - _ = try l.enqueue(l.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: gone, - urlPrefix: "https://s3.test/new")).get() - let e = try XCTUnwrap(l.entry("cap-1")) - XCTAssertEqual(e.bodyPath, "blob") - XCTAssertTrue(e.parts.allSatisfy { !$0.accepted }, "a new plan starts over on the kept bytes") - XCTAssertEqual(l.transport.live.count, 3) - } - - func testImportRunsOnce() throws { - h.boot() - let late = JournaledEventV9(eventId: "late", id: "t", type: "error", timestamp: 1) - try JSONEncoder().encode(late).write(to: h.journal.root.appendingPathComponent("late.json")) - let fresh = Harness(root: h.root) - fresh.boot() - XCTAssertNil(fresh.row("t"), "the marker stops a second import") - } -} diff --git a/ios/Tests/CoordinatorRelaunchTests.swift b/ios/Tests/CoordinatorRelaunchTests.swift new file mode 100644 index 00000000..2762e26e --- /dev/null +++ b/ios/Tests/CoordinatorRelaunchTests.swift @@ -0,0 +1,483 @@ +import XCTest +@testable import RNBGUCore + +/// Relaunch reconciliation and the v9 import. +final class CoordinatorRelaunchTests: XCTestCase { + private var h: Harness! + + override func setUp() { + h = Harness() + } + + override func tearDown() { + try? FileManager.default.removeItem(at: h.root) + } + + func testNothingIssuesBeforeReconcileThenReconcileIssues() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertTrue(h.transport.created.isEmpty, "not ready: nothing issues") + XCTAssertEqual(h.row("a")?["state"] as? String, "queued", "getRequests works before reconcile") + h.boot() + XCTAssertEqual(h.transport.live.count, 1) + XCTAssertEqual(h.entry("a")?.state, .running) + } + + func testRelaunchAdoptsTheLiveTaskAndItsCompletionSettles() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + // New process: the daemon still holds the task. + let survivor = FakeTask(key: task.key, description: task.taskDescription, request: task.request, file: task.file) + let fresh = Harness(root: h.root) + fresh.relaunch(daemonTasks: [survivor]) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty, "adopted, not re-issued") + XCTAssertEqual(fresh.row("a")?["state"] as? String, "running") + fresh.complete(survivor) + XCTAssertEqual(fresh.entry("a")?.state, .completed) + XCTAssertEqual(fresh.sink.settled.count, 1) + } + + func testRunningWithNoTaskAndNoTaskMapKeyReissuesNow() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + h.map.removeKey(task.key) // as if the completion was handled, or the task never made it + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertEqual(fresh.transport.created.count, 1) + XCTAssertEqual(fresh.entry("a")?.attempts, 2) + } + + func testRunningWithAPendingCompletionWaitsForTheReplay() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty, "the TaskMap key says a completion may be pending") + // The daemon replays the completion that happened while we were dead. + fresh.complete(FakeTask(key: task.key, description: task.taskDescription, request: task.request)) + XCTAssertEqual(fresh.entry("a")?.state, .completed) + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertTrue(fresh.transport.created.isEmpty, "no duplicate request") + } + + func testRunningWithALostTaskReissuesAfterTheGraceAndPrunesItsKey() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let lost = h.transport.live[0] + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty) + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertEqual(fresh.transport.created.count, 1) + XCTAssertNil(fresh.map.meta(forKey: lost.key)) + XCTAssertTrue(fresh.map.keys(where: { $0.id == "a" && $0.attempt == 1 }).isEmpty, "\(lost.key) pruned") + // The next launch does not wait the grace again for the same lost task. + let third = Harness(root: h.root) + third.boot() + XCTAssertTrue(third.map.keys(where: { $0.attempt == 1 }).isEmpty) + } + + func testReconcileDropsKeysWhoseIdHasNoEntry() throws { + h.boot() + h.map.set(.init(id: "ghost", attempt: 1, generation: 1, purpose: .attempt), forKey: "any:77") + let live = FakeTask(key: "any:78", description: ChunkedEngine.taskDescription(id: "ghost2", attempt: 1, generation: 1)) + h.map.set(.init(id: "ghost2", attempt: 1, generation: 1, purpose: .attempt), forKey: live.key) + let fresh = Harness(root: h.root) + fresh.relaunch(daemonTasks: [live]) + fresh.boot() + XCTAssertNil(fresh.map.meta(forKey: "any:77")) + XCTAssertEqual(fresh.map.meta(forKey: live.key)?.purpose, .superseded, "a live task keeps its key for its cancel") + } + + // The delayed task never reached the daemon (a crash between the save and + // resume): no TaskMap key. It never ran, so it keeps its ordinal and id. + func testDelayedRetryThatNeverReachedTheDaemonKeepsItsWaitAndOrdinal() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(h.transport.live[0], status: 503) + let waiting = h.transport.live[0] + h.map.removeKey(waiting.key) + let fresh = Harness(root: h.root) + fresh.clock = h.clock + 400 + fresh.boot() + let task = try XCTUnwrap(fresh.transport.live.first) + XCTAssertEqual(task.beginAt, Date(timeIntervalSince1970: (h.clock + 1_000) / 1000)) + XCTAssertEqual(fresh.entry("a")?.attempts, 2) + XCTAssertEqual(task.header("X-Request-Id"), waiting.header("X-Request-Id")) + } + + // The key says the delayed task may have run while the app was dead: wait + // for its replay, then mint a new attempt so a late replay is stale. + func testQueuedEntryWithAPendingKeyWaitsTheGraceThenMintsANewAttempt() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(h.transport.live[0], status: 503) + let waiting = h.transport.live[0] + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty, "a replay may be pending") + fresh.advance(Double(QueueCoordinator.graceMs)) + let task = try XCTUnwrap(fresh.transport.live.first) + XCTAssertEqual(fresh.entry("a")?.attempts, 3) + XCTAssertNotEqual(task.header("X-Request-Id"), waiting.header("X-Request-Id")) + XCTAssertTrue(fresh.map.keys(where: { $0.id == "a" && $0.attempt == 2 }).isEmpty, "the lost task's key is pruned") + // A late replay of the lost attempt is dropped. + fresh.complete(FakeTask(key: waiting.key, description: waiting.taskDescription, request: waiting.request)) + XCTAssertEqual(fresh.entry("a")?.state, .running) + } + + // MARK: - Review fixes: lost saves, early completions, pending replays + + /// The journal write landed, the entry.json save did not. The relaunch + /// must not send the request again. + private func settleWithFailedSave(_ finish: (Harness, FakeTask) -> Void) throws -> String { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + let dir = h.store.dir("a") + setReadOnly(dir, true) + finish(h, task) + setReadOnly(dir, false) + XCTAssertEqual(h.store.load("a")?.state, .running, "the disk still says running") + return try XCTUnwrap(h.journal.unacknowledged().first?.eventId) + } + + func testSettleThenFailedSaveIsRepairedAtRelaunchAndNotSentAgain() throws { + let eventId = try settleWithFailedSave { h, task in h.complete(task) } + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty, "no second POST") + XCTAssertEqual(fresh.entry("a")?.state, .completed) + XCTAssertEqual(fresh.entry("a")?.settledEventId, eventId) + XCTAssertEqual(fresh.store.load("a")?.state, .completed, "the repair is saved") + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertTrue(fresh.transport.created.isEmpty) + XCTAssertEqual(fresh.unacknowledged().first?["eventId"] as? String, eventId) + fresh.ack([eventId]) + XCTAssertNil(fresh.row("a")) + } + + func testCancelThenFailedSaveDoesNotRunAgainAtRelaunch() throws { + let eventId = try settleWithFailedSave { h, _ in h.cancel("a") } + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty) + XCTAssertEqual(fresh.entry("a")?.state, .cancelled) + XCTAssertEqual(fresh.entry("a")?.settledEventId, eventId) + } + + func testAckBeforeReconcileForgetsARowWhoseSettleSaveFailed() throws { + let eventId = try settleWithFailedSave { h, task in h.complete(task) } + let fresh = Harness(root: h.root) // no boot: the ack runs first + fresh.ack([eventId]) + XCTAssertNil(fresh.row("a")) + XCTAssertFalse(FileIO.exists(fresh.store.dir("a"))) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty) + } + + func testCompletionBeforeReconcileMintsTheNextAttempt() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let ran = h.transport.live[0] + let survivor = FakeTask(key: ran.key, description: ran.taskDescription, request: ran.request) + let fresh = Harness(root: h.root) + fresh.transport.deferAllTasks = true + fresh.relaunch(daemonTasks: [survivor]) + fresh.coordinator.reconcileAll {} + fresh.drain() + fresh.complete(survivor, status: 503) // lands before reconcile + fresh.transport.releaseAllTasks() + fresh.drain() + fresh.drain() + let retry = try XCTUnwrap(fresh.transport.live.first) + XCTAssertEqual(fresh.entry("a")?.attempts, 2) + XCTAssertNotEqual(retry.header("X-Request-Id"), ran.header("X-Request-Id"), "a new attempt, a new id") + XCTAssertEqual(ChunkedEngine.parseRequestDescription(retry.taskDescription)?.attempt, 2) + XCTAssertNotNil(retry.beginAt, "it keeps the backoff") + } + + /// Parts 1 and 2 still live in the daemon; part 0 finished while the app + /// was dead, so its TaskMap key is there and its completion may replay. + private func chunkedRelaunchWithPart0Pending() throws -> (Harness, FakeTask) { + h.boot() + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + let parts = h.transport.live + let part0 = try XCTUnwrap(parts.first { ChunkedEngine.parsePartDescription($0.taskDescription)?.part == 0 }) + let survivors = parts.filter { $0 !== part0 } + .map { FakeTask(key: $0.key, description: $0.taskDescription, request: $0.request) } + let fresh = Harness(root: h.root) + fresh.relaunch(daemonTasks: survivors) + return (fresh, part0) + } + + func testChunkedRelaunchHoldsAPendingPartUntilItsReplay() throws { + let (fresh, part0) = try chunkedRelaunchWithPart0Pending() + var released = false + fresh.coordinator.reconcileAll { released = true } + fresh.drain() + fresh.drain() + XCTAssertTrue(fresh.transport.created.isEmpty, "no second PUT of part 0, and no part 3 past the window") + XCTAssertFalse(released, "the background completion handler waits for the grace") + let file = fresh.store.partFileURL("cap", 0, incarnation: fresh.entry("cap")!.incarnation, start: 0, end: 10) + XCTAssertTrue(FileIO.exists(file), "the part file stays for the replay") + fresh.complete(FakeTask(key: part0.key, description: part0.taskDescription, request: part0.request)) + XCTAssertEqual(fresh.entry("cap")?.parts[0].accepted, true) + XCTAssertEqual(fresh.transport.created.count, 1) + XCTAssertEqual(ChunkedEngine.parsePartDescription(fresh.transport.created[0].taskDescription)?.part, 3) + XCTAssertTrue(released, "the replay came, so the handler releases at once") + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertEqual(fresh.transport.created.count, 1, "the grace timer releases nothing") + } + + func testChunkedPendingPartWithNoReplayIsSentAfterTheGrace() throws { + let (fresh, part0) = try chunkedRelaunchWithPart0Pending() + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty) + fresh.advance(Double(QueueCoordinator.graceMs)) + let resent = try XCTUnwrap(fresh.transport.live.first { + ChunkedEngine.parsePartDescription($0.taskDescription)?.part == 0 }) + XCTAssertNotEqual(resent.key, part0.key) + XCTAssertNil(fresh.map.meta(forKey: part0.key), "the lost task's key is pruned") + // A late replay of the lost task: the part is accepted, and the + // duplicate PUT stops before its part file goes. + fresh.complete(FakeTask(key: part0.key, description: part0.taskDescription, request: part0.request)) + XCTAssertEqual(fresh.entry("cap")?.parts[0].accepted, true) + XCTAssertTrue(resent.cancelled) + } + + func testBackgroundCompletionWaitsForTheSimpleGraceAndNotLonger() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let fresh = Harness(root: h.root) // the task's key is there, the task is not + var released = false + fresh.coordinator.reconcileAll { released = true } + fresh.drain() + fresh.drain() + XCTAssertFalse(released, "held while the replay may still come") + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertTrue(released) + XCTAssertEqual(fresh.transport.live.count, 1, "released after the re-issue") + + let idle = Harness(root: makeTempDir()) + defer { try? FileManager.default.removeItem(at: idle.root) } + var idleReleased = false + idle.coordinator.reconcileAll { idleReleased = true } + idle.drain() + idle.drain() + XCTAssertTrue(idleReleased, "no grace, no wait") + } + + func testBackgroundCompletionReleasesWhenTheSimpleReplayLands() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + let fresh = Harness(root: h.root) + var released = false + fresh.coordinator.reconcileAll { released = true } + fresh.drain() + fresh.drain() + XCTAssertFalse(released) + fresh.complete(FakeTask(key: task.key, description: task.taskDescription, request: task.request)) + XCTAssertEqual(fresh.entry("a")?.state, .completed) + XCTAssertTrue(released, "the replay came, so the handler does not wait out the grace") + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertTrue(fresh.transport.created.isEmpty, "the grace timer sends nothing") + } + + func testAReplayThatLeavesTheEntryAloneReissuesAtOnce() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = h.transport.live[0] + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertTrue(fresh.transport.created.isEmpty) + // The replay was superseded, so it does not move the entry. The attempt + // is lost, and the wait for it is over. + fresh.map.setPurpose(.superseded, forKey: task.key, id: "a") + fresh.complete(FakeTask(key: task.key, description: task.taskDescription, request: task.request), + status: 503) + XCTAssertEqual(fresh.transport.live.count, 1, "re-issued without waiting out the grace") + XCTAssertEqual(fresh.entry("a")?.attempts, 2, "a new ordinal: the lost attempt may have run") + fresh.advance(Double(QueueCoordinator.graceMs)) + XCTAssertEqual(fresh.transport.created.count, 1, "the timer does not send it again") + } + + func testAnEndedGraceTimerDoesNotCloseANewerWait() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let first = h.transport.live[0] + let fresh = Harness(root: h.root) + fresh.boot() // wait A opens, its timer is due at graceMs + fresh.complete(FakeTask(key: first.key, description: first.taskDescription, request: first.request), + status: 503) // the replay ends wait A; the retry is attempt 2 + let retry = try XCTUnwrap(fresh.transport.live.first) + fresh.advance(Double(QueueCoordinator.graceMs / 2)) + // The retry finished while the app was dead: its key is there, the task + // is not. A second reconcile opens wait B for it. + retry.isLive = false + var released = false + fresh.coordinator.reconcileAll { released = true } + fresh.drain() + fresh.drain() + XCTAssertFalse(released) + fresh.advance(Double(QueueCoordinator.graceMs / 2)) // wait A's timer fires + XCTAssertFalse(released, "wait A's timer does not close wait B") + fresh.advance(Double(QueueCoordinator.graceMs / 2)) + XCTAssertTrue(released) + } + + func testUnownedAndStaleTasksAreCancelled() throws { + let v9 = FakeTask(key: "any:90", description: "bare-v9-id") + let orphan = FakeTask(key: "any:91", description: ChunkedEngine.taskDescription(id: "gone", attempt: 1, generation: 1)) + h.relaunch(daemonTasks: [v9, orphan]) + h.boot() + XCTAssertTrue(v9.cancelled) + XCTAssertTrue(orphan.cancelled) + XCTAssertEqual(h.map.meta(forKey: "any:90")?.purpose, .superseded) + h.complete(v9, error: NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled)) + XCTAssertTrue(h.sink.attempts.isEmpty) + XCTAssertTrue(h.sink.settled.isEmpty) + } + + func testChunkedRelaunchAdoptsLivePartsAndCancelsDuplicates() throws { + h.boot() + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + let parts = h.transport.live + let fresh = Harness(root: h.root) + let survivors = parts.map { FakeTask(key: $0.key, description: $0.taskDescription, request: $0.request) } + let duplicate = FakeTask(key: "any:99", description: parts[0].taskDescription) + fresh.relaunch(daemonTasks: survivors + [duplicate]) + fresh.boot() + XCTAssertTrue(duplicate.cancelled, "never two tasks for one part") + XCTAssertTrue(fresh.transport.created.isEmpty, "the window is full with the adopted tasks") + fresh.complete(survivors[0]) + XCTAssertEqual(fresh.transport.created.count, 1, "refill after the adopted part completes") + } + + func testV9ImportMakesLegacyRowsAndKeepsDormantManifests() throws { + // v9 state on disk before the first v10 launch: a journal entry, a + // half-done chunked manifest with no journal entry, and v9 task metadata. + let root = makeTempDir() + let v9Store = QueueStore(root: root.appendingPathComponent("queue")) + let v9Journal = EventJournal(root: root.appendingPathComponent("events")) + V9Journal.write(V9Journal.completed(eventId: "ev1", id: "transfer-1", timestamp: 100), eventId: "ev1", + into: v9Journal.root) + let manifest = ChunkedManifestV9(id: "cap-1", parts: [ + .init(url: "https://s3.test/part1", start: 0, end: 10, accepted: true), + .init(url: "https://s3.test/part2", start: 10, end: 20, accepted: false), + .init(url: "https://s3.test/part3", start: 20, end: 30, accepted: false), + ], expiresAt: h.expiresAt, incarnation: "v9inc") + writeFile(v9Store.fileURL("cap-1", QueueStore.manifestName), + String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!) + writeFile(v9Store.fileURL("cap-1", "blob"), bytes: 30) + TaskMap(fileURL: root.appendingPathComponent("taskmap.json")).set(.init(id: "transfer-1"), forKey: "any:5") + + h = Harness(root: root) // first v10 launch: the import runs in init + XCTAssertNotNil(h.row("transfer-1"), "legacy rows are in the index before any reconcile") + h.boot() + let legacy = try XCTUnwrap(h.row("transfer-1")) + XCTAssertEqual(legacy["key"] as? String, "legacy") + XCTAssertEqual(legacy["state"] as? String, "completed") + XCTAssertTrue(legacy["vars"] is NSNull) + XCTAssertNil(h.row("cap-1"), "a dormant manifest is not a row") + XCTAssertTrue(h.sink.settled.isEmpty, "nothing is delivered") + XCTAssertTrue(h.journal.legacyEvents().isEmpty) + XCTAssertNil(h.map.meta(forKey: "any:5")) + XCTAssertTrue(h.store.isImported()) + + // Diana's re-send with the same parts resumes the v9 bytes. + let resend = h.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: h.root.appendingPathComponent("gone"), + urlPrefix: "https://s3.test/part") + _ = try h.enqueue(resend).get() + let e = try XCTUnwrap(h.entry("cap-1")) + XCTAssertEqual(e.parts.map(\.accepted), [true, false, false]) + XCTAssertEqual(e.incarnation, "v9inc") + XCTAssertEqual(e.bodyPath, "blob") + XCTAssertNil(h.store.loadV9Manifest("cap-1"), "adopted") + XCTAssertEqual(h.transport.live.count, 2, "only the unaccepted parts") + + // Diana cancels the legacy row: gone now. + h.cancel("transfer-1") + XCTAssertNil(h.row("transfer-1")) + } + + /// v9 state with a journaled outcome for a chunked id whose manifest and + /// blob are still on disk. + private func legacyChunked(type: String, accepted: [Bool]) throws -> Harness { + let root = makeTempDir() + let v9Store = QueueStore(root: root.appendingPathComponent("queue")) + let v9Journal = EventJournal(root: root.appendingPathComponent("events")) + let json = type == "error" ? V9Journal.error(eventId: "ev1", id: "cap-1", timestamp: 100) + : V9Journal.completed(eventId: "ev1", id: "cap-1", timestamp: 100) + V9Journal.write(json, eventId: "ev1", into: v9Journal.root) + let parts: [ChunkedManifestV9.Part] = (0..<3).map { i in + ChunkedManifestV9.Part(url: "https://s3.test/part\(i + 1)", start: Int64(i * 10), + end: Int64(i * 10 + 10), accepted: accepted[i]) + } + let manifest = ChunkedManifestV9(id: "cap-1", parts: parts, expiresAt: h.expiresAt, incarnation: "v9inc") + writeFile(v9Store.fileURL("cap-1", QueueStore.manifestName), + String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!) + writeFile(v9Store.fileURL("cap-1", "blob"), bytes: 30) + let fresh = Harness(root: root) + fresh.boot() + return fresh + } + + func testLegacyCompletedRowAdoptsTheV9BytesOnASameIdEnqueue() throws { + let l = try legacyChunked(type: "completed", accepted: [true, true, false]) + defer { try? FileManager.default.removeItem(at: l.root) } + XCTAssertEqual(l.row("cap-1")?["state"] as? String, "completed") + XCTAssertEqual(l.row("cap-1")?["bytesSent"] as? Int64, 0, "a legacy row reports 0/0, as on Android") + XCTAssertEqual(l.row("cap-1")?["totalBytes"] as? Int64, 0) + let gone = l.root.appendingPathComponent("gone") + _ = try l.enqueue(l.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: gone)).get() + let e = try XCTUnwrap(l.entry("cap-1")) + XCTAssertEqual(l.row("cap-1")?["bytesSent"] as? Int64, 20, "the adopted entry counts the v9 accepted parts") + XCTAssertEqual(l.row("cap-1")?["totalBytes"] as? Int64, 30) + XCTAssertFalse(e.legacy) + XCTAssertEqual(e.bodyPath, "blob") + XCTAssertEqual(e.parts.map(\.accepted), [true, true, false], "resumes, not E_FILE_MISSING") + XCTAssertEqual(l.transport.live.count, 1) + } + + func testLegacyErrorRowWithNewPartsKeepsTheV9Blob() throws { + let l = try legacyChunked(type: "error", accepted: [true, false, false]) + defer { try? FileManager.default.removeItem(at: l.root) } + let gone = l.root.appendingPathComponent("gone") + _ = try l.enqueue(l.chunkedRaw(id: "cap-1", size: 30, parts: 3, source: gone, + urlPrefix: "https://s3.test/new")).get() + let e = try XCTUnwrap(l.entry("cap-1")) + XCTAssertEqual(e.bodyPath, "blob") + XCTAssertTrue(e.parts.allSatisfy { !$0.accepted }, "a new plan starts over on the kept bytes") + XCTAssertEqual(l.transport.live.count, 3) + } + + func testV9JournalFilesOfEachKindImportAsLegacyRows() throws { + let root = makeTempDir() + let events = root.appendingPathComponent("events") + V9Journal.write(V9Journal.completed(eventId: "e1", id: "t-done", timestamp: 100), eventId: "e1", into: events) + V9Journal.write(V9Journal.error(eventId: "e2", id: "t-bad", timestamp: 200), eventId: "e2", into: events) + V9Journal.write(V9Journal.cancelled(eventId: "e3", id: "t-off", timestamp: 300), eventId: "e3", into: events) + h = Harness(root: root) + h.boot() + XCTAssertEqual(h.row("t-done")?["state"] as? String, "completed") + XCTAssertEqual(h.row("t-bad")?["state"] as? String, "error") + XCTAssertEqual(h.entry("t-bad")?.lastPartIndex, 2) + XCTAssertEqual(h.row("t-off")?["state"] as? String, "cancelled") + XCTAssertTrue(["t-done", "t-bad", "t-off"].allSatisfy { h.row($0)?["key"] as? String == "legacy" }) + XCTAssertTrue(h.journal.legacyEvents().isEmpty, "the v9 files are gone after the import") + XCTAssertTrue(h.unacknowledged().isEmpty, "nothing is delivered") + } + + func testImportRunsOnce() throws { + h.boot() + V9Journal.write(V9Journal.error(eventId: "late", id: "t", timestamp: 1), eventId: "late", into: h.journal.root) + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertNil(fresh.row("t"), "the marker stops a second import") + } +} diff --git a/ios/Tests/CoordinatorSimpleTests.swift b/ios/Tests/CoordinatorSimpleTests.swift index d8a31608..dd32e2ed 100644 --- a/ios/Tests/CoordinatorSimpleTests.swift +++ b/ios/Tests/CoordinatorSimpleTests.swift @@ -73,6 +73,36 @@ final class CoordinatorSimpleTests: XCTestCase { XCTAssertEqual(h.unacknowledged().first?["deliveries"] as? Int, 3) } + func testSettleWithNoListenerIsJournaledAtZeroAndTheFirstDrainReturnsOne() throws { + h.sink.listening = false // headless: no JS listener yet + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask()) + XCTAssertTrue(h.sink.settled.isEmpty, "not emitted live") + let eventId = try XCTUnwrap(h.journal.unacknowledged().first?.eventId) + XCTAssertEqual(h.journal.load(eventId)?.deliveries, 0) + // Rule 7 with no listener: nothing emitted, nothing counted. + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertTrue(h.sink.settled.isEmpty) + XCTAssertEqual(h.journal.load(eventId)?.deliveries, 0) + XCTAssertEqual(h.unacknowledged().first?["deliveries"] as? Int, 1, "the first delivery") + // The drain marks the listener: the next settle goes out live at 1. + _ = try h.enqueue(h.dataRaw(id: "b")).get() + h.complete(onlyTask()) + XCTAssertEqual(h.sink.settled.last?["id"] as? String, "b") + XCTAssertEqual(h.sink.settled.last?["deliveries"] as? Int, 1) + } + + func testFailedJournalWriteWithNoListenerKeepsZeroForTheDrain() throws { + h.sink.listening = false + _ = try h.enqueue(h.dataRaw(id: "a")).get() + setReadOnly(h.journal.root, true) + defer { setReadOnly(h.journal.root, false) } + h.complete(onlyTask()) + XCTAssertTrue(h.sink.settled.isEmpty) + XCTAssertEqual(h.coordinator.pendingJournal.values.first?.deliveries, 0) + XCTAssertEqual(h.unacknowledged().first?["deliveries"] as? Int, 1) + } + // MARK: - Same-id rules func testRule7CompletedUnackedReemitsWithoutRunning() throws { @@ -89,7 +119,7 @@ final class CoordinatorSimpleTests: XCTestCase { func testRule3SameBodyWhileRunningReplacesHeadersAndVars() throws { _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "old"])).get() var raw = h.dataRaw(id: "a", headers: ["Authorization": "new"]) - raw["vars"] = ["n": 2] + raw["varsJson"] = #"{"n":2}"# _ = try h.enqueue(raw).get() XCTAssertEqual(h.transport.created.count, 1, "the in-flight task keeps its request") XCTAssertEqual(h.entry("a")?.headers["Authorization"], "new") @@ -97,6 +127,38 @@ final class CoordinatorSimpleTests: XCTestCase { XCTAssertEqual(h.entry("a")?.attempts, 1, "a live resume keeps the attempt ordinal") } + func testADifferentUrlOrMethodIsADifferentBody() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + guard case .failure(let e) = h.enqueue(h.dataRaw(id: "a", url: "https://api.test/other")) else { + return XCTFail("a new url on a running entry") + } + XCTAssertEqual(e.code, "E_RUNNING") + h.complete(onlyTask(), status: 503) // now waiting, not running + _ = try h.enqueue(h.dataRaw(id: "a", extra: ["method": "PUT"])).get() + let entry = try XCTUnwrap(h.entry("a")) + XCTAssertEqual(entry.generation, 2, "replaced, not resumed") + XCTAssertEqual(entry.method, "PUT") + XCTAssertEqual(onlyTask().request.httpMethod, "PUT") + } + + func testSameBodyOnAWaitingRetryRetriesNowWithTheSameAttempt() throws { + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "old"])).get() + h.complete(onlyTask(), status: 503) + let waiting = onlyTask() + XCTAssertNotNil(waiting.beginAt) + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "new"])).get() + XCTAssertTrue(waiting.cancelled) + let now = onlyTask() + XCTAssertNil(now.beginAt, "no wait") + XCTAssertEqual(now.header("X-Request-Id"), waiting.header("X-Request-Id"), "the waiting attempt never ran") + XCTAssertEqual(now.header("Authorization"), "new") + XCTAssertEqual(h.entry("a")?.attempts, 2) + XCTAssertEqual(h.entry("a")?.state, .running) + XCTAssertNil(h.entry("a")?.nextAttemptAt) + h.deliverCancel(waiting) + XCTAssertEqual(h.entry("a")?.state, .running, "the replaced task's cancel does nothing") + } + func testRule5DifferentBodyWhileRunningRejects() throws { _ = try h.enqueue(h.dataRaw(id: "a")).get() guard case .failure(let e) = h.enqueue(h.dataRaw(id: "a", data: ["x": 2])) else { return XCTFail() } @@ -152,14 +214,14 @@ final class CoordinatorSimpleTests: XCTestCase { XCTAssertEqual(h.entry("a")?.generation, 2) } - func testMissingFileRejectsFileMissingAndParseErrorRejectsStorage() { + func testMissingFileRejectsFileMissingAndParseErrorRejectsInvalid() { guard case .failure(let missing) = h.enqueue(h.raw(id: "f", descriptor: [ "url": "https://a.test", "file": "/does/not/exist"])) else { return XCTFail() } XCTAssertEqual(missing.code, "E_FILE_MISSING") XCTAssertNil(h.row("f")) guard case .failure(let bad) = h.enqueue(["id": "b", "key": "k", "descriptor": ["url": "https://a.test"]]) else { return XCTFail() } - XCTAssertEqual(bad.code, "E_STORAGE", "no expiresAt") + XCTAssertEqual(bad.code, "E_INVALID", "no expiresAt") } // MARK: - Cancel @@ -241,11 +303,10 @@ final class CoordinatorSimpleTests: XCTestCase { XCTAssertEqual(h.sink.progress.last?["totalBytes"] as? Int64, 7) } - func testSystemCancelIsAnAttemptAndARetry() throws { + func testSystemCancelIsNoAttemptAndARetry() throws { _ = try h.enqueue(h.dataRaw(id: "a")).get() h.deliverCancel(onlyTask()) - XCTAssertEqual(h.sink.attempts.last?["outcome"] as? String, "cancelled") - XCTAssertEqual(h.sink.attempts.last?["cancelReason"] as? String, "system") + XCTAssertTrue(h.sink.attempts.isEmpty, "a cancel is not an attempt") XCTAssertTrue(h.sink.settled.isEmpty, "never a cancelled outcome") XCTAssertEqual(h.entry("a")?.state, .queued) XCTAssertNotNil(onlyTask().beginAt) @@ -260,14 +321,61 @@ final class CoordinatorSimpleTests: XCTestCase { } func testExpiryTimerSettlesAWaitingEntry() throws { + _ = try h.enqueue(h.dataRaw(id: "a", extra: ["expiresAt": h.clock + 60_000])).get() + h.complete(onlyTask(), status: 503) + let waiting = onlyTask() + h.advance(60_200) + XCTAssertEqual(h.entry("a")?.state, .error) + XCTAssertTrue(waiting.cancelled) + XCTAssertEqual((h.sink.settled.last?["error"] as? [String: Any])?["errorKind"] as? String, "expired") + } + + func testExpiryLeavesARunningAttemptToItsOwnResult() throws { _ = try h.enqueue(h.dataRaw(id: "a", extra: ["expiresAt": h.clock + 60_000])).get() let task = onlyTask() h.advance(60_200) + XCTAssertEqual(h.entry("a")?.state, .running, "not settled mid-flight") + XCTAssertFalse(task.cancelled) + h.complete(task, status: 400) + let error = try XCTUnwrap(h.sink.settled.last?["error"] as? [String: Any]) + XCTAssertEqual(error["errorKind"] as? String, "http", "a real response keeps its kind") + + _ = try h.enqueue(h.dataRaw(id: "b", extra: ["expiresAt": h.clock + 60_000])).get() + let second = onlyTask() + h.advance(60_200) + h.complete(second, status: 503) + XCTAssertEqual((h.sink.settled.last?["error"] as? [String: Any])?["errorKind"] as? String, "expired", + "a transient result past expiresAt is expired") + } + + func testAnExpiredEntryThatParksStillExpires() throws { + _ = try h.enqueue(h.dataRaw(id: "a", extra: ["expiresAt": h.clock + 60_000])).get() + let task = onlyTask() + h.advance(60_200) // passes while running + h.complete(task, status: 401) + XCTAssertEqual(h.entry("a")?.state, .awaitingAuth) + h.advance(100) XCTAssertEqual(h.entry("a")?.state, .error) - XCTAssertTrue(task.cancelled) XCTAssertEqual((h.sink.settled.last?["error"] as? [String: Any])?["errorKind"] as? String, "expired") } + func testPausedEntryCrossingExpiresAtSettlesAtResume() throws { + _ = try h.enqueue(h.dataRaw(id: "a", extra: ["expiresAt": h.clock + 60_000])).get() + _ = try h.enqueue(h.dataRaw(id: "b", extra: ["expiresAt": h.clock + 60_000])).get() + h.complete(h.transport.live.first { $0.taskDescription?.contains("\"b\"") == true }!, status: 401) + h.pause() + h.advance(60_200) + XCTAssertEqual(h.entry("a")?.state, .paused, "a pause does not expire") + XCTAssertEqual(h.entry("b")?.state, .paused) + XCTAssertTrue(h.sink.settled.isEmpty) + h.resume() + XCTAssertEqual(h.entry("a")?.state, .error) + XCTAssertEqual(h.entry("b")?.state, .error, "a parked entry too") + XCTAssertEqual(h.sink.settled.compactMap { ($0["error"] as? [String: Any])?["errorKind"] as? String }, + ["expired", "expired"]) + XCTAssertTrue(h.transport.live.isEmpty, "nothing issues") + } + func testExpiredAtIssue() throws { h.pause() _ = try h.enqueue(h.dataRaw(id: "a", extra: ["expiresAt": h.clock + 10])).get() @@ -326,6 +434,15 @@ final class CoordinatorSimpleTests: XCTestCase { XCTAssertEqual(h.entry("a")?.state, .running) } + func testSameBodyOnAParkedEntryKeepsCountingAttempts() throws { + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "expired"])).get() + h.complete(onlyTask(), status: 401) + XCTAssertEqual(h.entry("a")?.attempts, 1) + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "fresh"])).get() + XCTAssertEqual(h.entry("a")?.attempts, 2, "the same generation: no reset") + XCTAssertEqual(ChunkedEngine.parseRequestDescription(onlyTask().taskDescription)?.attempt, 2) + } + func testAuthUnderAnOlderGenerationReissuesAtOnce() throws { _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "old"])).get() let first = onlyTask() @@ -497,6 +614,42 @@ final class CoordinatorSimpleTests: XCTestCase { XCTAssertNil(h.row("a")) } + func testInvalidInputRejectsInvalidAndStoresNothing() { + for d: [String: Any] in [ + ["url": "ftp://a.test/x"], + ["url": "https://a.test/x", "headers": ["Authorization": "t\r\nX: 1"]], + ["url": "https://a.test/x", "method": "GET", "dataJson": "{}"], + ] { + guard case .failure(let e) = h.enqueue(h.raw(id: "bad", descriptor: d)) else { return XCTFail("\(d)") } + XCTAssertEqual(e.code, "E_INVALID") + } + XCTAssertNil(h.row("bad")) + XCTAssertFalse(FileIO.exists(h.store.dir("bad"))) + } + + func testUpdateHeadersRejectsALineBreakAndChangesNothing() throws { + _ = try h.enqueue(h.dataRaw(id: "a", headers: ["Authorization": "old"])).get() + var code: String? + h.coordinator.updateHeaders(["Authorization": "new\r\n"], resolve: {}, reject: { c, _ in code = c }) + h.drain() + XCTAssertEqual(code, "E_INVALID") + XCTAssertEqual(h.entry("a")?.headers["Authorization"], "old") + XCTAssertEqual(h.coordinator.settings.headerGeneration, 0) + } + + func testNullValuedKeysSurviveOnTheBodyTheRowAndTheOutcome() throws { + _ = try h.enqueue(h.raw(id: "a", vars: ["status": NSNull(), "n": 1], descriptor: [ + "url": "https://api.test/x", "dataJson": #"{"status":null}"#])).get() + let task = onlyTask() + XCTAssertEqual(try String(contentsOf: task.file!), #"{"status":null}"#) + let vars = try XCTUnwrap(h.row("a")?["vars"] as? [String: Any]) + XCTAssertTrue(vars["status"] is NSNull, "the key is there, as JS null") + h.complete(task) + let settledVars = try XCTUnwrap(h.sink.settled.last?["vars"] as? [String: Any]) + XCTAssertTrue(settledVars["status"] is NSNull) + XCTAssertEqual(settledVars["n"] as? Int, 1) + } + func testDataNullSendsTheJSONNullBody() throws { _ = try h.enqueue(h.dataRaw(id: "a", data: NSNull())).get() let task = onlyTask() @@ -504,7 +657,23 @@ final class CoordinatorSimpleTests: XCTestCase { XCTAssertEqual(task.header("Content-Type"), "application/json") } - // MARK: - Rows + func testRowsCarryLiveBytesAndAFailureKeepsThem() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = onlyTask() + h.coordinator.taskProgress(key: task.key, description: task.taskDescription, sent: 3, expected: 7) + h.drain() + XCTAssertEqual(h.row("a")?["bytesSent"] as? Int64, 3) + XCTAssertEqual(h.store.load("a")?.bytesSent, 0, "progress is memory only") + h.complete(task, status: 503) + XCTAssertEqual(h.row("a")?["bytesSent"] as? Int64, 0, "a new attempt starts from 0") + let retry = onlyTask() + h.coordinator.taskProgress(key: retry.key, description: retry.taskDescription, sent: 5, expected: 7) + h.drain() + h.complete(retry, status: 400) + XCTAssertEqual(h.sink.settled.last?["bytesSent"] as? Int64, 5, "the failed attempt's live bytes") + } + + // MARK: - Rows // MARK: - Rows func testGetRequestsRows() throws { _ = try h.enqueue(h.dataRaw(id: "a")).get() diff --git a/ios/Tests/QueueEntryTests.swift b/ios/Tests/QueueEntryTests.swift index ac583515..91520df4 100644 --- a/ios/Tests/QueueEntryTests.swift +++ b/ios/Tests/QueueEntryTests.swift @@ -2,17 +2,30 @@ import XCTest @testable import RNBGUCore final class EnqueueParserTests: XCTestCase { + /// The bridge form: vars and data as JSON text. A `data` key in + /// `descriptor` becomes `dataJson`. private func raw(_ descriptor: [String: Any], vars: Any = ["a": 1]) -> [String: Any] { var d = descriptor if d["expiresAt"] == nil { d["expiresAt"] = 2_000_000_000_000.0 } - return ["id": "id-1", "key": "k", "vars": vars, "descriptor": d] + if let data = d.removeValue(forKey: "data") { d["dataJson"] = jsonText(data) } + return ["id": "id-1", "key": "k", "varsJson": jsonText(vars), "descriptor": d] + } + + private func invalid(_ raw: [String: Any], file: StaticString = #filePath, line: UInt = #line) { + XCTAssertThrowsError(try EnqueueParser.parse(raw), file: file, line: line) { + XCTAssertTrue($0 is ParseError, file: file, line: line) + } } func testDataBodyDefaultsToPost() throws { - let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "data": ["b": 2, "a": 1]])) + var r = raw(["url": "https://a.test/x"]) + var d = r["descriptor"] as! [String: Any] + d["dataJson"] = #"{"b":2,"a":1}"# + r["descriptor"] = d + let p = try EnqueueParser.parse(r) XCTAssertEqual(p.method, "POST") guard case .data(let json) = p.body else { return XCTFail("expected data") } - XCTAssertEqual(json, #"{"a":1,"b":2}"#) + XCTAssertEqual(json, #"{"b":2,"a":1}"#, "the text JS built is the body") XCTAssertEqual(p.varsJSON, #"{"a":1}"#) } @@ -20,23 +33,73 @@ final class EnqueueParserTests: XCTestCase { let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "method": "DELETE"])) guard case .none = p.body else { return XCTFail("expected none") } XCTAssertEqual(p.method, "DELETE") - XCTAssertEqual(p.fingerprint, "none") + XCTAssertTrue(p.fingerprint.hasPrefix("none")) } func testNullVarsAndNSNullFieldsAreAbsent() throws { - let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "file": NSNull(), "form": NSNull()], - vars: NSNull())) + let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "file": NSNull(), "form": NSNull(), + "dataJson": NSNull()], vars: NSNull())) XCTAssertEqual(p.varsJSON, "null") guard case .none = p.body else { return XCTFail("NSNull must read as absent") } } - func testDataNullIsAJSONNullBody() throws { + func testNullValuedKeysSurviveAsText() throws { + let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "data": ["status": NSNull()]], + vars: ["a": NSNull()])) + XCTAssertEqual(p.varsJSON, #"{"a":null}"#) + guard case .data(let json) = p.body else { return XCTFail("expected data") } + XCTAssertEqual(json, #"{"status":null}"#) + } + + func testDataJsonNullIsAJSONNullBody() throws { let p = try EnqueueParser.parse(raw(["url": "https://a.test/x", "data": NSNull(), "file": NSNull()])) - guard case .data(let json) = p.body else { return XCTFail("data: null is a body") } + guard case .data(let json) = p.body else { return XCTFail("dataJson \"null\" is a body") } XCTAssertEqual(json, "null") - XCTAssertEqual(p.fingerprint, "data:" + JSONText.sha256("null")) - XCTAssertThrowsError(try EnqueueParser.parse(raw(["url": "https://a.test/x", "data": NSNull(), - "file": "/tmp/a"])), "two body kinds") + invalid(raw(["url": "https://a.test/x", "data": NSNull(), "file": "/tmp/a"])) + } + + func testObjectVarsOrDataAndBadJSONTextAreInvalid() { + invalid(["id": "i", "key": "k", "vars": ["a": 1], "descriptor": ["url": "https://a.test", "expiresAt": 1]]) + var withData = raw(["url": "https://a.test"]) + var d = withData["descriptor"] as! [String: Any] + d["data"] = ["x": 1] + withData["descriptor"] = d + invalid(withData) + var badVars = raw(["url": "https://a.test"]) + badVars["varsJson"] = "{nope" + invalid(badVars) + d = raw(["url": "https://a.test"])["descriptor"] as! [String: Any] + d["dataJson"] = "{nope" + invalid(["id": "i", "key": "k", "varsJson": "null", "descriptor": d]) + } + + func testGetWithABodyIsInvalid() throws { + invalid(raw(["url": "https://a.test", "method": "GET", "data": ["a": 1]])) + invalid(raw(["url": "https://a.test", "method": "get", "file": "/tmp/a"])) + XCTAssertNoThrow(try EnqueueParser.parse(raw(["url": "https://a.test", "method": "GET"]))) + XCTAssertNoThrow(try EnqueueParser.parse(raw(["url": "https://a.test", "method": "DELETE", "data": ["a": 1]]))) + } + + func testSchemeMustBeHttpOrHttps() throws { + invalid(raw(["url": "ftp://a.test/x"])) + invalid(raw(["url": "file:///tmp/x"])) + invalid(raw(["url": "https:///nohost"])) + invalid(raw(["file": "/tmp/f", "parts": [["url": "javascript://a.test/1", "range": ["start": 0, "end": 1]]]])) + XCTAssertNoThrow(try EnqueueParser.parse(raw(["url": "HTTP://a.test/x"]))) + } + + func testHeaderNamesAndValuesAreValidatedWithoutEchoingTheValue() { + invalid(raw(["url": "https://a.test", "headers": ["Bad Name": "x"]])) + invalid(raw(["url": "https://a.test", "headers": ["": "x"]])) + invalid(raw(["url": "https://a.test", "headers": ["X-A": "secret\r\nX-Injected: 1"]])) + invalid(raw(["url": "https://a.test", "headers": ["X-A": "a\nb"]])) + invalid(raw(["file": "/tmp/f", "parts": [["url": "https://s3.test/1", "headers": ["X": "a\rb"], + "range": ["start": 0, "end": 1]]]])) + invalid(raw(["url": "https://a.test", "form": [["name": "a", "contentType": "t\r\nX: 1", "string": "s"]]])) + XCTAssertThrowsError(try EnqueueParser.headers(["Authorization": "Bearer tok\r\n"])) { + XCTAssertFalse($0.localizedDescription.contains("tok"), "the value never reaches the message") + XCTAssertTrue($0.localizedDescription.contains("Authorization")) + } } func testFormAndFile() throws { @@ -51,11 +114,11 @@ final class EnqueueParserTests: XCTestCase { let file = try EnqueueParser.parse(raw(["url": "https://a.test/x", "file": "file:///tmp/a.bin"])) guard case .file(let path) = file.body else { return XCTFail("expected file") } XCTAssertEqual(path, "file:///tmp/a.bin") - XCTAssertEqual(file.fingerprint, "file:file:///tmp/a.bin") + XCTAssertTrue(file.fingerprint.hasPrefix("file:file:///tmp/a.bin")) } func testMissingUrlWithoutPartsThrows() { - XCTAssertThrowsError(try EnqueueParser.parse(raw(["data": ["a": 1]]))) + invalid(raw(["data": ["a": 1]])) } func testPartsNeedNoUrlAndValidateRanges() throws { @@ -64,27 +127,34 @@ final class EnqueueParserTests: XCTestCase { ]])) XCTAssertNil(ok.url) XCTAssertEqual(ok.parts.count, 1) - XCTAssertThrowsError(try EnqueueParser.parse(raw(["file": "/tmp/f", "parts": [ + invalid(raw(["file": "/tmp/f", "parts": [ ["url": "https://s3.test/1", "range": ["start": 5, "end": 5]], - ]]))) - XCTAssertThrowsError(try EnqueueParser.parse(raw(["parts": [ + ]])) + invalid(raw(["parts": [ ["url": "https://s3.test/1", "range": ["start": 0, "end": 5]], - ]])), "parts requires file") + ]])) } func testTwoBodyKindsThrow() { - XCTAssertThrowsError(try EnqueueParser.parse(raw(["url": "https://a.test", "data": [:], "file": "/tmp/a"]))) + invalid(raw(["url": "https://a.test", "data": [:], "file": "/tmp/a"])) } - func testHeadersKeepStringsAndNumbersOnly() { - let h = EnqueueParser.headers(["A": "x", "B": 3, "C": NSNull(), "D": ["nested": 1]]) + func testHeadersKeepStringsAndNumbersOnly() throws { + let h = try EnqueueParser.headers(["A": "x", "B": 3, "C": NSNull(), "D": ["nested": 1]]) XCTAssertEqual(h, ["A": "x", "B": "3"]) } func testDataFingerprintIgnoresKeyOrder() throws { - let a = try EnqueueParser.parse(raw(["url": "https://a.test", "data": ["x": 1, "y": ["b": 1, "a": 2]]])) - let b = try EnqueueParser.parse(raw(["url": "https://a.test", "data": ["y": ["a": 2, "b": 1], "x": 1]])) - let c = try EnqueueParser.parse(raw(["url": "https://a.test", "data": ["x": 2]])) + func withText(_ text: String) -> [String: Any] { + var r = raw(["url": "https://a.test"]) + var d = r["descriptor"] as! [String: Any] + d["dataJson"] = text + r["descriptor"] = d + return r + } + let a = try EnqueueParser.parse(withText(#"{"x":1,"y":{"b":1,"a":2}}"#)) + let b = try EnqueueParser.parse(withText(#"{"y":{"a":2,"b":1},"x":1}"#)) + let c = try EnqueueParser.parse(withText(#"{"x":2}"#)) XCTAssertEqual(a.fingerprint, b.fingerprint) XCTAssertNotEqual(a.fingerprint, c.fingerprint) } @@ -121,7 +191,8 @@ final class QueueEntryTests: XCTestCase { private func parsed(_ descriptor: [String: Any], id: String = "e1", vars: Any = ["v": 1]) -> ParsedEnqueue { var d = descriptor if d["expiresAt"] == nil { d["expiresAt"] = 5_000.0 } - return try! EnqueueParser.parse(["id": id, "key": "k", "vars": vars, "descriptor": d]) + if let data = d.removeValue(forKey: "data") { d["dataJson"] = jsonText(data) } + return try! EnqueueParser.parse(["id": id, "key": "k", "varsJson": jsonText(vars), "descriptor": d]) } private let staged = StagedBody(kind: .parts, relativePath: "blob-1", contentType: nil, diff --git a/ios/Tests/QueueStoreTests.swift b/ios/Tests/QueueStoreTests.swift index 7dbf8eca..aa0dd579 100644 --- a/ios/Tests/QueueStoreTests.swift +++ b/ios/Tests/QueueStoreTests.swift @@ -16,7 +16,7 @@ final class QueueStoreTests: XCTestCase { private func entry(_ id: String, state: QueueEntry.State = .queued, body: String? = "body-a") -> QueueEntry { QueueEntry( - id: id, key: "k", varsJSON: "null", descriptorJSON: "{}", url: "https://a.test", method: "POST", + id: id, key: "k", varsJSON: "null", url: "https://a.test", method: "POST", accept: [], retry: nil, bodyKind: .data, bodyPath: body, bodyContentType: "application/json", forceContentType: false, bodyFingerprint: "f", parts: [], incarnation: "inc-1", headers: [:], headerGeneration: 0, state: state, authParked: false, generation: 1, attempts: 0, bytesSent: 0, @@ -123,15 +123,14 @@ final class QueueStoreTests: XCTestCase { func testDormantManifestReadAndRemove() throws { let manifest = ChunkedManifestV9( - id: "v9", parts: [.init(url: "https://s3.test/1", headers: [:], start: 0, end: 5, accepted: true)], - accept: [], expiresAt: 10, wifiOnly: false, createdAt: 1, incarnation: "old") + id: "v9", parts: [.init(url: "https://s3.test/1", start: 0, end: 5, accepted: true)], + expiresAt: 10, incarnation: "old") writeFile(store.fileURL("v9", QueueStore.manifestName), String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!) XCTAssertEqual(store.loadV9Manifest("v9"), manifest) - XCTAssertEqual(store.allDormantManifests(), [manifest]) XCTAssertEqual(store.allV9Manifests()["v9"], manifest) try store.save(entry("v9")) - XCTAssertTrue(store.allDormantManifests().isEmpty, "an entry.json makes it not dormant") + XCTAssertEqual(store.loadV9Manifest("v9"), manifest, "read with or without an entry.json") store.removeV9Manifest("v9") XCTAssertNil(store.loadV9Manifest("v9")) } diff --git a/ios/Tests/RetryClassifierTests.swift b/ios/Tests/RetryClassifierTests.swift index 00bda393..b717cc60 100644 --- a/ios/Tests/RetryClassifierTests.swift +++ b/ios/Tests/RetryClassifierTests.swift @@ -4,13 +4,13 @@ import XCTest final class RetryClassifierTests: XCTestCase { private func classify(_ status: Int? = nil, body: String? = nil, error: NSError? = nil, accept: [UploadOutcome.AcceptRule] = [], exempt: [Int] = [404], - part: Bool = false, fileExists: Bool = true, now: Double = 0, + fileExists: Bool = true, now: Double = 0, expiresAt: Double = 100) -> RetryClassifier.Class { var policy = RetryPolicy.defaults policy.exempt = exempt return RetryClassifier.classify(.init( statusCode: status, body: body, error: error, accept: accept, policy: policy, - isChunkedPart: part, fileExists: fileExists, now: now, expiresAt: expiresAt)) + fileExists: fileExists, now: now, expiresAt: expiresAt)) } func testTable() { @@ -27,7 +27,7 @@ final class RetryClassifierTests: XCTestCase { XCTAssertEqual(classify(500), .transient) XCTAssertEqual(classify(503), .transient) XCTAssertEqual(classify(404), .transient, "404 is exempt by default") - XCTAssertEqual(classify(404, exempt: [], part: true), .terminalHttp, "the chunked part-404 case") + XCTAssertEqual(classify(404, exempt: []), .terminalHttp, "the chunked part-404 case") XCTAssertEqual(classify(400), .terminalHttp) XCTAssertEqual(classify(422), .terminalHttp) XCTAssertEqual(classify(304), .terminalHttp) @@ -38,18 +38,22 @@ final class RetryClassifierTests: XCTestCase { .transient) XCTAssertEqual(classify(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)), .transient) let fileError = NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist) - XCTAssertEqual(classify(error: fileError, fileExists: true), .fileUnreadable) + XCTAssertEqual(classify(error: fileError, fileExists: true), .transient, "unreadable now, maybe not later") XCTAssertEqual(classify(error: fileError, fileExists: false), .fileMissing) XCTAssertEqual(classify(error: NSError(domain: "Other", code: 1)), .transient) } - func testExpiredWinsOverEverythingButAccepted() { + func testPastExpiresAtOnlyATransientResultIsExpired() { XCTAssertEqual(classify(200, now: 100, expiresAt: 100), .accepted) XCTAssertEqual(classify(503, now: 100, expiresAt: 100), .expired) - XCTAssertEqual(classify(401, now: 101, expiresAt: 100), .expired) - XCTAssertEqual(classify(400, now: 101, expiresAt: 100), .expired) XCTAssertEqual(classify(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut), now: 200, expiresAt: 100), .expired) + XCTAssertEqual(classify(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist), + fileExists: true, now: 200, expiresAt: 100), .expired) + XCTAssertEqual(classify(401, now: 101, expiresAt: 100), .auth, "a real response keeps its class") + XCTAssertEqual(classify(400, now: 101, expiresAt: 100), .terminalHttp) + XCTAssertEqual(classify(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist), + fileExists: false, now: 200, expiresAt: 100), .fileMissing) } func testBackoff() { diff --git a/ios/Tests/SupportComponentTests.swift b/ios/Tests/SupportComponentTests.swift index 00472877..8856692e 100644 --- a/ios/Tests/SupportComponentTests.swift +++ b/ios/Tests/SupportComponentTests.swift @@ -3,7 +3,7 @@ import XCTest private func sampleEntry(_ id: String, createdAt: Double, vars: String = #"{"n":1}"#) -> QueueEntry { QueueEntry( - id: id, key: "k", varsJSON: vars, descriptorJSON: "{}", url: "https://a.test", method: "POST", + id: id, key: "k", varsJSON: vars, url: "https://a.test", method: "POST", accept: [], retry: nil, bodyKind: .none, bodyPath: "body-1", bodyContentType: nil, forceContentType: false, bodyFingerprint: "none", parts: [], incarnation: "i", headers: [:], headerGeneration: 0, state: .queued, authParked: false, generation: 1, attempts: 0, bytesSent: 0, @@ -70,10 +70,10 @@ final class EventJournalTests: XCTestCase { func testUnacknowledgedSortedAndSkipsV9() throws { journal.append(event("late", at: 5)) journal.append(event("early", at: 1)) - let v9 = JournaledEventV9(eventId: "old", id: "u", type: "completed", timestamp: 0) - try JSONEncoder().encode(v9).write(to: root.appendingPathComponent("old.json")) + V9Journal.write(V9Journal.cancelled(eventId: "old", id: "u", timestamp: 0), eventId: "old", into: root) XCTAssertEqual(journal.unacknowledged().map(\.eventId), ["early", "late"]) - XCTAssertEqual(journal.legacyEvents(), [v9]) + XCTAssertEqual(journal.legacyEvents(), + [JournaledEventV9(eventId: "old", id: "u", type: "cancelled", timestamp: 0, cancelReason: "user")]) } func testPruneKeepsEventsThatRowsName() { @@ -184,7 +184,7 @@ final class TaskMapTests: XCTestCase { let url = makeTempDir().appendingPathComponent("map.json") try Data(#"{"s:9":{"id":"old","acceptStatus":[409]},"s:10":{"id":"new","purpose":"future"}}"#.utf8).write(to: url) let map = TaskMap(fileURL: url) - XCTAssertEqual(map.meta(forKey: "s:9")?.accept, [.init(status: 409, bodyIncludes: nil)]) + XCTAssertEqual(map.meta(forKey: "s:9")?.id, "old", "an old key is ignored, not fatal") XCTAssertNil(map.meta(forKey: "s:9")?.generation) XCTAssertNil(map.meta(forKey: "s:10")?.purpose, "an unknown purpose reads as nil") map.removeAll { _, meta in meta.generation == nil } @@ -229,9 +229,9 @@ final class ChunkedEngineTests: XCTestCase { final class LegacyImportTests: XCTestCase { private func manifest(_ id: String) -> ChunkedManifestV9 { ChunkedManifestV9(id: id, parts: [ - .init(url: "https://s3.test/1", headers: [:], start: 0, end: 5, accepted: true), - .init(url: "https://s3.test/2", headers: [:], start: 5, end: 12, accepted: false), - ], accept: [], expiresAt: 99, wifiOnly: false, createdAt: 1, incarnation: "inc") + .init(url: "https://s3.test/1", start: 0, end: 5, accepted: true), + .init(url: "https://s3.test/2", start: 5, end: 12, accepted: false), + ], expiresAt: 99, incarnation: "inc") } func testJournalOnly() { @@ -250,8 +250,8 @@ final class LegacyImportTests: XCTestCase { let rows = LegacyImport.plan(events: [JournaledEventV9(eventId: "1", id: "cap", type: "error", timestamp: 3)], manifests: ["cap": manifest("cap")]) XCTAssertEqual(rows.count, 1) - XCTAssertEqual(rows[0].bytesSent, 5) - XCTAssertEqual(rows[0].totalBytes, 12) + XCTAssertEqual(rows[0].bytesSent, 0, "legacy rows report 0/0, as on Android") + XCTAssertEqual(rows[0].totalBytes, 0) XCTAssertEqual(rows[0].bodyPath, "blob") } @@ -278,10 +278,10 @@ final class ProgressThrottleTests: XCTestCase { final class AttemptEventTests: XCTestCase { private func input(status: Int? = 200, error: NSError? = nil, accepted: Bool = true, - systemCancel: Bool = false, body: String? = "ok") -> AttemptEvent.Input { + body: String? = "ok") -> AttemptEvent.Input { AttemptEvent.Input(id: "i", key: "k", requestId: "r", attempt: 1, url: "https://a.test", method: "PUT", partIndex: 2, statusCode: status, headers: ["h": "v"], body: body, error: error, - accepted: accepted, systemCancel: systemCancel, at: 5) + accepted: accepted, at: 5) } func testOutcomes() { @@ -296,9 +296,10 @@ final class AttemptEventTests: XCTestCase { accepted: false)) XCTAssertEqual(net["errorKind"] as? String, "network") XCTAssertNil(net["httpCode"]) - let cancel = AttemptEvent.build(input(status: nil, accepted: false, systemCancel: true)) - XCTAssertEqual(cancel["outcome"] as? String, "cancelled") - XCTAssertEqual(cancel["cancelReason"] as? String, "system") + for event in [ok, http, net] { + XCTAssertTrue(["completed", "error"].contains(event["outcome"] as? String), "only completed or error") + XCTAssertNil(event["cancelReason"]) + } } func testBodyCappedAtFourKilobytes() { diff --git a/ios/Tests/TestSupport.swift b/ios/Tests/TestSupport.swift index 2c28d34a..754a376f 100644 --- a/ios/Tests/TestSupport.swift +++ b/ios/Tests/TestSupport.swift @@ -17,6 +17,35 @@ func setReadOnly(_ dir: URL, _ readOnly: Bool) { try! FileManager.default.setAttributes([.posixPermissions: readOnly ? 0o555 : 0o755], ofItemAtPath: dir.path) } +/// JSON text as JS JSON.stringify sends it (keys sorted, for stable tests). +func jsonText(_ value: Any) -> String { + String(data: try! JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed, .sortedKeys]), + encoding: .utf8)! +} + +/// Journal files exactly as a v9 build wrote them (JSONEncoder of the v9 +/// JournaledEvent: nil fields omitted), one per kind. Literal, so a change to +/// JournaledEventV9 cannot change the fixture with it. +enum V9Journal { + static func completed(eventId: String, id: String, timestamp: Int) -> String { + #"{"eventId":"\#(eventId)","id":"\#(id)","type":"completed","timestamp":\#(timestamp),"# + + #""responseCode":200,"responseBody":"{\"ok\":true}","responseHeaders":{"Content-Type":"application\/json"}}"# + } + + static func error(eventId: String, id: String, timestamp: Int) -> String { + #"{"eventId":"\#(eventId)","id":"\#(id)","type":"error","timestamp":\#(timestamp),"responseCode":404,"# + + #""responseBody":"NoSuchUpload","error":"HTTP 404","errorKind":"http","partIndex":2}"# + } + + static func cancelled(eventId: String, id: String, timestamp: Int) -> String { + #"{"eventId":"\#(eventId)","id":"\#(id)","type":"cancelled","timestamp":\#(timestamp),"cancelReason":"user"}"# + } + + static func write(_ json: String, eventId: String, into journalRoot: URL) { + writeFile(journalRoot.appendingPathComponent(eventId + ".json"), json) + } +} + func writeFile(_ url: URL, _ text: String) { try! FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try! Data(text.utf8).write(to: url) @@ -59,20 +88,32 @@ final class FakeTransport: Transport { /// Tasks the daemon held before this process started. var daemonTasks: [FakeTask] = [] private(set) var created: [FakeTask] = [] - private var next = 1 + /// Static: task keys stay unique across a relaunch, as session task ids do. + private static var next = 1 + /// When set, allTasks holds its answer until `releaseAllTasks()`, so a + /// completion can land before reconcile. + var deferAllTasks = false + private var pendingAllTasks: (() -> Void)? func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String, beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask { - let task = FakeTask(key: "\(wifiOnly ? "wifi" : "any"):\(next)", description: description, + let task = FakeTask(key: "\(wifiOnly ? "wifi" : "any"):\(Self.next)", description: description, request: request, file: file, beginAt: beginAt, wifiOnly: wifiOnly) - next += 1 + Self.next += 1 beforeResume(task.key) created.append(task) return task } func allTasks(_ completion: @escaping ([UploadTask]) -> Void) { - completion(daemonTasks + created) + let answer = { completion(self.daemonTasks + self.created) } + if deferAllTasks { pendingAllTasks = answer } else { answer() } + } + + func releaseAllTasks() { + let answer = pendingAllTasks + pendingAllTasks = nil + answer?() } var live: [FakeTask] { created.filter(\.isLive) } @@ -88,6 +129,10 @@ final class FakeSink: EventSink { func emitProgress(_ body: [String: Any]) { progress.append(body) } func emitAttempt(_ body: [String: Any]) { attempts.append(body) } func emitSettled(_ body: [String: Any]) { settled.append(body) } + /// A JS listener is attached. A test sets it false for a headless run. + var listening = true + func canDeliver() -> Bool { listening } + func listenerReady() { listening = true } var stateNames: [String] { states.compactMap { $0["state"] as? String } } } @@ -215,12 +260,13 @@ final class Harness { func raw(id: String, key: String = "k", vars: Any = ["n": 1], descriptor: [String: Any]) -> [String: Any] { var d = descriptor if d["expiresAt"] == nil { d["expiresAt"] = expiresAt } - return ["id": id, "key": key, "vars": vars, "descriptor": d] + return ["id": id, "key": key, "varsJson": jsonText(vars), "descriptor": d] } + /// `data` crosses as its JSON text, as the JS layer sends it. func dataRaw(id: String, data: Any = ["x": 1], url: String = "https://api.test/x", headers: [String: Any] = [:], extra: [String: Any] = [:]) -> [String: Any] { - var d: [String: Any] = ["url": url, "data": data, "headers": headers] + var d: [String: Any] = ["url": url, "dataJson": jsonText(data), "headers": headers] for (k, v) in extra { d[k] = v } return raw(id: id, descriptor: d) } diff --git a/ios/Transport.swift b/ios/Transport.swift index ab46cf2f..768577d9 100644 --- a/ios/Transport.swift +++ b/ios/Transport.swift @@ -36,6 +36,13 @@ protocol EventSink: AnyObject { func emitProgress(_ body: [String: Any]) func emitAttempt(_ body: [String: Any]) func emitSettled(_ body: [String: Any]) + /// true when a JS listener can take a settled outcome: from the first + /// journal drain until the module goes away. When false, an outcome is + /// journaled with deliveries 0 and not emitted; the drain delivers it. + func canDeliver() -> Bool + /// The drain calls this on the coordinator queue before it reads the + /// journal, so a settle is either in the drain or emitted, never both. + func listenerReady() } /// What didCompleteWithError reports, with the response body already capped. From 2c0a9a2470fd6c22f321e3e7ae19a674dcf0bd78 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 16:40:59 -0400 Subject: [PATCH 16/22] iOS: cancel fails closed when the journal or store cannot be written Owner decision, matching Android: a cancel whose journal write fails rejects with E_STORAGE and changes nothing; the caller may call again. The hold-and-retry path stays for settle outcomes only. A cancel of a settled entry now forgets atomically: the id directory is set aside by one rename, the journal files are deleted, and a failure puts the row back and rejects. A crash between the two steps is finished or undone at launch. 178 tests. Co-Authored-By: Claude Fable 5.1 --- ios/EventJournal.swift | 15 ++++- ios/QueueCoordinator+Outcomes.swift | 85 ++++++++++++++++++++---- ios/QueueCoordinator.swift | 25 +++++-- ios/QueueStore.swift | 41 +++++++++++- ios/RNBackgroundUpload.swift | 2 +- ios/Tests/CoordinatorChunkedTests.swift | 20 ++++++ ios/Tests/CoordinatorRelaunchTests.swift | 30 +++++++++ ios/Tests/CoordinatorSimpleTests.swift | 65 ++++++++++++++++++ ios/Tests/SupportComponentTests.swift | 13 +++- ios/Tests/TestSupport.swift | 8 ++- 10 files changed, 274 insertions(+), 30 deletions(-) diff --git a/ios/EventJournal.swift b/ios/EventJournal.swift index 5fe1dcdb..46a0af89 100644 --- a/ios/EventJournal.swift +++ b/ios/EventJournal.swift @@ -175,9 +175,18 @@ final class EventJournal { } } - /// cancel() on a settled entry: its unacked outcomes go with it. - func removeForId(_ id: String) { - ack(unacknowledgedForId(id).map(\.eventId)) + /// cancel() on a settled entry: its unacked outcomes go with it. Throws at + /// the first file that cannot be deleted; a file already gone is not an + /// error. + func removeForId(_ id: String) throws { + let ids = unacknowledgedForId(id).map(\.eventId) + try queue.sync { + for eventId in ids { + let file = url(eventId) + guard FileIO.exists(file) else { continue } + try FileManager.default.removeItem(at: file) + } + } } /// v9 entries: files that decode as the v9 shape (have `type` and diff --git a/ios/QueueCoordinator+Outcomes.swift b/ios/QueueCoordinator+Outcomes.swift index 914b5281..9684185f 100644 --- a/ios/QueueCoordinator+Outcomes.swift +++ b/ios/QueueCoordinator+Outcomes.swift @@ -8,25 +8,28 @@ extension QueueCoordinator { static let journalRetryMs = 5_000 static let journalRetryMaxMs = 600_000 - /// The one terminal path. Cancels the entry's remaining tasks, emits the - /// trailing progress, journals the outcome, saves the settled row, emits - /// `state`, then emits `settled` with deliveries 1 when a listener exists. - /// With no listener the outcome stays at deliveries 0 for the drain. + /// The one terminal path. Journals the outcome, cancels the entry's + /// remaining tasks, emits the trailing progress, saves the settled row, + /// emits `state`, then emits `settled` with deliveries 1 when a listener + /// exists. With no listener the outcome stays at deliveries 0 for the + /// drain. /// - /// A failed journal write still settles and emits: the request already - /// ran, so a retry would send it twice, and a user cancel must not run - /// again. The event stays in memory, a timer retries the write, and ack - /// finds the row by its settledEventId. - func settle(_ id: String, _ outcome: Outcome) { - guard var e = index.entry(id) else { return } - cancelTasks(id, purpose: .superseded) - chunked.stop(id) + /// `requireJournal` (a user cancel): a failed journal write returns false + /// and changes nothing. No task stops, no row moves, nothing is emitted, + /// and nothing is kept in memory, so the caller can reject and JS can call + /// again. + /// + /// Otherwise a failed journal write still settles and emits: the request + /// already ran, so a retry would send it twice. The event stays in memory, + /// a timer retries the write, and ack finds the row by its settledEventId. + @discardableResult + func settle(_ id: String, _ outcome: Outcome, requireJournal: Bool = false) -> Bool { + guard var e = index.entry(id) else { return true } switch outcome { case .completed: e.bytesSent = e.totalBytes // A simple entry keeps the live bytes of its last attempt. default: if e.isChunked { e.bytesSent = e.acceptedBytes } } - emitProgress(id, sent: e.bytesSent, total: e.totalBytes) var event = JournaledEvent( eventId: UUID().uuidString, id: id, key: e.key, varsJSON: e.varsJSON, at: now(), @@ -50,6 +53,10 @@ extension QueueCoordinator { e.state = .cancelled } let journaled = journal.append(event, keeping: referencedEventIds().union([event.eventId])) + if !journaled && requireJournal { return false } + cancelTasks(id, purpose: .superseded) + chunked.stop(id) + emitProgress(id, sent: e.bytesSent, total: e.totalBytes) e.settledEventId = event.eventId e.nextAttemptAt = nil e.authParked = false @@ -71,6 +78,7 @@ extension QueueCoordinator { } disarmExpiry(id) throttle.reset(id) + return true } /// A settle whose journal write landed but whose entry.json save failed @@ -117,13 +125,62 @@ extension QueueCoordinator { store.remove(id) index.remove(id) if dropEvents { - journal.removeForId(id) + try? journal.removeForId(id) pendingJournal = pendingJournal.filter { $0.value.id != id } } disarmExpiry(id) throttle.reset(id) } + /// cancel() on a settled or legacy entry: the row, its bytes and its + /// unacked outcomes, all or none. The directory is set aside first (one + /// rename), then the outcome files are deleted. When a delete fails, the + /// directory goes back and this throws; nothing in memory changed. Limit: + /// with two or more outcome files, a failure after the first delete leaves + /// the ones already deleted gone. + func forgetWithEvents(_ id: String) throws { + let aside = try store.setAside(id) + do { + try journal.removeForId(id) + } catch { + if let aside { + do { + try store.restore(aside, id) + } catch let restore { + NSLog("[RNFileUploader] cannot restore \(id) after a failed cancel: \(restore.localizedDescription)") + } + } + throw error + } + pendingJournal = pendingJournal.filter { $0.value.id != id } + cancelTasks(id, purpose: .superseded) + chunked.stop(id) + index.remove(id) + disarmExpiry(id) + throttle.reset(id) + if let aside { store.discard(aside) } + } + + /// Launch, before the index loads: a set-aside directory is a + /// `forgetWithEvents` that did not finish. It finishes it when the + /// outcome files can go, and puts the row back when they cannot. When the + /// id has a directory again, the forget already passed its journal step + /// (only the discard failed), so it only discards. + func finishSetAsideForgets() { + for (aside, id) in store.setAsideDirectories() { + guard let id, !FileIO.exists(store.dir(id)) else { + store.discard(aside) + continue + } + do { + try journal.removeForId(id) + store.discard(aside) + } catch { + try? store.restore(aside, id) + } + } + } + /// One ack. The event comes from the journal, or from memory when its /// write failed. When neither has it (pruned, or a crash after the file /// went), the row that names the eventId still settles. diff --git a/ios/QueueCoordinator.swift b/ios/QueueCoordinator.swift index 1b38b5e0..93360c71 100644 --- a/ios/QueueCoordinator.swift +++ b/ios/QueueCoordinator.swift @@ -74,8 +74,9 @@ final class QueueCoordinator { /// release. var graces: [String: Grace] = [:] var afterGrace: [() -> Void] = [] - /// Outcomes whose journal write failed, by eventId. They were emitted - /// live; a timer retries the write while the entry still names them. + /// Settle outcomes (never a user cancel) whose journal write failed, by + /// eventId. They were emitted live; a timer retries the write while the + /// entry still names them. var pendingJournal: [String: JournaledEvent] = [:] lazy var chunked = ChunkedCoordinator(self) @@ -96,6 +97,7 @@ final class QueueCoordinator { queue.asyncAfter(deadline: .now() + .milliseconds(ms), execute: block) } settings = store.loadSettings() + finishSetAsideForgets() // Synchronous, before any session exists, so getRequests() is warm by // the time JS can call it, legacy rows included. index.load(store.all()) @@ -180,15 +182,24 @@ final class QueueCoordinator { /// Live entry: journal 'cancelled' (user); forgotten after its ack. /// Settled entry: forgotten now, with its unacked outcomes. Unknown: no-op. - func cancel(_ id: String, resolve: @escaping () -> Void) { + /// When the journal or the store cannot be written, rejects E_STORAGE and + /// changes nothing: the entry keeps running (or stays settled), and JS may + /// call again. + func cancel(_ id: String, resolve: @escaping () -> Void, reject: @escaping (String, String) -> Void) { queue.async { - defer { resolve() } - guard let e = self.index.entry(id) else { return } + guard let e = self.index.entry(id) else { return resolve() } if e.isLive && !e.legacy { - self.settle(id, .cancelled(reason: "user")) + guard self.settle(id, .cancelled(reason: "user"), requireJournal: true) else { + return reject("E_STORAGE", "cancel: cannot journal the outcome of '\(id)'; nothing changed") + } } else { - self.forget(id, dropEvents: true) + do { + try self.forgetWithEvents(id) + } catch { + return reject("E_STORAGE", "cancel: cannot delete '\(id)': \(error.localizedDescription)") + } } + resolve() } } diff --git a/ios/QueueStore.swift b/ios/QueueStore.swift index 07d76f23..3d3382eb 100644 --- a/ios/QueueStore.swift +++ b/ios/QueueStore.swift @@ -24,6 +24,8 @@ final class QueueStore { static let manifestName = "manifest.json" private static let settingsName = "settings.json" private static let importedMarker = "v10-imported" + /// Id directory names are base64url, which never starts with ".". + private static let asidePrefix = ".forget-" init(root: URL) { self.root = root @@ -80,6 +82,43 @@ final class QueueStore { queue.sync { _ = try? FileManager.default.removeItem(at: dir(id)) } } + // MARK: - Forget in steps (cancel on a settled entry) + + /// Step one of a forget that must not half-happen: renames the id + /// directory to a hidden name in `root`. One rename, so either the row is + /// gone from `all()` or nothing moved. Throws when the rename fails. + /// Returns nil when the id has no directory. + func setAside(_ id: String) throws -> URL? { + try queue.sync { + let d = dir(id) + guard FileIO.exists(d) else { return nil } + let aside = root.appendingPathComponent( + Self.asidePrefix + d.lastPathComponent + "-" + UUID().uuidString, isDirectory: true) + try FileIO.rename(d, onto: aside) + return aside + } + } + + /// Undoes `setAside`. + func restore(_ aside: URL, _ id: String) throws { + try queue.sync { try FileIO.rename(aside, onto: dir(id)) } + } + + /// Deletes a set-aside directory. A failure leaves it for the next launch. + func discard(_ aside: URL) { + queue.sync { _ = try? FileManager.default.removeItem(at: aside) } + } + + /// Set-aside directories a crash left, with the id their entry names (nil + /// when entry.json is unreadable). + func setAsideDirectories() -> [(aside: URL, id: String?)] { + queue.sync { + let items = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? [] + return items.filter { $0.lastPathComponent.hasPrefix(Self.asidePrefix) } + .map { ($0, Self.read($0.appendingPathComponent(Self.entryName))?.id) } + } + } + /// Deletes files the entry does not reference: an old body after a /// replace, a body staged by an enqueue that crashed before its save, /// `.tmp` leftovers, and part files of another incarnation. @@ -223,7 +262,7 @@ final class QueueStore { private func subdirectories() -> [URL] { let items = (try? FileManager.default.contentsOfDirectory( - at: root, includingPropertiesForKeys: [.isDirectoryKey])) ?? [] + at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])) ?? [] return items.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } } diff --git a/ios/RNBackgroundUpload.swift b/ios/RNBackgroundUpload.swift index 9728ea17..eb8fa4a2 100644 --- a/ios/RNBackgroundUpload.swift +++ b/ios/RNBackgroundUpload.swift @@ -152,7 +152,7 @@ public class RNBackgroundUpload: NSObject, URLSessionDataDelegate { @objc(cancel:resolve:reject:) public func cancel(_ id: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - coordinator.cancel(id) { resolve(nil) } + coordinator.cancel(id, resolve: { resolve(nil) }, reject: { reject($0, $1, nil) }) } @objc(setWifiOnly:resolve:reject:) diff --git a/ios/Tests/CoordinatorChunkedTests.swift b/ios/Tests/CoordinatorChunkedTests.swift index 747493ba..fc6b635e 100644 --- a/ios/Tests/CoordinatorChunkedTests.swift +++ b/ios/Tests/CoordinatorChunkedTests.swift @@ -281,6 +281,26 @@ final class CoordinatorChunkedTests: XCTestCase { XCTAssertFalse(FileIO.exists(h.store.dir("cap"))) } + func testCancelLiveChunkedWhoseJournalWriteFailsKeepsThePartsRunning() throws { + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5)).get() + h.complete(try XCTUnwrap(partTask(0))) + let live = h.transport.live + let states = h.sink.states.count + setReadOnly(h.journal.root, true) + XCTAssertEqual(h.cancel("cap")?.code, "E_STORAGE") + XCTAssertTrue(live.allSatisfy { !$0.cancelled }) + XCTAssertEqual(h.entry("cap")?.state, .running) + XCTAssertEqual(h.sink.states.count, states) + XCTAssertTrue(h.sink.settled.isEmpty) + // The window still refills: the chunked side did not stop. + h.complete(live[0]) + XCTAssertEqual(h.transport.live.count, 3) + setReadOnly(h.journal.root, false) + XCTAssertNil(h.cancel("cap")) + XCTAssertTrue(h.transport.live.isEmpty) + XCTAssertEqual(h.journal.unacknowledged().count, 1) + } + func testLateCallbackFromAReplacedPlanIsDropped() throws { _ = try h.enqueue(h.chunkedRaw(id: "cap", extra: ["retry": ["terminalHttp": ["exempt": []]]])).get() let stale = try XCTUnwrap(partTask(2)) diff --git a/ios/Tests/CoordinatorRelaunchTests.swift b/ios/Tests/CoordinatorRelaunchTests.swift index 2762e26e..0fc54e3a 100644 --- a/ios/Tests/CoordinatorRelaunchTests.swift +++ b/ios/Tests/CoordinatorRelaunchTests.swift @@ -169,6 +169,36 @@ final class CoordinatorRelaunchTests: XCTestCase { XCTAssertEqual(fresh.entry("a")?.settledEventId, eventId) } + /// A cancel on a settled entry that died after the directory was set + /// aside: the next launch finishes it. + func testRelaunchFinishesACancelThatDiedAfterTheSetAside() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(h.transport.live[0], status: 400) + _ = try h.store.setAside("a") + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertNil(fresh.row("a")) + XCTAssertTrue(fresh.journal.unacknowledged().isEmpty, "its outcome goes with it") + XCTAssertTrue(fresh.store.setAsideDirectories().isEmpty) + } + + /// Same, but the outcome files still cannot be deleted: the row comes + /// back, so no outcome is left without its row. + func testRelaunchPutsASetAsideRowBackWhenItsEventsCannotBeDeleted() throws { + h.boot() + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(h.transport.live[0], status: 400) + _ = try h.store.setAside("a") + setReadOnly(h.journal.root, true) + defer { setReadOnly(h.journal.root, false) } + let fresh = Harness(root: h.root) + fresh.boot() + XCTAssertEqual(fresh.row("a")?["state"] as? String, "error") + XCTAssertEqual(fresh.journal.unacknowledged().count, 1) + XCTAssertTrue(fresh.store.setAsideDirectories().isEmpty) + } + func testAckBeforeReconcileForgetsARowWhoseSettleSaveFailed() throws { let eventId = try settleWithFailedSave { h, task in h.complete(task) } let fresh = Harness(root: h.root) // no boot: the ack runs first diff --git a/ios/Tests/CoordinatorSimpleTests.swift b/ios/Tests/CoordinatorSimpleTests.swift index dd32e2ed..7bca024a 100644 --- a/ios/Tests/CoordinatorSimpleTests.swift +++ b/ios/Tests/CoordinatorSimpleTests.swift @@ -251,6 +251,71 @@ final class CoordinatorSimpleTests: XCTestCase { h.cancel("unknown") // no-op } + func testCancelWhoseJournalWriteFailsRejectsStorageAndChangesNothing() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = onlyTask() + let states = h.sink.states.count, progress = h.sink.progress.count, timers = h.timers.count + setReadOnly(h.journal.root, true) + defer { setReadOnly(h.journal.root, false) } + let rejected = try XCTUnwrap(h.cancel("a")) + XCTAssertEqual(rejected.code, "E_STORAGE") + XCTAssertTrue(rejected.message.contains("'a'"), rejected.message) + XCTAssertEqual(h.entry("a")?.state, .running) + XCTAssertEqual(h.store.load("a")?.state, .running) + XCTAssertNil(h.entry("a")?.settledEventId) + XCTAssertFalse(task.cancelled, "the work keeps running") + XCTAssertEqual(h.sink.states.count, states) + XCTAssertEqual(h.sink.progress.count, progress) + XCTAssertTrue(h.sink.settled.isEmpty) + XCTAssertTrue(h.coordinator.pendingJournal.isEmpty) + XCTAssertEqual(h.timers.count, timers, "no journal retry is scheduled") + XCTAssertTrue(h.unacknowledged().isEmpty) + } + + func testCancelAgainAfterTheJournalIsWritableSettlesWithOneRecord() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + let task = onlyTask() + setReadOnly(h.journal.root, true) + XCTAssertEqual(h.cancel("a")?.code, "E_STORAGE") + setReadOnly(h.journal.root, false) + XCTAssertNil(h.cancel("a")) + XCTAssertTrue(task.cancelled) + XCTAssertEqual(h.entry("a")?.state, .cancelled) + XCTAssertEqual(h.sink.settled.count, 1) + let records = h.journal.unacknowledged() + XCTAssertEqual(records.count, 1, "one record") + XCTAssertEqual(records.first?.kind, .cancelled) + XCTAssertEqual(records.first?.eventId, h.sink.settled.first?["eventId"] as? String) + XCTAssertTrue(h.coordinator.pendingJournal.isEmpty) + } + + func testCancelSettledWhoseDirectoryCannotMoveRejectsStorageAndKeepsAll() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 400) + setReadOnly(h.store.root, true) + defer { setReadOnly(h.store.root, false) } + XCTAssertEqual(h.cancel("a")?.code, "E_STORAGE") + XCTAssertEqual(h.row("a")?["state"] as? String, "error") + XCTAssertEqual(h.store.load("a")?.state, .error) + XCTAssertEqual(h.journal.unacknowledged().count, 1) + } + + func testCancelSettledWhoseEventsCannotBeDeletedPutsTheRowBack() throws { + _ = try h.enqueue(h.dataRaw(id: "a")).get() + h.complete(onlyTask(), status: 400) + setReadOnly(h.journal.root, true) + XCTAssertEqual(h.cancel("a")?.code, "E_STORAGE") + XCTAssertEqual(h.row("a")?["state"] as? String, "error") + XCTAssertEqual(h.store.load("a")?.state, .error, "the directory is back") + XCTAssertEqual(h.journal.unacknowledged().count, 1) + setReadOnly(h.journal.root, false) + XCTAssertNil(h.cancel("a")) + XCTAssertNil(h.row("a")) + XCTAssertFalse(FileIO.exists(h.store.dir("a"))) + XCTAssertTrue(h.journal.unacknowledged().isEmpty) + XCTAssertTrue(h.store.setAsideDirectories().isEmpty, "nothing left aside") + } + // MARK: - Retry, backoff, expiry func testTransientSchedulesADelayedTask() throws { diff --git a/ios/Tests/SupportComponentTests.swift b/ios/Tests/SupportComponentTests.swift index 8856692e..4780d67d 100644 --- a/ios/Tests/SupportComponentTests.swift +++ b/ios/Tests/SupportComponentTests.swift @@ -95,11 +95,20 @@ final class EventJournalTests: XCTestCase { XCTAssertNil(journal.load("1")) } - func testRemoveForId() { + func testRemoveForId() throws { journal.append(event("1", id: "a")) journal.append(event("2", id: "b")) - journal.removeForId("a") + try journal.removeForId("a") XCTAssertEqual(journal.unacknowledged().map(\.eventId), ["2"]) + try journal.removeForId("a") // nothing left: not an error + } + + func testRemoveForIdThrowsWhenAFileCannotBeDeleted() { + journal.append(event("1", id: "a")) + setReadOnly(journal.root, true) + defer { setReadOnly(journal.root, false) } + XCTAssertThrowsError(try journal.removeForId("a")) + XCTAssertNotNil(journal.load("1")) } func testBodyCapAtOneMegabyte() { diff --git a/ios/Tests/TestSupport.swift b/ios/Tests/TestSupport.swift index 754a376f..c9e21d58 100644 --- a/ios/Tests/TestSupport.swift +++ b/ios/Tests/TestSupport.swift @@ -198,9 +198,13 @@ final class Harness { return result! } - func cancel(_ id: String) { - coordinator.cancel(id) {} + /// Returns the rejection, or nil when cancel resolved. + @discardableResult + func cancel(_ id: String) -> EnqueueError? { + var rejected: EnqueueError? + coordinator.cancel(id, resolve: {}, reject: { rejected = EnqueueError(code: $0, message: $1) }) drain() + return rejected } func ack(_ eventIds: [String]) { From 371ac5197e817dca03626b5ef86e6dbccd9ceac0 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Tue, 6 Oct 2026 13:34:31 -0400 Subject: [PATCH 17/22] iOS: never crash when the session cannot open an upload's file A background URLSession raises an Objective-C exception, not an error, when uploadTask(with:fromFile:) cannot read the file. Swift cannot catch it, so the app ends. v9.0.1 fixed this for the v9 code (Sentry DIANA-19VW). This applies the same guard to the v10 transport. The transport now throws when the session cannot open the file. Each caller decides what that means: - A simple entry whose staged body is gone settles error 'file', as the missing-body check before it already does. - A simple entry whose body still exists is unreadable for now (data protection while the device is locked). It goes back to queued and is issued again after a backoff. - A chunked part follows the failed part-file rule: a short blob settles error 'file'; otherwise the window refills after a backoff. Co-Authored-By: Claude Opus 5.5 --- ios/ChunkedCoordinator.swift | 25 +++++++++++++++++------ ios/Package.swift | 6 ++++-- ios/QueueCoordinator+Simple.swift | 27 ++++++++++++++++++++----- ios/RNBackgroundUpload.swift | 16 ++++++++++++--- ios/Tests/CoordinatorChunkedTests.swift | 27 +++++++++++++++++++++++++ ios/Tests/CoordinatorSimpleTests.swift | 27 +++++++++++++++++++++++++ ios/Tests/TestSupport.swift | 7 ++++++- ios/Transport.swift | 5 +++-- 8 files changed, 121 insertions(+), 19 deletions(-) diff --git a/ios/ChunkedCoordinator.swift b/ios/ChunkedCoordinator.swift index 1aca7410..af126bfb 100644 --- a/ios/ChunkedCoordinator.swift +++ b/ios/ChunkedCoordinator.swift @@ -318,12 +318,25 @@ final class ChunkedCoordinator { id: id, partIndex: index, incarnation: e.incarnation, attempt: e.attempts, requestId: requestId, headerGeneration: q.settings.headerGeneration, generation: e.generation, purpose: .attempt) - let task = q.transport.upload( - q.buildRequest(e, url: url, requestId: requestId, partHeaders: part.headers), - fromFile: file, wifiOnly: q.settings.wifiOnly, - description: ChunkedEngine.taskDescription(id: id, part: index, incarnation: e.incarnation), - beginAt: delayMs.map { Date(timeIntervalSince1970: (q.now() + Double($0)) / 1000) }, - beforeResume: { key in self.q.taskMap.set(meta, forKey: key) }) + let task: UploadTask + do { + task = try q.transport.upload( + q.buildRequest(e, url: url, requestId: requestId, partHeaders: part.headers), + fromFile: file, wifiOnly: q.settings.wifiOnly, + description: ChunkedEngine.taskDescription(id: id, part: index, incarnation: e.incarnation), + beginAt: delayMs.map { Date(timeIntervalSince1970: (q.now() + Double($0)) / 1000) }, + beforeResume: { key in self.q.taskMap.set(meta, forKey: key) }) + } catch { + // The session could not open the part file. As for a failed part file + // build: a short blob can never succeed; otherwise try again later. + let blobSize = FileIO.size(q.store.fileURL(id, blob)) ?? 0 + if blobSize < part.end { + q.settle(id, .fileError("cannot read part \(index): \(error.localizedDescription)", partIndex: index)) + } else { + refillLater(e, part: part, delayMs: delayMs) + } + return false + } inFlight[id, default: [:]][index] = task.key q.liveTasks[task.key] = (id, task) if let delayMs { diff --git a/ios/Package.swift b/ios/Package.swift index 8a78b51a..ab3711e2 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -2,7 +2,8 @@ // Host-side unit tests for the pure half of the iOS module: `cd ios && swift test`. // The CocoaPods build ignores this file (see exclude_files in the podspec). // RNBackgroundUpload.swift and the .mm import React and UIKit, so they are -// not part of this package. +// not part of this package. Neither is the Obj-C exception helper that +// RNBackgroundUpload.swift calls; the tests fake the transport instead. import PackageDescription let package = Package( @@ -12,7 +13,8 @@ let package = Package( .target( name: "RNBGUCore", path: ".", - exclude: ["Tests", "RNBackgroundUpload.swift", "RNFileUploader.h", "RNFileUploader.mm", ".gitignore"], + exclude: ["Tests", "RNBackgroundUpload.swift", "RNFileUploader.h", "RNFileUploader.mm", + "RNBGUCatchException.h", "RNBGUCatchException.m", ".gitignore"], sources: [ "BodyStaging.swift", "ChunkedCoordinator.swift", diff --git a/ios/QueueCoordinator+Simple.swift b/ios/QueueCoordinator+Simple.swift index 511a25ae..17c6d35c 100644 --- a/ios/QueueCoordinator+Simple.swift +++ b/ios/QueueCoordinator+Simple.swift @@ -75,11 +75,28 @@ extension QueueCoordinator { let meta = TaskMap.Meta( id: id, attempt: e.attempts, requestId: requestId, headerGeneration: settings.headerGeneration, generation: e.generation, purpose: .attempt) - let task = transport.upload( - buildRequest(e, url: url, requestId: requestId), fromFile: body, wifiOnly: settings.wifiOnly, - description: ChunkedEngine.taskDescription(id: id, attempt: e.attempts, generation: e.generation), - beginAt: beginAt.map { Date(timeIntervalSince1970: $0 / 1000) }, - beforeResume: { key in self.taskMap.set(meta, forKey: key) }) + let task: UploadTask + do { + task = try transport.upload( + buildRequest(e, url: url, requestId: requestId), fromFile: body, wifiOnly: settings.wifiOnly, + description: ChunkedEngine.taskDescription(id: id, attempt: e.attempts, generation: e.generation), + beginAt: beginAt.map { Date(timeIntervalSince1970: $0 / 1000) }, + beforeResume: { key in self.taskMap.set(meta, forKey: key) }) + } catch { + // The session could not open the body. Gone since the check above: it + // can never send. Still there: it is unreadable for now (data + // protection while the device is locked), so try again later. + guard FileIO.exists(body) else { + settle(id, .fileError("cannot read the staged body: \(error.localizedDescription)")) + return + } + var waiting = e + waiting.state = .queued + waiting.nextAttemptAt = nil + commit(waiting) + deferIssue(waiting, delayMs: delayMs) + return + } liveTasks[task.key] = (id, task) throttle.reset(id) } diff --git a/ios/RNBackgroundUpload.swift b/ios/RNBackgroundUpload.swift index eb8fa4a2..f25a480d 100644 --- a/ios/RNBackgroundUpload.swift +++ b/ios/RNBackgroundUpload.swift @@ -339,10 +339,20 @@ private final class SessionTransport: Transport { } func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String, - beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask { + beginAt: Date?, beforeResume: (String) -> Void) throws -> UploadTask { let session = self.session(wifiOnly: wifiOnly) - // A background session uploads from a file only. - let task = session.uploadTask(with: request, fromFile: file) + // A background session uploads from a file only. When it cannot read the + // file it raises an Objective-C exception, not an error. Swift cannot + // catch that, and it ends the app, so the helper catches it. + var created: URLSessionUploadTask? + if let exception = RNBGUCatchException({ + created = session.uploadTask(with: request, fromFile: file) + }) { + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile, userInfo: [ + NSLocalizedDescriptionKey: exception.reason ?? "Cannot read file at \(file.path)", + ]) + } + let task = created! task.taskDescription = description if let beginAt { task.earliestBeginDate = beginAt } let handle = SessionTask(session: session, task: task) diff --git a/ios/Tests/CoordinatorChunkedTests.swift b/ios/Tests/CoordinatorChunkedTests.swift index fc6b635e..2c304141 100644 --- a/ios/Tests/CoordinatorChunkedTests.swift +++ b/ios/Tests/CoordinatorChunkedTests.swift @@ -52,6 +52,33 @@ final class CoordinatorChunkedTests: XCTestCase { XCTAssertFalse(FileIO.exists(h.store.dir("cap"))) } + func testPartTheSessionCannotOpenTriesAgainLater() throws { + let src = h.root.appendingPathComponent("capture.mp4") + writeFile(src, bytes: 50) + h.transport.failUpload = { _ in NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile) } + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5, source: src)).get() + XCTAssertTrue(h.transport.live.isEmpty) + XCTAssertTrue(h.sink.settled.isEmpty, "the blob is whole: not terminal") + h.transport.failUpload = nil + h.advance(4 * 3_600_000) + XCTAssertEqual(h.transport.live.count, 3, "the window fills again") + } + + func testPartTheSessionCannotOpenOverAShortBlobSettlesFile() throws { + let src = h.root.appendingPathComponent("capture.mp4") + writeFile(src, bytes: 50) + h.transport.failUpload = { [unowned self] _ in + let e = self.h.entry("cap")! + try? FileManager.default.removeItem(at: self.h.store.fileURL("cap", e.bodyPath ?? ChunkedManifestV9.blobName)) + return NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile) + } + _ = try h.enqueue(h.chunkedRaw(id: "cap", size: 50, parts: 5, source: src)).get() + XCTAssertEqual(h.entry("cap")?.state, .error) + let error = try XCTUnwrap(h.sink.settled.last?["error"] as? [String: Any]) + XCTAssertEqual(error["errorKind"] as? String, "file") + XCTAssertEqual(error["partIndex"] as? Int, 0) + } + func testPart404WithEmptyExemptIsTerminalAndKeepsBytes() throws { _ = try h.enqueue(h.chunkedRaw(id: "cap", extra: ["retry": ["terminalHttp": ["exempt": []]]])).get() let second = try XCTUnwrap(partTask(1)) diff --git a/ios/Tests/CoordinatorSimpleTests.swift b/ios/Tests/CoordinatorSimpleTests.swift index 7bca024a..3e8f7b91 100644 --- a/ios/Tests/CoordinatorSimpleTests.swift +++ b/ios/Tests/CoordinatorSimpleTests.swift @@ -464,6 +464,33 @@ final class CoordinatorSimpleTests: XCTestCase { XCTAssertEqual(h.sink.attempts.last?["errorKind"] as? String, "file") } + // The session raises an exception for a file it cannot open. The + // transport turns it into an error; these check what the queue does next. + + func testSessionCannotOpenAMissingBodySettlesFile() throws { + h.transport.failUpload = { file in + try? FileManager.default.removeItem(at: file) // deleted after the check + return NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile) + } + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertEqual(h.entry("a")?.state, .error) + XCTAssertEqual((h.sink.settled.last?["error"] as? [String: Any])?["errorKind"] as? String, "file") + XCTAssertTrue(h.transport.live.isEmpty) + } + + func testSessionCannotOpenAReadableBodyTriesAgainLater() throws { + h.transport.failUpload = { _ in NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotOpenFile) } + _ = try h.enqueue(h.dataRaw(id: "a")).get() + XCTAssertEqual(h.entry("a")?.state, .queued) + XCTAssertEqual(h.store.load("a")?.state, .queued, "the disk does not say running") + XCTAssertTrue(h.transport.live.isEmpty) + XCTAssertTrue(h.sink.settled.isEmpty) + h.transport.failUpload = nil + h.advance(4 * 3_600_000) + XCTAssertEqual(h.transport.live.count, 1) + XCTAssertEqual(h.entry("a")?.state, .running) + } + func testAcceptRuleWithBodyIncludes() throws { _ = try h.enqueue(h.dataRaw(id: "a", extra: ["accept": [["status": 409, "bodyIncludes": "already completed"]]])).get() h.complete(onlyTask(), status: 409, body: "upload already completed") diff --git a/ios/Tests/TestSupport.swift b/ios/Tests/TestSupport.swift index c9e21d58..ac890b66 100644 --- a/ios/Tests/TestSupport.swift +++ b/ios/Tests/TestSupport.swift @@ -95,8 +95,13 @@ final class FakeTransport: Transport { var deferAllTasks = false private var pendingAllTasks: (() -> Void)? + /// When set, upload calls it and throws what it returns: the session could + /// not open the file. It may delete the file first, to race the check. + var failUpload: ((URL) -> Error?)? + func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String, - beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask { + beginAt: Date?, beforeResume: (String) -> Void) throws -> UploadTask { + if let error = failUpload?(file) { throw error } let task = FakeTask(key: "\(wifiOnly ? "wifi" : "any"):\(Self.next)", description: description, request: request, file: file, beginAt: beginAt, wifiOnly: wifiOnly) Self.next += 1 diff --git a/ios/Transport.swift b/ios/Transport.swift index 768577d9..b2b8c903 100644 --- a/ios/Transport.swift +++ b/ios/Transport.swift @@ -21,9 +21,10 @@ protocol UploadTask: AnyObject { protocol Transport: AnyObject { /// Creates an upload task, sets its description and begin date, calls /// `beforeResume` with its key (the caller writes the TaskMap there), then - /// resumes it. + /// resumes it. Throws when the session cannot open `file`: no task exists + /// then, and `beforeResume` was not called. func upload(_ request: URLRequest, fromFile file: URL, wifiOnly: Bool, description: String, - beginAt: Date?, beforeResume: (String) -> Void) -> UploadTask + beginAt: Date?, beforeResume: (String) -> Void) throws -> UploadTask /// Every task of both sessions. The completion may run on any queue. func allTasks(_ completion: @escaping ([UploadTask]) -> Void) From 55607174e96c6c251ca46a1fab1505dbb2e87ce5 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Thu, 24 Sep 2026 17:40:15 -0400 Subject: [PATCH 18/22] v10 slice 5: example app on v10, test seam, CI, docs, version 10.0.0 The example app is the device test harness. It is rewritten on the v10 API: definitions at module scope (JSON POST, multipart, chunked with chunkPlan, bodiless DELETE, no-vars GET), configure() with a headers provider, a queue list from getRequests() and the state and progress feeds, an attempt log, and controls for every step of the two device scripts: cancel, pause, resume, wifiOnly, updateHeaders with a typed value, same-id resume and replace, a short expiresAt, and a section of outcomes journaled before this launch for the kill-and-relaunch check. The local Express server answers by path segment (401, 404, 409, 503, slow, oversize) and writes chunked parts at their offsets. The stale Podfile.lock is regenerated for RN 0.84.1, the AppDelegate wires the background completion handler, and the Android manifest declares and requests POST_NOTIFICATIONS. The example README carries the device script in UI terms. Library: createUploadClient({ native }) accepts a fake TurboModule, and react-native-background-upload/src/testing exports createFakeNative(), an in-memory Spec with settle(), seeded rows, recorded calls, and ack tracking, so a consumer tests definitions and handlers against the real registry and delivery. CI gains a macOS job that runs swift test. Docs: CHANGELOG 10.0.0 covers all slices; README gets an "Upgrading from v9" section, iOS platform notes, a "Testing your definitions" section, and a corrected AppDelegate snippet (@import for .m, header search path for .mm). Version 10.0.0. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/node.yml | 39 + CHANGELOG.md | 193 +- README.md | 158 +- example/RNBGUExample/App.tsx | 918 ++++++--- example/RNBGUExample/README.md | 321 +++- example/RNBGUExample/__tests__/App.test.tsx | 107 +- .../android/app/src/main/AndroidManifest.xml | 4 + example/RNBGUExample/harness/files.ts | 80 + example/RNBGUExample/harness/log.ts | 54 + example/RNBGUExample/harness/settings.ts | 45 + example/RNBGUExample/harness/uploads.ts | 327 ++++ example/RNBGUExample/ios/Podfile.lock | 1663 ++++++++++------- .../RNBGUExample.xcodeproj/project.pbxproj | 20 + .../ios/RNBGUExample/AppDelegate.mm | 32 +- .../RNBGUExample/ios/RNBGUExample/Info.plist | 5 +- example/RNBGUExample/metro.config.js | 2 +- example/server/.gitignore | 3 + example/server/package.json | 2 +- example/server/src/index.ts | 347 ++-- example/server/yarn.lock | 14 +- package.json | 6 +- src/__tests__/client.test.ts | 93 + src/__tests__/testing.test.ts | 423 +++++ src/__tests__/testingGlobal.test.ts | 46 + src/index.ts | 17 +- src/testing.ts | 581 ++++++ 26 files changed, 4342 insertions(+), 1158 deletions(-) create mode 100644 example/RNBGUExample/harness/files.ts create mode 100644 example/RNBGUExample/harness/log.ts create mode 100644 example/RNBGUExample/harness/settings.ts create mode 100644 example/RNBGUExample/harness/uploads.ts create mode 100644 src/__tests__/testing.test.ts create mode 100644 src/__tests__/testingGlobal.test.ts create mode 100644 src/testing.ts diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml index 4e31494b..46803663 100644 --- a/.github/workflows/node.yml +++ b/.github/workflows/node.yml @@ -40,3 +40,42 @@ jobs: run: | cd example/RNBGUExample/android ./gradlew :react-native-background-upload:testDebugUnitTest + + # Host-side unit tests for the pure Swift half of the iOS module. + # They run on macOS with Xcode's Swift toolchain. ios/Package.swift needs + # swift-tools-version 5.9. The macos-15 image ships Xcode 16 or later + # (Swift 6 or later), which meets that. + # + # This job does not build the example app. Do that by hand before a + # release, because a simulator build is too slow for every push: + # cd example/RNBGUExample/ios && pod install + # xcodebuild -workspace RNBGUExample.xcworkspace -scheme RNBGUExample \ + # -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \ + # -configuration Debug build + ios-host-tests: + runs-on: macos-15 + timeout-minutes: 30 + if: "!contains(github.event.head_commit.message, '[skip ci]')" + + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: swift version + id: swift + run: | + swift --version + echo "version=$(swift --version 2>&1 | head -n 1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT" + + - name: cache swift build + uses: actions/cache@v4 + with: + path: ios/.build + key: swiftpm-${{ runner.os }}-${{ steps.swift.outputs.version }}-${{ hashFiles('ios/*.swift', 'ios/Tests/**/*.swift') }} + restore-keys: | + swiftpm-${{ runner.os }}-${{ steps.swift.outputs.version }}- + + - name: ios host tests + run: | + cd ios + swift test diff --git a/CHANGELOG.md b/CHANGELOG.md index c66ec282..8415ca37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,12 @@ -## 10.0.0 (unreleased) +## 10.0.0 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`. +"Usage" and "Reliable delivery" sections. "Upgrading from v9" there covers +the migration. Breaking: - **`startUpload` and `getAllUploads` are removed.** `define()` + `mutate()` @@ -29,13 +26,18 @@ Breaking: - **`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`, a - `headers` provider that runs at `mutate()`, `maxVarsBytes` (default 1 MB), - and `enqueueTimeoutMs` (default 10 s): `mutate()` rejects with a named - error and warns when the native write has not settled by then, so a native - bug cannot hang a caller in silence. + the replay of journaled outcomes. Its `android` notification options are + unchanged from v9. - **`ErrorKind` gains `'truncated'`.** With a `response` parser set and a body over the 1 MB cap, `onError` fires with it instead of `onSuccess`. +- **Removed exports:** `startUpload`, `cancelUpload`, `removeUpload`, + `getAllUploads`, the public `getUnacknowledgedEvents` / `ackEvents`, the + `error` / `completed` / `cancelled` event names, and the + `ProgressData`, `CompletedData`, `ErrorData`, `CancelledData`, `EventData`, + `TerminalEventData`, `JournaledEvent`, `UploadSnapshot`, `UploadOptions`, + `ChunkedUploadOptions`, `StartUploadOptions`, `AndroidOnlyUploadOptions`, + `RawUploadOptions`, and `UploadId` types. The native `startChunkedUpload` is + internal: a descriptor with `parts` routes to it. Added: - **`createUploadClient()`**: builds a client with its own definitions and @@ -43,49 +45,146 @@ Added: - **`define({ key, request, response?, onSuccess?, onError? })`**: `vars` infer from the `request` parameter, the handler data type from the `response` return. `response(raw, vars)` also receives the entry's `vars`; - a one-argument parser such as `schema.parse` still fits. A duplicate key - replaces the definition and warns in development. + a one-argument parser such as `schema.parse` still fits. Without + `response`, `onSuccess` receives the `RawResponse`. A duplicate key + replaces the definition and warns in development. A `request` that takes + no parameter gives a `mutate()` that takes no arguments. - **`mutate(vars, { id? })`**: runs `request(vars)` once, merges the configured headers under the descriptor's, validates the descriptor (at most one of - `data` / `form` / `file`, none for a bodiless DELETE; no body on a GET; - `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` is any - JSON-serializable object, so generated API request types work as they are; - `mutate()` rejects vars or `data` that do not serialize (a cycle, a function, - a BigInt) and caps `vars` at `configure().maxVarsBytes`, 1 MB by default. A - definition whose `request` takes no vars calls `mutate()` with no arguments. - It resolves after the row and every staged body copy are on disk, so the - caller may delete its source file then; native failures reject with - `E_INVALID`, `E_RUNNING`, `E_FILE_MISSING`, or `E_STORAGE`. + `data` / `form` / `file`; no body on a GET; `parts` only with `file`; parts + must tile the file from 0; no field outside the descriptor shape, with a + "did you mean" hint), defaults `expiresAt` to now + `lifetimeMs`, and + resolves when the entry is durable: after the row and every staged body + copy are on disk, so the caller may delete its source file then. `vars` is + any JSON-serializable object, so generated API request types work as they + are; `mutate()` rejects vars or `data` that do not serialize (a cycle, a + function, a BigInt) and caps `vars` at `configure().maxVarsBytes`, 1 MB by + default. `id` defaults to a UUID. +- **Same id, again.** The body is `data`, `form`, `file`, or `parts`, and a + different `url` or `method` counts as a different body. Same body: resume, + with new headers, `expiresAt`, and `vars`. Different body on an entry that + is not running: replace and reopen; it settles once more. Different body + on a running entry: reject `E_RUNNING`. Cancelled but not yet acknowledged: + a fresh generation. Completed but not yet acknowledged: the journaled + outcome is emitted again and nothing is re-sent. - **Request bodies**: JSON (`data`), multipart (`form`), whole file (`file`), - and chunked (`file` + `parts`). All under one entry shape and one id. + and chunked (`file` + `parts`). All under one entry shape and one id. A + bodiless request (a GET, a DELETE, a POST whose meaning is in the URL) sets + none. `data: null` sends the JSON body `null`. +- **`configure()` options**: `lifetimeMs` (default 14 days), `retry` + (backoff base 1 s, max 2 h, jitter 0.2; `terminalHttp.exempt` default + `[404]`), a `headers` provider that runs at `mutate()`, `maxVarsBytes` + (default 1 MB), `enqueueTimeoutMs` (default 10 s: `mutate()` rejects with + a named error and warns when the native write has not settled by then, so + a native bug cannot hang a caller in silence), and the v9 `android` + notification options. +- **Queue control**: `pause()` / `resume()` for the whole queue, `cancel(id)`, + `setWifiOnly(enabled)`, and `updateHeaders(patch)` to re-auth entries + parked on 401 or 403. Each `updateHeaders()` call bumps a header + generation, so a 401 from an attempt sent under older headers re-issues at + once instead of parking. +- **`getRequests(filter?)`**: synchronous, from native's in-memory index. + Returns every entry native has not yet forgotten, so completed and + cancelled rows appear until their ack and `error` rows until `cancel()` or + a same-id `mutate()`. `filter` is `{ key?, id? }`. +- **`RequestRow`**: `{ id, key, vars, state, bytesSent, totalBytes, attempts, + updatedAt, nextAttemptAt? }`. `state` is `queued`, `running`, + `awaiting-auth`, `paused`, `completed`, `error`, or `cancelled`. + `nextAttemptAt` (epoch ms) is set while an entry waits out a retry + backoff. `attempts` counts the current generation. +- **`Meta`** for handlers: `{ id, key, at, attempts, requestId?, deliveries }`. + `deliveries` counts deliveries that reached a JS listener: 1 on the first, + +1 per replay. A handler that keeps throwing sees it grow; the library + never gives up on its own, so the app decides a poison policy. +- **Events**: `state` (a full `RequestRow` per transition, plus + `reason: 'unhandled-key'` for an outcome whose key has no definition), + `progress` (byte-weighted across a chunked upload's parts, throttled to + 1 s in the foreground), and `attempt` (one row per HTTP attempt with + `requestId`, `httpCode`, a 4 KB response body, `responseHeaders`, and + `errorKind`). `attempt.outcome` is `completed` or `error`; pause, cancel, + and supersede emit none. `attempt` events are live-only, never journaled. - **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. No ordering is - promised between different ids. -- **`Meta.deliveries`**: counts deliveries that reached a JS listener: 1 on - the first, +1 per replay. A handler that keeps throwing sees it grow; the - library never gives up on its own, so the app decides a poison policy. -- **`RequestRow.nextAttemptAt`**: epoch ms, set while an entry waits out a - retry backoff. `getRequests()` returns every entry native has not yet - forgotten, so completed and cancelled rows appear until their ack. -- **`pause()` / `resume()`** for the whole queue, **`updateHeaders(patch)`** to - re-auth parked entries, and the **`attempt`** event with one row per HTTP - attempt. Its `outcome` is `completed` or `error`; pause, cancel, and - supersede emit none. - -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. + a handler that has not settled after 30 s logs a warning and keeps + waiting; a malformed journal entry is dropped with a warning. No ordering + is promised between different ids. +- **Error codes** on the native promises: `E_INVALID` (input native cannot + send: a non-http(s) URL, a header name or value the platform HTTP client + rejects, a GET with a body, parts that do not tile the file), + `E_RUNNING`, `E_FILE_MISSING`, and `E_STORAGE`. `cancel()` rejects with + `E_STORAGE` and changes nothing when the journal or the store cannot be + written. `updateHeaders()` rejects with `E_INVALID` for a bad header name + or value. +- **`X-Request-Id`** on every attempt, minted per attempt. `Meta.requestId` + and `attempt.requestId` carry it. +- **`chunkPlan`** stays a module export and is also on the client. +- **Testing seam**: `createUploadClient({ native })` takes a fake native + module, and `createFakeNative()` in `src/testing` builds one that keeps + rows, journals outcomes, and acks, with `settle()`, `failNext()`, + `seedRows()`, and `seedUnacknowledged()` to script native behavior. The + real validation and delivery run on top of it. + +Native: +- **Android queue.** One durable store under `files/rnbgupload-chunked/`, + one directory per entry (`entry.json` plus the staged body), written + tmp + fsync + rename. The journal of settled outcomes moves to + `files/rnbgupload-settled/`. Queue settings (`wifiOnly`, `paused`, header + generation, retry defaults) persist next to the entries. An in-memory + index serves `getRequests()`. One WorkManager worker per id; a long + backoff waits on a separate wake job so a same-id `mutate()` or + `updateHeaders()` can run the entry at once. The backoff streak is stored + on the entry, so the wait grows toward 2 h across runs. A run stopped by + WorkManager's timeout takes one more backoff step instead of restarting + at once. The global cap of 4 concurrent requests and the window of 3 + parts per chunked upload carry over from v9. A boot sweep repairs a settle + whose entry save was lost, applies a cancel whose save was lost, and + finishes a pause or resume cut short by process death. A settle whose + journal write fails holds the record in memory and retries the write + (5 s, doubling to 10 min). Response bodies are capped at 1 MB while they + stream, counted in UTF-8 bytes and cut on a character boundary. +- **iOS queue.** The v9 chunked store is generalized in place into the queue + store. Every body is staged to a file in the entry directory, because a + background `URLSession` uploads from files only. Store, journal, and task + map writes are tmp + fsync + rename, and the directories are excluded from + backup. Retries are delayed session tasks (`earliestBeginDate`) for simple + entries and for chunked parts, so a backoff keeps running while the app is + suspended or dead. Each attempt owns its task through the task + description, so a stale completion cannot settle a newer attempt. At + relaunch, an entry whose task is gone but whose completion may still be + pending waits up to 10 s for the replay before it re-issues, so a request + the daemon finished while the app was dead is not sent twice; the + background completion handler is released as soon as that replay lands. + Two background sessions, one that allows cellular and one Wi-Fi only, each + with `httpMaximumConnectionsPerHost = 4`. A system cancel, including a + force-quit, is a transient failure: no outcome, the entry retries. A + multipart body always uses its own `Content-Type`, because a caller's has + no boundary. Response bodies are capped at 1 MB while they stream. The v9 + per-part budget of 3 HTTP rejections is gone; the retry policy decides. +- **v9 import, both platforms.** The first v10 launch imports each v9 journal + entry as a read-only settled row with key `legacy`, the v9 upload id, and + 0/0 bytes. Nothing is delivered for them: the app reads them with + `getRequests({ key: 'legacy' })` and calls `cancel(id)`. In-flight v9 work + is cancelled (Android WorkManager rows, iOS session tasks). v9 chunked + manifests and their bytes stay: a same-id `mutate()` with the same parts + resumes from the accepted parts, and one with different parts starts over + on the kept bytes. Wi-Fi only starts off. +- **Platform limits.** Android, API 31 and later: a WorkManager run started + from the background usually cannot start its foreground service and then + has JobScheduler's limit of about 10 minutes; a single body that does not + finish in that time restarts from byte 0 after a backoff, so large bodies + need `parts`. Android: the store and journal hold auth headers and staged + bodies, so the host app sets `android:allowBackup="false"` or excludes the + two directories. iOS: no global in-flight cap, and the AppDelegate + `handleEventsForBackgroundURLSession` hook is required for outcomes to be + journaled when the system relaunches the app. + +Fixed: +- iOS: a chunked part's retry backoff no longer stalls while the app is + dead. v9 ran the cooldown on an in-process timer. +- iOS: task-map keys whose completion never arrives are pruned at the end of + the relaunch grace wait and when their entry is gone. v9 kept them forever. ## 9.0.1 diff --git a/README.md b/README.md index c0189ddc..e76194b6 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,12 @@ yarn add react-native-background-upload cd ios && pod install && cd .. ``` -`pod install` is required after installing — it runs codegen to generate the native +`pod install` is required after installing. It runs codegen to generate the native spec this module implements. > The package ships TypeScript source with no build step, so it resolves through Metro -> (and `tsc`) but not through plain Node. If you import it from a non-Metro context — -> a script, or Jest without a transform — add it to your `transformIgnorePatterns` +> (and `tsc`) but not through plain Node. If you import it from a non-Metro context, +> such as a script or Jest without a transform, add it to your `transformIgnorePatterns` > allowlist or mock it. ## iOS: background completion handler (required) @@ -29,7 +29,8 @@ So uploads that finish while the app is terminated can relaunch it and be journaled, add this to your `AppDelegate`: ```objc -#import +// AppDelegate.m (Objective-C). Diana uses this form. +@import react_native_background_upload; - (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier @@ -39,15 +40,20 @@ handleEventsForBackgroundURLSession:(NSString *)identifier } ``` -> The Swift header import name is the pod name with hyphens as underscores. If -> your app links pods as frameworks, use `@import react_native_background_upload;` -> instead of the `#import <...-Swift.h>` line. +> CocoaPods wires the module map for the app target, so `@import` works in an +> Objective-C `.m` file with the default static-library setup. If your +> AppDelegate is Objective-C++ (`.mm`), `@import` is not available. Add +> `"$(PODS_CONFIGURATION_BUILD_DIR)/react-native-background-upload/Swift Compatibility Header"` +> to the app target's `HEADER_SEARCH_PATHS`, then use +> `#import ` followed by +> `#import "react_native_background_upload-Swift.h"`. The example app's +> `AppDelegate.mm` does this. This hook is load-bearing beyond just calling the completion handler: it is what brings the library's background `URLSession` back to life in a process the system relaunched with no JS running, so queued completions get journaled. `RNFileUploader` -is the TurboModule and is deliberately not reachable from plain Objective-C — its -generated header is Objective-C++ only — so the handler lives on `RNBackgroundUpload`. +is the TurboModule and is deliberately not reachable from plain Objective-C, because its +generated header is Objective-C++ only. So the handler lives on `RNBackgroundUpload`. # Usage @@ -206,6 +212,30 @@ accepted parts are kept across runs. iOS has no equal limit. and staged bodies. Set `android:allowBackup="false"` in the host app, or exclude those two directories in its backup rules. +### iOS platform notes + +**Bodies are files.** A background `URLSession` uploads from files only, so +the library writes every `data` and `form` body to a file in its own +directory at `mutate()`. The bytes stay there until the entry is forgotten. +The store and journal are excluded from iCloud and iTunes backups. + +**No global cap.** iOS has no hard limit on requests in flight. Each of the +two background sessions (cellular allowed, and Wi-Fi only) sets +`httpMaximumConnectionsPerHost = 4` as a per-host backstop, and a chunked +upload sends at most 3 parts at a time. + +**Force-quit.** When the user swipes the app away, iOS cancels the session's +tasks. The library treats that as a transient failure: no outcome is +produced, the entry stays `queued` or `running`, and it is sent again at the +next launch. Accepted chunked parts are kept. A suspension or a system +termination is different: the tasks keep running, and the AppDelegate hook +above lets the library journal their outcomes. + +**Relaunch.** When the app comes back and an entry's task is gone but a +completion may still be in flight from the daemon, the library waits up to +10 s for it before it sends again. This is what stops a request that finished +while the app was dead from being sent twice. + # Reliable delivery 1. **Write-ahead.** Entry, descriptor, and staged body persist before any @@ -343,8 +373,11 @@ entry past `expiresAt` settles `error` with `errorKind: 'expired'` at A live entry settles `cancelled` with reason `user` and is forgotten after its ack. A settled entry is forgotten now: row, bytes, and its unacknowledged outcomes. An unknown id resolves and does nothing. If the -journal or the store cannot be written, `cancel()` rejects with `E_STORAGE` and changes -nothing; the caller may call again. +journal or the store cannot be written, `cancel()` rejects with `E_STORAGE`. +The caller may call again. On Android, a cancel whose journal write landed but +whose entry save failed is already in effect: the work stops and the +`cancelled` outcome is journaled. Its ack, a retry, or the next boot sweep +finishes it. ### `setWifiOnly(enabled): Promise` Persisted natively. Applies to queued and future entries. @@ -355,14 +388,16 @@ resumes the entries parked on `awaiting-auth`. The patch also replaces same-named headers a part carries. This is how a fresh token reaches requests that stalled on 401. Each call bumps a header generation: a 401 or 403 from an attempt issued under an older generation re-issues at once -instead of parking. Parking emits one `state` event per entry. +instead of parking. Parking emits one `state` event per entry. A header name +or value the platform HTTP client cannot send rejects with `E_INVALID`. ### `getRequests(filter?): RequestRow[]` Synchronous, from native's in-memory index, so it works offline. Returns every entry native has not yet forgotten: `queued`, `running`, `awaiting-auth`, and `paused` entries; `completed` and `cancelled` entries until their ack; `error` entries until `cancel()` or a same-id `mutate()`; -and imported legacy rows. `filter` is `{ key?, id? }`. A row is +and rows imported from a v9 install (key `legacy`, see Upgrading from v9). +`filter` is `{ key?, id? }`. A row is `{ id, key, vars, state, bytesSent, totalBytes, attempts, updatedAt, nextAttemptAt? }`; `nextAttemptAt` (epoch ms) is set while the entry waits out a retry backoff. `vars` is typed `Json`, because a row does not know its definition. Narrow @@ -398,6 +433,103 @@ Fires when the Android progress notification is pressed. No event data. Terminal outcomes do not appear here. They go to the definition's handlers. +# Upgrading from v9 + +The CHANGELOG lists every removed v9 export with its replacement. In short: +`startUpload` becomes a `define()` plus `mutate()`; `getAllUploads` becomes +`getRequests()`; `cancelUpload` and `removeUpload` become `cancel(id)`; the +terminal event names become the definition's handlers; per-upload `wifiOnly` +becomes `setWifiOnly()`. + +What happens to work a v9 build left behind: + +- **The v9 journal becomes `legacy` rows.** On the first v10 launch, every + v9 journal entry that JS never acknowledged becomes a read-only settled row + with key `legacy`, the v9 upload id, and `0/0` bytes. No handler runs for + them. Read them with `getRequests({ key: 'legacy' })`, reconcile your own + state, then `cancel(id)` each one. The import runs before the first + `getRequests()` answers. +- **In-flight v9 work is cancelled natively.** Nothing runs unowned. +- **v9 chunked uploads resume under the same id.** The v9 manifest and bytes + stay on disk. A `mutate()` with the v9 upload id and the same parts resumes + from the accepted parts; different parts start over on the kept bytes. An + id with no `mutate()` keeps its bytes until `cancel(id)`. +- **Wi-Fi only starts off.** Call `setWifiOnly(true)` again if the app had it + on. + +Each `mutate()` on an id that already exists follows the rules in "Same id, +again" above, so a re-dispatch from persisted app state is safe. + +# Testing your definitions + +`createUploadClient({ native })` takes a fake native module, and +`createFakeNative()` builds one. The real validation, header merge, and +delivery run on top of it; the fake keeps rows, journals outcomes, and acks, +but never sends HTTP. + +Importing the package root resolves the native module at load, so a test must +mock `react-native`'s `TurboModuleRegistry` first, as below. The fake imports +no react-native code at runtime, so the mock factory can require it. + +```ts +jest.mock('react-native', () => { + const { createFakeNative } = jest.requireActual( + 'react-native-background-upload/src/testing', + ); + const fake = createFakeNative(); + return { + TurboModuleRegistry: { getEnforcing: () => fake, get: () => fake }, + }; +}); + +import { createUploadClient } from 'react-native-background-upload'; +import { createFakeNative } from 'react-native-background-upload/src/testing'; + +const native = createFakeNative(); +const uploads = createUploadClient({ native }); +const onSuccess = jest.fn(); +const ping = uploads.define({ + key: 'ping', + request: () => ({ url: 'https://api.test/ping' }), + onSuccess, +}); +uploads.configure({}); + +const { id } = await ping.mutate(); +await native.settle(id, { kind: 'completed', response: { body: '{}' } }); +expect(onSuccess).toHaveBeenCalled(); +``` + +`settle()` resolves after delivery acks the outcome, so an async handler +has finished. It rejects after 2 s (`ackTimeoutMs`) when no ack comes. A +handler that rejects is not acked, so test it with a short timeout: + +```ts +const native = createFakeNative({ ackTimeoutMs: 50 }); +// ... define, configure, and mutate as above ... +await expect(native.settle(id, outcome)).rejects.toThrow(/not acknowledged/); +``` + +If you fire an event with `native.emit.settled()` instead, delivery runs the +handler and the ack later. Flush pending promises (for example +`await new Promise(setImmediate)`) before you assert on +`native.ackedEventIds`. + +The fake does not model the same-id rules (resume, replace, `E_RUNNING`, +re-emit) or the header merge of `updateHeaders()`; script those with +`failNext()` and `seedUnacknowledged()`. + +`native.entries` holds every enqueue as `mutate()` sent it, with `vars` and +`data` parsed back from JSON. `failNext()` makes the next native call reject +with a code such as `E_STORAGE`. `seedUnacknowledged()` and `seedRows()` +model a journal and rows left by a dead session, so `configure()` replays +them. + +An app that builds its client at module load can use the fake from the mock +for every client instead. Get it with +`TurboModuleRegistry.getEnforcing('RNFileUploader')`, and call +`native.reset()` in `beforeEach`. + # Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/example/RNBGUExample/App.tsx b/example/RNBGUExample/App.tsx index f7dbc9f7..8ce289bf 100644 --- a/example/RNBGUExample/App.tsx +++ b/example/RNBGUExample/App.tsx @@ -1,314 +1,706 @@ /** - * Sample React Native App - * https://github.com/facebook/react-native + * The v10 device test harness. Every step of the device test script in + * README.md can be done from this screen. The request kinds, the listeners, + * and configure() are in ./harness/uploads.ts. * * @format - * @flow */ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useState, useSyncExternalStore} from 'react'; import { - SafeAreaView, - StyleSheet, + PermissionsAndroid, + Platform, + Pressable, ScrollView, - View, - Text, StatusBar, - Button, + StyleSheet, + Switch, + Text, + TextInput, + View, } from 'react-native'; -import notifee, {AndroidImportance} from '@notifee/react-native'; +import Upload, {type RequestRow} from 'react-native-background-upload'; -import Upload, { - ChunkedUploadOptions, - UploadOptions, -} from 'react-native-background-upload'; +import { + exists, + makeFile, + makePhoto, + missingPath, + remove, + type TestFile, +} from './harness/files'; +import {clearLog, clock, getLog, log, subscribeLog} from './harness/log'; +import type {LogEntry, LogKind} from './harness/log'; +import {saveSettings, settings} from './harness/settings'; +import { + byKey, + deleteThing, + getNoVars, + getWithBody, + LAUNCHED_AT, + MODES, + PART_BYTES, + postForm, + postJson, + progress, + putChunked, + putFile, + ready, + session, + type Mode, +} from './harness/uploads'; + +const runId = () => + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); + +let counter = 0; + +type RejectLike = {code?: string; message?: string}; + +// Runs one button's work and logs the result or the rejection code. +const run = (label: string, work: () => Promise) => () => { + log('action', `${label}...`); + work().then( + result => + log( + 'action', + `${label}: resolved${ + result === undefined ? '' : ` ${JSON.stringify(result)}` + }`, + ), + (e: RejectLike) => + log( + 'warn', + `${label}: rejected code=${e?.code ?? '-'} ${e?.message ?? String(e)}`, + ), + ); +}; -import * as RNFS from 'react-native-fs'; +const fileNote = (f: TestFile) => + log('action', `file ${f.path.split('/').pop()} ${f.size} B md5=${f.md5}`); -const TEST_FILE = `${RNFS.DocumentDirectoryPath}/1MB.bin`; -const TEST_FILE_URL = - 'https://gist.githubusercontent.com/khaykov/a6105154becce4c0530da38e723c2330/raw/41ab415ac41c93a198f7da5b47d604956157c5c3/gistfile1.txt'; -const UPLOAD_URL = 'https://httpbin.org/post'; -const CHUNKED_UPLOAD_URL = 'https://httpbin.org/put'; -const NOTIFICATION_CHANNEL = 'RNBGUExample'; +// The path after its first segment: /ok/json/abc gives /json/abc. +const afterMode = (path: string) => { + const i = path.indexOf('/', 1); + return i < 0 ? '' : path.slice(i); +}; const App = () => { - const [uploadId, setUploadId] = useState(); - const [progress, setProgress] = useState(); - const [testFileDownload, setTestFileDownload] = useState< - 'downloading' | 'downloaded' - >(); + const [isReady, setReady] = useState(false); + const [host, setHost] = useState(settings.host); + const [mode, setMode] = useState(session.mode); + const [authDraft, setAuthDraft] = useState('Bearer good'); + const [auth, setAuth] = useState(session.auth); + const [customId, setCustomId] = useState(''); + const [expiresInS, setExpiresInS] = useState(''); + const [sizeMb, setSizeMb] = useState('30'); + const [wifiOnly, setWifiOnlyMirror] = useState(settings.wifiOnly); + const [throwInHandlers, setThrow] = useState(settings.throwInHandlers); + const [silent, setSilent] = useState(settings.silent); + const [rows, setRows] = useState([]); + const [filter, setFilter] = useState('all'); + const entries = useSyncExternalStore(subscribeLog, getLog); useEffect(() => { - // One-time notification configuration. The library keeps it in native - // storage. Thus a headless WorkManager relaunch shows the same text. The - // call does nothing on iOS. - Upload.configure({ - android: { - notificationId: NOTIFICATION_CHANNEL, - notificationTitle: NOTIFICATION_CHANNEL, - notificationTitleNoWifi: 'No wifi', - notificationTitleNoInternet: 'No internet', - notificationChannel: NOTIFICATION_CHANNEL, + ready.then( + () => { + setHost(settings.host); + setWifiOnlyMirror(settings.wifiOnly); + setThrow(settings.throwInHandlers); + setSilent(settings.silent); + setReady(true); }, - }); + e => log('warn', `boot failed: ${String(e)}`), + ); + if (Platform.OS === 'android' && Platform.Version >= 33) { + // The progress notification is also the foreground-service + // notification. Without this permission Android 13+ hides it. + PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + ).then(r => log('action', `POST_NOTIFICATIONS: ${r}`)); + } }, []); + // getRequests() is synchronous and cheap. A 1 s read also refreshes the + // backoff countdowns and drops rows that native forgot after an ack. useEffect(() => { - Upload.addListener('progress', data => { - setProgress(data.progress); - }); - Upload.addListener('error', data => { - console.log('Error!', JSON.stringify(data)); - }); - Upload.addListener('completed', data => { - console.log('Completed!', JSON.stringify(data)); - }); - Upload.addListener('cancelled', data => { - console.log('Cancelled!', JSON.stringify(data)); - }); - }, []); + if (!isReady) return; + const read = () => setRows(Upload.getRequests()); + read(); + const sub = Upload.addListener('state', read); + const timer = setInterval(read, 1000); + return () => { + sub.remove(); + clearInterval(timer); + }; + }, [isReady]); - useEffect(() => { - RNFS.exists('file://' + TEST_FILE) - .then(exists => { - if (exists) return; - - setTestFileDownload('downloading'); - return RNFS.downloadFile({fromUrl: TEST_FILE_URL, toFile: TEST_FILE}) - .promise; - }) - .then(() => setTestFileDownload('downloaded')); - }, []); + const target = (path: string) => ({ + host: settings.host, + path, + ...(Number(expiresInS) > 0 ? {expiresInS: Number(expiresInS)} : {}), + ...(settings.silent ? {silent: true} : {}), + }); + const idOption = () => (customId.trim() ? {id: customId.trim()} : undefined); + const mb = () => Math.max(1, Math.floor(Number(sizeMb) || 1)); - const ensureNotificationChannel = async () => { - await notifee.requestPermission({alert: true, sound: true}); + const enqueueJson = (m: Mode = mode, id = idOption()) => + postJson.mutate( + {...target(`/${m}/json/${runId()}`), n: ++counter, text: 'hi'}, + id, + ); - await notifee.createChannel({ - id: NOTIFICATION_CHANNEL, - name: NOTIFICATION_CHANNEL, - importance: AndroidImportance.LOW, - }); + const enqueueChunked = async (m: Mode = mode, id = idOption()) => { + const f = await makeFile(`chunked-${runId()}.bin`, mb()); + fileNote(f); + const r = await putChunked.mutate( + {...target(`/${m}/chunk/${runId()}`), file: f.path, size: f.size}, + id, + ); + log( + 'action', + `chunked ${r.id}: ${Math.ceil(f.size / PART_BYTES)} parts. Source ${ + (await exists(f.path)) ? 'still there (unexpected)' : 'moved' + }`, + ); + return r; }; - const onPressUpload = async () => { - await ensureNotificationChannel(); - - const uploadOpts: UploadOptions = { - type: 'raw', - url: UPLOAD_URL, - path: TEST_FILE, - method: 'POST', - headers: {}, - }; - - Upload.startUpload(uploadOpts) - .then(uploadId => { - console.log( - `Upload started with options: ${JSON.stringify(uploadOpts)}`, - ); - setUploadId(uploadId); - setProgress(0); - }) - .catch(function (err) { - setUploadId(undefined); - setProgress(undefined); - console.log('Upload error!', err); - }); + const actions = { + json: run('JSON POST', () => enqueueJson()), + form: run('Multipart photo', async () => { + const photo = await makePhoto(`photo-${runId()}.jpg`); + fileNote(photo); + const r = await postForm.mutate( + { + ...target(`/${mode}/form/${runId()}`), + photo: photo.path, + caption: 'test', + }, + idOption(), + ); + // The library copied the file, so the source can go now. + await remove(photo.path); + log('action', `form ${r.id}: source deleted after mutate()`); + return r; + }), + file: run('File PUT', async () => { + const f = await makeFile(`file-${runId()}.bin`, mb()); + fileNote(f); + const r = await putFile.mutate( + {...target(`/${mode}/file/${runId()}`), file: f.path}, + idOption(), + ); + await remove(f.path); + log('action', `file ${r.id}: source deleted after mutate()`); + return r; + }), + chunked: run('Chunked PUT', () => enqueueChunked()), + del: run('DELETE', () => + deleteThing.mutate(target(`/${mode}/thing/${runId()}`), idOption()), + ), + get: run('GET', () => getNoVars.mutate(null, idOption())), + threeJson: run('3 JSON at once', () => + Promise.all([1, 2, 3].map(() => enqueueJson(mode, undefined))), + ), + capTest: run('Cap test', async () => { + await fetch(`${settings.host}/stats/reset`, {method: 'POST'}); + return Promise.all([ + ...[1, 2, 3].map(() => enqueueChunked('slow', undefined)), + ...[1, 2, 3].map(() => enqueueJson('slow', undefined)), + ]); + }), + stats: run('Server stats', async () => + (await fetch(`${settings.host}/stats`)).json(), + ), + ping: run('Ping server (plain fetch)', async () => + (await fetch(`${settings.host}/`)).text(), + ), + missing: run('Missing file', () => + putFile.mutate({...target(`/${mode}/file/missing`), file: missingPath()}), + ), + getBody: run('GET with a body', () => + getWithBody.mutate(target(`/${mode}/get-body`)), + ), + pause: run('pause()', () => Upload.pause()), + resume: run('resume()', () => Upload.resume()), + updateHeaders: run(`updateHeaders(${authDraft})`, async () => { + await Upload.updateHeaders({Authorization: authDraft}); + session.auth = authDraft; + setAuth(authDraft); + }), + cancelUnknown: run("cancel('nope')", () => Upload.cancel('nope')), }; - const onPressChunkedUpload = async () => { - await ensureNotificationChannel(); + const onWifiOnly = (enabled: boolean) => + run(`setWifiOnly(${enabled})`, async () => { + await Upload.setWifiOnly(enabled); + saveSettings({wifiOnly: enabled}); + setWifiOnlyMirror(enabled); + })(); - // The library takes ownership of a chunked upload's file. It renames the - // file into its own directory. Thus we upload a copy, and the test file - // stays available. - const chunkedFile = `${RNFS.DocumentDirectoryPath}/chunked.bin`; - if (await RNFS.exists('file://' + chunkedFile)) { - await RNFS.unlink(chunkedFile); + // Same id and the stored vars: the same body. A different mode and path + // is a different body. + const again = (row: RequestRow, newBody: boolean) => { + const def = byKey[row.key]; + let vars = row.vars as {path?: string} | null; + if (newBody && vars?.path) { + vars = {...vars, path: `/${mode}${afterMode(vars.path)}-b`}; } - await RNFS.copyFile(TEST_FILE, chunkedFile); - - // A small min and max, so the 1MB test file still splits into some parts. - // Production callers use the server's real part-size limits. - const {size} = await RNFS.stat(chunkedFile); - const ranges = Upload.chunkPlan(size, {min: 128 * 1024, max: 256 * 1024}); - - const uploadOpts: ChunkedUploadOptions = { - type: 'chunked', - id: 'chunked-demo', - path: chunkedFile, - parts: ranges.map((range, i) => ({ - url: `${CHUNKED_UPLOAD_URL}?partNum=${i + 1}`, - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Range': `bytes ${range.start}-${range.end - 1}/${size}`, - }, - range, - })), - expiresAt: Date.now() + 24 * 60 * 60 * 1000, - }; - - Upload.startUpload(uploadOpts) - .then(uploadId => { - console.log( - `Chunked upload started: ${uploadId} (${ranges.length} parts)`, - ); - setUploadId(uploadId); - setProgress(0); - }) - .catch(function (err) { - setUploadId(undefined); - setProgress(undefined); - console.log('Chunked upload error!', err); - }); + run(`${newBody ? 'new body' : 'same body'} ${row.id}`, () => + def.mutate(vars as never, {id: row.id}), + )(); }; + const shown = entries.filter(e => filter === 'all' || e.kind === filter); + const journaled = entries.filter(e => e.journaled); + return ( - <> + - - - {testFileDownload === 'downloading' && ( - Downloading test file... - )} - - {testFileDownload === 'downloaded' && ( - - - -