mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-13 08:45:45 +00:00
feat: background delivery seam, retries, and consent (PUT-1682) (#3684)
This commit is contained in:
@@ -166,7 +166,13 @@ Values reach a handler through **`context`**, which is evaluated **once, at subs
|
||||
|
||||
See [`puter.events.handlers`](/Events/handlers/) for the deploy side — publishing, replacing, and what removing a name does to the subscriptions bound to it.
|
||||
|
||||
A persistent subscription can also stop without you unsubscribing: its handler was removed, its holder ran out of credit, or the share it was made under was withdrawn. It is then *suspended* rather than deleted, and [`list()`](/Events/list/) reports `suspendedAt` and `suspendedReason`. Everything but a withdrawn grant can resume.
|
||||
### Running when nobody is there takes consent
|
||||
|
||||
A persistent subscription delivers to a connected client when there is one and runs the app's handler in the background when there is not. The background half is a separate thing to agree to — your code running on the user's account with nobody watching — so it takes the per-app permission **`events:background`**, requested with [`puter.perms.request()`](/Perms/request/) and revocable wherever the user manages the app's access. Without it, subscribing with `worker` among its `targets` (the default for an app) fails with `events_background_consent_required`; taking it back suspends every worker-target subscription that app holds for that user. A subscription that only wants deliveries while your app is open asks for `targets: ['socket']` and needs no consent.
|
||||
|
||||
Pass `handler` as a **function** and it runs here too, whenever this client is the one the delivery goes to — the same body that runs in the worker, with the same `{ event, ctx, user, fetch, ack }`. See [`onPersistent()`](/Events/onPersistent/) for the acknowledgement rules; the short version is that a `single` delivery is settled by returning from the handler, and a handler that throws sees the event again.
|
||||
|
||||
A persistent subscription can also stop without you unsubscribing: its handler was removed, its holder ran out of credit, the handler kept failing, or the share it was made under was withdrawn. It is then *suspended* rather than deleted, and [`list()`](/Events/list/) reports `suspendedAt` and `suspendedReason`. Everything but a withdrawn grant can resume.
|
||||
|
||||
## Limits
|
||||
|
||||
|
||||
@@ -27,6 +27,40 @@ puter.events.onPersistent(options)
|
||||
- `context` (Object): Values the handler needs, delivered to it as a frozen `ctx`. **Capped at 4 KB serialized** — see below.
|
||||
- `expiresAt` (Number | String): When the subscription ends by itself — unix seconds or an ISO-8601 string, and it has to be in the future.
|
||||
|
||||
## Background delivery takes the user's consent
|
||||
|
||||
A persistent subscription can run your handler when nobody is there — a different thing from delivering to a page the user has open — so it takes its own per-app permission, **`events:background`**. Subscribing with `worker` among its `targets` without it fails with `events_background_consent_required`, and `['socket', 'worker']` is the default for a subscription an app creates. Ask for it the way you ask for anything else:
|
||||
|
||||
```js
|
||||
await puter.perms.request(['events:background']);
|
||||
```
|
||||
|
||||
The user can take it back wherever they manage an app's access; every worker-target subscription that app holds for them is then suspended with `permission_revoked`, and re-granting does not bring one back — subscribe again. A subscription that only wants deliveries while your app is open needs no consent at all: pass `targets: ['socket']`.
|
||||
|
||||
## Where the handler runs, and what it is handed
|
||||
|
||||
The handler runs **in this client while it is connected**, and in the app's events worker when it is not. It is the same body either way, called with:
|
||||
|
||||
| Binding | What it is |
|
||||
| --- | --- |
|
||||
| `event` | The projected event, or a gap marker. |
|
||||
| `ctx` | The frozen `context` this subscription was created with. |
|
||||
| `user` | A `puter` bound to the account holding the subscription — the ambient one in a client. |
|
||||
| `fetch` | [`puter.net.fetch`](/Networking/fetch/) where it exists, the environment's `fetch` otherwise. |
|
||||
| `ack` | On a `single` subscription only — see below. |
|
||||
|
||||
Passing `handler` as a **function** is what registers it to run here; a source string or `{ file }` is sent as a hash only, and nothing runs client-side. Either way the hash must match what is published under `handlerName`.
|
||||
|
||||
### Acknowledging a `single` delivery
|
||||
|
||||
A `single` delivery is owed to exactly one consumer, so it stays owed until it is acknowledged:
|
||||
|
||||
- Calling `ack()` takes the delivery.
|
||||
- Returning **without** calling it acknowledges it anyway — a handler that finished did the work.
|
||||
- **Throwing acknowledges nothing.** The lease lapses after 30 seconds and the delivery is offered again, so a handler that throws sees the same event twice. `event.id` is stable across redeliveries; use it to make the second one a no-op.
|
||||
|
||||
In the events worker the same three outcomes are the response status: `2xx` takes the delivery, `4xx` refuses it (it is dropped with a `gap` marker carrying `reason: 'handler_rejected'`), and `5xx`, `429` or no answer within 30 seconds means "not now" — the delivery is retried after 2 seconds, doubling to at most 5 minutes. **Five failures in a row, refusals included, suspend the subscription** with `failures`; the developer is notified and republishing the handler puts it back in service.
|
||||
|
||||
## `context` is evaluated once, and capped at 4 KB
|
||||
|
||||
A handler is deployed, not called: it is serialized and run later, somewhere else, so it cannot close over anything. `context` is how values reach it — and it is evaluated **at this call**, serialized, and never re-evaluated. `ctx.endpoint` is whatever `process.env.INGEST_URL` was when you subscribed, forever, until you subscribe again.
|
||||
@@ -52,6 +86,7 @@ A `Promise` that resolves to the subscription:
|
||||
- `contextKeys` (Array | null), `contextHash` (String | null): the shape of the stored context, never its values.
|
||||
- `createdAt`, `expiresAt` (Number | null): unix seconds.
|
||||
- `suspendedAt` (Number | null), `suspendedReason` (String | null): why it stopped delivering without being removed — see [`puter.events.handlers.remove()`](/Events/handlers/).
|
||||
- `off()` (Function): ends the subscription — stops running its handler here and unsubscribes it. The same thing as [`puter.events.unsubscribe(subId)`](/Events/unsubscribe/), with nothing to pass.
|
||||
|
||||
The promise rejects with `{ message, code }`:
|
||||
|
||||
@@ -65,6 +100,7 @@ The promise rejects with `{ message, code }`:
|
||||
| `events_handler_not_found` | No handler is published under `handlerName`. The subscription is **not** created. |
|
||||
| `events_handler_hash_mismatch` | The published handler is not the source this subscription was written against. |
|
||||
| `events_handler_required` | `delivery: 'single'` without a `handlerName`. |
|
||||
| `events_background_consent_required` | The subscription targets `worker` and the user has not granted this app `events:background`. |
|
||||
| `events_context_too_large` | The serialized `context` is over 4 KB. |
|
||||
| `events_context_invalid` | `context` is not JSON-serializable. |
|
||||
| `invalid_targets` | A target outside `socket`/`worker`/`push`, `push` on a `single` subscription, or `worker` on a subscription with no app. |
|
||||
|
||||
@@ -172,6 +172,10 @@ One write can reach many subscriptions, so events are bounded on both halves: ho
|
||||
| Undelivered deliveries per subscription | 10,000 |
|
||||
| Undelivered deliveries per *suspended* subscription | 100 |
|
||||
| Suspended subscriptions kept for | 30 days |
|
||||
| Handler invocation timeout | 30 seconds |
|
||||
| Wait before retrying a failed handler | 2 seconds, doubling |
|
||||
| Longest wait between retries | 5 minutes |
|
||||
| Handler failures in a row before suspension | 5 |
|
||||
| Published handlers per app | 100 |
|
||||
| Handler source size | 64 KB |
|
||||
| Handlers per `publishAll` call | 50 |
|
||||
@@ -207,6 +211,8 @@ A `kv:` subject is indexed on the first **6** `:`-segments, or **160 bytes**, of
|
||||
|
||||
The three per-event ceilings do not fail your call — they truncate the delivery and send a `gap` marker in its place, an event with `op: 'gap'` and no `uid` or `path`. A gap means something happened that you were not told the details of, so a client that must not miss changes should re-read the anchor when it sees one rather than treat the silence as "nothing changed".
|
||||
|
||||
A **background delivery** — one that runs your app's handler with nobody there — takes the user's consent, the per-app permission `events:background`, and a subscription targeting `worker` without it is refused with `events_background_consent_required`. A handler has **30 seconds** to answer each invocation. Answering `2xx` takes the delivery; `4xx` refuses it, and it is dropped with a `gap` marker carrying `reason: 'handler_rejected'` rather than sent again to the same answer; `5xx`, `429` and a timeout are all "not now", and the delivery is held **2 seconds** before the next attempt, doubling each time up to **5 minutes**. **Five failures in a row** — refusals included — suspend the subscription with `failures`, hold what it is owed under the suspended-backlog rules above, and notify the app's developer. Until an events worker is deployed for an app there is nothing to invoke, so a worker-target subscription self-limits along exactly this path.
|
||||
|
||||
A `single` subscription is delivered to exactly one consumer, which has **30 seconds** to acknowledge each delivery before it is offered again — twice to a connected client, then to the subscription's handler. Until it is acknowledged it is held for you, so a consumer that is away is a backlog that grows: **10,000** undelivered deliveries per subscription, after which the oldest are dropped and one `gap` marker with `reason: 'backlog_overflow'` takes their place. Each region also holds at most **1,000,000** undelivered deliveries across every subscription it serves, and sheds the oldest first — with the same marker — before it reaches that. A redelivery after a missed acknowledgement is normal and expected: deliveries are at-least-once, `event.id` is stable across them, and a handler that runs twice on the same id should do nothing the second time.
|
||||
|
||||
### Peer connections
|
||||
|
||||
Reference in New Issue
Block a user