Files
puter/src/backend/services/events/workerRuntime.ts
T
Daniel Salazar 23782dec12
release-please / release-please (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
feat(events): inline handlers in the user's own scope for onSchedule and onPersistent (#4487)
* feat(events): schema for user-scope handlers

Add event_user_handlers and event_subscriptions.handler_scope in all three
dialects. event_handlers is unchanged.

* feat(events): scope the handler and durable subscription stores

Handler store methods take a scope (app, or a user's scope of an app) and read
the matching table. App-scope behavior, SQL and cache keys are unchanged; user
scopes get their own cache keys, a 20-handler cap, and the inline @<sha256>
name. Adds user-scope listing, counting, deletion and unbound-handler reads.
Durable subscriptions store and return handlerScope, and listByHandler
filters by scope and holder.

* fix(events): hold the user-scope handler cap under concurrency and drop all scope caches on app delete

A create that lands past the cap removes itself and is refused. deleteForApp
drops each holder's cached set in batches, here and on peers. Batch scans over
user scopes and unbound handlers take limit and cursor.

* feat(events): set a CPU limit on events worker deploys

* test(events): cover redeploy and resend of an evicted events script

* test(events): name the deploy-on-miss test for what it checks

* fix(events): keep a deploy-on-miss call inside the delivery lease

* feat(workers): cap rented units per plan and refuse only new ones

* feat(events): publish handlers into a user scope, with inline create

* test(events): cover user-scope publish, inline create and listing

* test(workers): name the credentials gate without a vendor

* feat(events): run user-scope handlers as the holder

A user scope deploys one script per handler under the holder's own
identity, throttled per scope, with an eager deploy after publish that
skips an upload another holder already made. Delivery and schedule runs
address the holder's handler, carry the holder on the invoke headers,
batch calls, the miss handler and the rehydrate route, and stay owed
while the flag is off. Suspensions of a user scope notify the holder,
teardown (app delete, consent revoke, account delete) drops the scope,
and a job deletes unbound handlers after a day.

* test(events): cover user-scope deploy, address, notices, teardown and the sweep

Runs an inline user-scope handler through the local worker runtime as its
holder, and covers the holder on the wire, the flag-off path, revoke and
account-delete teardown, and the unbound-handler sweep.

* test(events): a lagging replica read does not suspend a user-scope schedule run

* feat(puter-js): inline user-scope handlers and scope option for handlers and workers

* docs(events): document inline and user-scope handlers, with playground examples

Cover inline handlers on onSchedule and onPersistent, handlerScope, the
scope option on handlers and workers, the user-scope limits and rented
units, and the new error codes. Add six playground examples and pin the
new limit constants in limits.test.ts.

* feat(schedules): carry the handler scope on the schedule view

* docs(events): describe user-handler cleanup, schedule handlerScope and suspension notices

* fix(events): gate the rented-unit cap on user handlers and let an account session destroy its own user scope

* fix(events): keep user-scope rows off app handlers across mixed versions and bound their deploys and reads

Cached durable rows carry an explicit scope marker; an unmarked entry with a
handler is resolved from the table and rewritten. User-scope rows are parked
before credit checks, billing, client delivery and suspension while the flag
is off. Delivery-time handler checks and addressing share the replica cache
entry. Concurrent eager deploys of one user-scope script upload once.

* fix(events): keep a user scope's age across handler churn for rent

event_user_handlers.scope_since records when the scope first held a handler.
A new row takes the scope's existing minimum, so replacing code does not make
the scope look newer to the monthly sweep. The count uses it instead of the
oldest remaining handler's created_at.

* fix(events): drop cached entries no row backs; document account-session scope destroy

* test(jobs): list the user-handler sweep among the system jobs

* fix(events): let the user-handlers migration apply on its own

* fix(events): fail a cached-row read whose scope lookup fails instead of returning app-scope rows

* fix(events): touch a reused user-scope handler before binding so the sweep cannot take it

* fix(events): bound the deploy-on-miss wait by the call's deadline
2026-11-03 23:26:04 -07:00

103 lines
4.3 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { createHash, createHmac } from 'node:crypto';
import type { EventHandler } from '../../stores/events/EventHandlerStore.js';
import { handlerSetHash } from './workerSource.js';
/**
* How an app's events worker is named and how it recognizes the platform.
*
* The script is named after the handler set it contains, so a changed set is a
* different script rather than an overwrite of a live one: publishing writes
* rows and nothing else, and whatever the rows currently hash to is what the
* next delivery resolves and deploys. Superseded scripts are left to the same
* idle eviction every hibernating worker gets.
*/
/** Prefix every events worker script carries, for tag-free identification. */
export const EVENTS_WORKER_PREFIX = 'evw-';
/**
* The worker session name a delivery's token is minted under. One session row
* per (user, app) is reused across every delivery to that app's handlers, so it
* shows up once in the user's sessions list and is revoked the same way any
* worker session is.
*/
// Contains `:`, which WORKER_NAME_REGEX forbids a user-created worker name
// from having — so this session can never collide with an ordinary
// `puter.workers.*` session under the same (user, app_uid) key.
export const EVENTS_WORKER_SESSION_NAME = 'events:handlers';
/**
* How long a delivery's token lasts: far past any invocation, and short enough
* that one kept after its run can't hold a chain at a lower depth for long.
*/
export const EVENTS_HANDLER_TOKEN_TTL_SECONDS = 15 * 60;
/**
* What scopes a script name to one backend. Two backends can share a dispatch
* namespace and database (staging and production, say); folding this into the
* name is what keeps a script deployed by one from resolving as the other's.
* The same value `#cfDeploy` binds a script's `puter_endpoint` as, so a script
* is always scoped to the backend whose requests it would actually answer.
*/
export const eventsWorkerScope = (config: {
workers?: { internetExposedUrl?: string };
}): string => config.workers?.internetExposedUrl ?? '';
/**
* The script a handler set deploys as, scoped to this backend. Truncated hash:
* long enough that two sets never collide, short enough to leave room in a
* script name.
*/
export const eventsWorkerScript = (setHash: string, scope: string): string =>
`${EVENTS_WORKER_PREFIX}${createHash('sha256')
.update(`${scope}\n${setHash}`, 'utf8')
.digest('hex')
.slice(0, 32)}`;
/**
* The script one user-scope handler deploys as: the set-of-one name, built from
* the handler's name and source hash alone. The holder and the app are not part
* of it, so identical code under one name is one script however many users and
* apps publish it.
*/
export const userHandlerScript = (
handler: Pick<EventHandler, 'name' | 'sourceHash'>,
scope: string,
): string => eventsWorkerScript(handlerSetHash([handler]), scope);
/** Version prefix on a derived key, so a rotation is visible in a log line. */
export const EVENTS_INVOKE_KEY_VERSION = 'k1';
/**
* The key an invocation carries and the script compares against its own
* binding. Derived from the deployment secret and the script name rather than
* stored, so there is no per-worker row to keep, and rotating the secret
* rotates every key at the next deploy.
*
* Defence in depth behind the dispatcher: reaching a script at all needs the
* internal secret, which no script ever sees.
*/
export const eventsInvokeKey = (secret: string, script: string): string =>
`${EVENTS_INVOKE_KEY_VERSION}:${createHmac('sha256', secret)
.update(`events-invoke:${script}`, 'utf8')
.digest('hex')}`;