Skip to content

Add a portable runtime surface for Routed and Cloudflare Workers #31

Description

@kingwill101

Context

Routed is planning a small jobs/scheduling layer built on Stem. It must run in normal Dart VM/Node deployments and in Cloudflare Workers, where VM libraries such as dart:io and dart:isolate are unavailable and queue work arrives as event-driven batches rather than through a long-lived consumer stream.

The current Stem public surface is not portable enough:

  • package:stem/stable.dart exports the VM Worker.
  • worker.dart owns process signals, isolates, host metrics, leases, and the long-lived consumer lifecycle.
  • src/core/stem.dart imports dart:io for host metadata and transitively imports VM-bound metrics.
  • src/core/contracts.dart imports task_invocation.dart, which exposes SendPort from dart:isolate.
  • src/core/task_result.dart imports the historical package:stem/stem.dart barrel.
  • QueueBroker requires consume/ack/nack/close even when a runtime only needs to publish.
  • ResultBackend combines task status, group/chord, and worker-heartbeat responsibilities.

Architecture

1. Portable public entrypoint

Add package:stem/portable.dart. Its complete transitive dependency graph must compile for Dart's JS target without dart:io, dart:isolate, dart:ffi, or other VM-only libraries.

It should expose:

  • StemProducer, StemObserver, and the Stem enqueue facade;
  • envelopes, task definitions, portable task contexts, handlers, registries, payload codecs, retry and signing contracts;
  • portable publishing and broker capability contracts;
  • narrow task-status, scheduling, and lock-store contracts;
  • a portable task-processing primitive and typed outcomes;
  • one-shot schedule execution.

Keep Worker, isolate pools and messaging, process signals, host metrics, daemon timers, filesystem stores, and other VM lifecycle code behind the existing compatibility barrels and an explicit VM entrypoint such as package:stem/vm.dart.

Existing imports from stable.dart and stem.dart must remain source compatible.

2. Split publishing from consumption

Introduce a publish-only contract:

abstract interface class TaskPublisher {
  Future<void> publish(Envelope envelope, {RoutingInfo? routing});
}

QueueBroker should extend TaskPublisher and retain consume/ack/nack/close for long-lived broker workers. Stem and one-shot scheduling depend on TaskPublisher; the VM Worker depends on QueueBroker.

Resource closing should use a separate optional lifecycle capability so Cloudflare bindings do not need fake consumer or close methods.

3. Portable task processing

Extract envelope validation, handler resolution, argument decoding, execution middleware, handler invocation, timeout handling, and retry classification into a portable primitive (working name: TaskProcessor).

The processor must not:

  • consume broker streams;
  • acknowledge, negatively acknowledge, or dead-letter deliveries;
  • manage broker leases;
  • start isolates or processes;
  • install signal handlers;
  • start permanent timers.

It accepts an envelope plus runtime-provided execution controls and returns a typed semantic outcome. Outcomes must cover at least:

  • success;
  • retry, including delay and next-attempt metadata;
  • terminal failure;
  • rejected input such as unknown task, invalid signature, or invalid payload;
  • cancellation/expiry;
  • skipped terminal duplicate.

The existing VM Worker translates outcomes into its current persistence, acknowledgement, retry publication, group/chord, linked-task, lease, signal, and telemetry behaviour. Event-driven adapters translate each outcome to their platform's per-message acknowledgement API.

4. Portable task context

Separate the handler-facing task context and control callbacks from isolate transport.

Portable code may expose heartbeat, progress, cancellation, lease-extension, enqueue, and workflow capabilities through callbacks/interfaces. SendPort, isolate request/reply signals, and remote isolate context implementations remain VM-only.

5. Narrow persistence capabilities

Add narrow portable contracts such as:

  • TaskStatusStore;
  • AtomicTerminalResultStore;
  • GroupResultStore;
  • WorkerHeartbeatStore.

Keep ResultBackend as the compatibility facade for existing adapters. The portable processor should depend only on capabilities it actually uses.

6. One-shot scheduling

Extract portable one-shot scheduling into a ScheduleRunner.runOnce()-style primitive. The existing timer-based Beat facade may delegate to it while retaining current source compatibility.

7. Cloudflare integration

This enables Routed to provide:

  • a Cloudflare Queue producer implementing TaskPublisher;
  • a batch consumer adapter mapping native queue messages to envelopes and processor outcomes to per-message ack(), retry({delaySeconds}), or DLQ behaviour;
  • a scheduled-event adapter invoking one-shot scheduling;
  • D1/DO-backed ScheduleStore, LockStore, task-status storage, and optional task-claim storage.

Cloudflare's native message attempts value is one-based. The adapter must normalize it to Stem's zero-based attempt and pass it as an execution override because a native retry redelivers the same message body. Envelope.id remains stable across every attempt.

Cloudflare Queues are at-least-once. A stable task ID is exposed as an idempotency key, but Stem does not claim exactly-once handler execution. Existing terminal-status suppression prevents already-completed work from being repeated; concurrent cross-instance suppression requires an optional atomic TaskClaimStore or application-level idempotency.

Successful messages must be acknowledged independently so another failure in the batch does not redeliver completed messages:

Non-goals

  • Porting the full process/isolate worker lifecycle to Workers.
  • Making Cloudflare Workflows a required Stem backend.
  • Adding Cloudflare-specific types to the Stem portable package.
  • Replacing the existing Node/VM worker runtime.
  • Claiming exactly-once execution from an at-least-once queue.
  • Requiring every result backend to implement worker heartbeat, groups, or chords.

Acceptance criteria

  • A JS-targeted Dart package can import package:stem/portable.dart without resolving VM-only libraries.
  • A compile guard checks the complete portable import closure for dart:io, dart:isolate, and dart:ffi.
  • Stem producer/enqueue tests compile and run on a web/JS target.
  • A Cloudflare-style publisher implements only the publish contract; it does not provide fake consume/ack/nack methods.
  • The portable processor executes a registered handler and returns typed success, retry, terminal failure, rejection, cancellation, and duplicate outcomes without transport side effects.
  • Attempt overrides preserve the stable envelope ID while exposing the correct current and next attempt values.
  • A mixed batch can independently map successful, retried, and terminal messages to different platform dispositions.
  • Tests explicitly cover sequential duplicate delivery and document the limits of concurrent idempotency without an atomic claim store.
  • One-shot scheduling runs without starting a daemon timer.
  • Existing VM worker imports and behaviour remain source compatible.
  • The Cloudflare integration package uses portable contracts wherever it does not need the VM worker runtime.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions