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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ tracing = { version = "0.1", features = ["std"] }
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter", "json"] }
semver = "1"

# Forensic hashing of oversized values (logged size + hash, never raw bytes;
# see docs/proposals/size-limits.md § 5.1 tier-3).
blake3 = "1"

# Metrics facade (always on, zero-cost if no recorder installed)
metrics = "0.24"

Expand Down
4 changes: 2 additions & 2 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

### Active TODOs

- Size limits
- ~~Size limits~~ *(implemented 2026-05-06)*
- RaiseEvent pub/sub
- **Stale activity cleanup / Activity TTL**
- Tagged activities that no matching worker picks up sit in the worker queue indefinitely
Expand Down Expand Up @@ -58,7 +58,7 @@
- move stress tests to cargo standard "bench"
- separate execution loops for orchestrations and activities, communicate through channels
- Port samples from DurableTasks and Temporal to tests/scenarios/
- Limits everywhere, orch/activity names, input/output event sizes, history sizes etc.
- ~~Limits everywhere, orch/activity names, input/output event sizes, history sizes etc.~~ *(implemented 2026-05-06)*
- LLM-orchestration/provider
- Revive batching from dispatcher-batching branch, currently the perf dropped drastically. Might've been a sqlite-only issue.
- Mgmt API feedback
Expand Down
92 changes: 87 additions & 5 deletions docs/ORCHESTRATION-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2561,7 +2561,8 @@ async fn order_actor(ctx: OrchestrationContext, state_json: String) -> Result<()

### 1. Minimize History Size

Each orchestration turn appends events. Keep history manageable:
Each orchestration turn appends events. Keep history manageable with
`continue_as_new()`. Use `ctx.history_pressure()` to roll over proactively:

```rust
// ❌ Bad - Creates millions of events
Expand All @@ -2574,18 +2575,23 @@ async fn bad_loop(ctx: OrchestrationContext, _input: String) -> Result<String, S
```

```rust
// ✅ Good - Use Continue As New to reset history
// ✅ Good - Roll over when pressure approaches the hard cap
async fn good_loop(ctx: OrchestrationContext, state_json: String) -> Result<String, String> {
let state: State = serde_json::from_str(&state_json)?;


// Proactively continue-as-new before hitting the 5 MiB history cap.
if ctx.history_pressure() >= 0.80 {
return ctx.continue_as_new(serde_json::to_string(&state)?).await;
}

// Process batch of 100
for i in 0..100 {
ctx.schedule_activity("Process", (state.offset + i).to_string())
.await?;
}

state.offset += 100;

if state.offset < state.total {
return ctx.continue_as_new(serde_json::to_string(&state)?).await; // Fresh history
} else {
Expand All @@ -2594,6 +2600,8 @@ async fn good_loop(ctx: OrchestrationContext, state_json: String) -> Result<Stri
}
```

See [Size Limits](#size-limits) for the full constant table and enforcement options.

### 2. Batch Operations

```rust
Expand All @@ -2616,6 +2624,80 @@ async fn fast_updates(ctx: OrchestrationContext, items_json: String) -> Result<(

---

## Size Limits

The runtime enforces hard caps on all values that pass through history or the provider.
These caps are `pub const` values in `src/runtime/limits.rs` — they are **not
configurable at runtime**.

| Constant | Value | Applies to |
|---|---|---|
| `MAX_HISTORY_BYTES` | 5 MiB | Total serialized history per orchestration execution |
| `MAX_PAYLOAD_BYTES` | 3 MiB | Activity inputs/outputs, orchestration inputs, sub-orch inputs/results, external event payloads, continue-as-new inputs |
| `MAX_SMALL_VALUE_BYTES` | 64 KiB | Names (activity, sub-orch, orchestration), identifiers (instance ID, event name, queue name), error strings, cancel reasons |
| `MAX_CUSTOM_STATUS_BYTES` | 64 KiB | Custom status strings |
| `MAX_KV_VALUE_BYTES` | 16 KiB | Individual KV store values |
| `MAX_KV_KEYS` | 100 | KV keys per instance |
| `MAX_TAG_NAME_BYTES` | 256 | Activity tag names |

### Enforcement is opt-in (two toggles)

Both toggles default to `false` so upgrades are safe and no existing orchestration
breaks without warning.

| `RuntimeOptions` field | Default | Effect when `true` |
|---|---|---|
| `enforce_size_limits` | `false` | Fails activities/turns that would exceed a cap |
| `emit_limit_exceeded_errors` | `false` | Produces `ConfigErrorKind::LimitExceeded` instead of `Application::OrchestrationFailed` |

```rust
let runtime = Runtime::builder(provider)
.with_options(RuntimeOptions {
enforce_size_limits: true,
emit_limit_exceeded_errors: true, // optional — cleaner error shape
..Default::default()
})
.build();
```

### Where enforcement happens

| Tier | When | What is checked |
|---|---|---|
| **Tier 1 — Client** | `start_orchestration`, `raise_event`, `enqueue_event`, `cancel_instance` | Instance ID, orch name, event name, queue name, input/payload sizes |
| **Tier 2 — Orchestration turn** | On ack, before persisting | Activity/sub-orch name & input, orch output, sub-orch result, CAN input, error strings; aggregate history cap pre-persist |
| **Tier 3 — Activity worker** | After activity returns | Activity output size; error strings truncated (never fail) |

When a tier-2 history-cap violation fires, the runtime **drops all side-effects**
(scheduled activities, timers, enqueued events) and writes only a terminal
`OrchestrationFailed` event, preventing the oversized data from ever reaching the
provider.

### Anticipating the history cap from orchestration code

Use `ctx.history_pressure()` (see [Performance Considerations](#1-minimize-history-size)
and [`docs/continue-as-new.md`](./continue-as-new.md#anticipating-the-history-limit))
to roll over before hitting the hard cap:

```rust
if ctx.history_pressure() >= 0.80 {
return ctx.continue_as_new(compact_state).await;
}
```

### Observability

Three metrics are always emitted (regardless of enforcement):

| Metric | Labels | Description |
|---|---|---|
| `duroxide_history_bytes` | `orchestration_name` | Serialized history size per turn (histogram) |
| `duroxide_payload_bytes` | `kind` | Per-payload-site byte histogram |
| `duroxide_limit_violations_total` | `resource` | Counter incremented on each violation |
| `duroxide_history_terminated_oversize_total` | `orchestration_name` | Counter when history cap terminates an instance |

---

## Common Gotchas

### 1. Non-Deterministic Control Flow
Expand Down
66 changes: 66 additions & 0 deletions docs/continue-as-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,69 @@ client.prune_executions("my-instance", PruneOptions {
- Pruning can be done while the workflow is actively running

**Note on `keep_last` semantics:** Since the current execution is always protected and is always the highest execution_id, `keep_last: None`, `Some(0)`, and `Some(1)` are all equivalent—they all prune down to exactly the current execution. Use `None` for clarity when you want to prune all historical executions.

## Anticipating the History Limit

Every event appended to an orchestration's history consumes space. The runtime hard-caps
history at **5 MiB** (`MAX_HISTORY_BYTES`). When an orchestration's persisted history
reaches that cap its next turn is terminated with an error — so it's better to roll over
before reaching the limit.

`OrchestrationContext` exposes two accessors for this purpose:

```rust
/// Serialized byte size of history at the start of this turn (replay-safe).
pub fn history_size_bytes(&self) -> usize

/// history_size_bytes() / MAX_HISTORY_BYTES, clamped to 0.0..=1.0.
pub fn history_pressure(&self) -> f32
```

Both values are set once per turn from the baseline history that existed when the turn
started. They are **deterministic across replay** — the replay engine sets them before
invoking the orchestration function, so every replay sees the same snapshot.

### Choosing a rollover threshold

A common pattern is to roll over at 80 % pressure, leaving a comfortable margin for the
events that will be appended during the current turn:

```rust
async fn eternal_monitor(ctx: OrchestrationContext, state_json: String) -> Result<String, String> {
let state: State = serde_json::from_str(&state_json).unwrap_or_default();

// Roll over proactively before the hard cap is hit.
if ctx.history_pressure() >= 0.80 {
let trimmed = serde_json::to_string(&state.trim_for_handoff()).unwrap();
return ctx.continue_as_new(trimmed).await;
}

// Normal turn logic ...
let result = ctx.schedule_activity("DoWork", state_json.clone()).await?;
ctx.schedule_timer(std::time::Duration::from_secs(60)).await;
return ctx.continue_as_new(result).await;
}
```

### Enforcement and error shape (opt-in)

By default the hard cap is **observed but not enforced** — the accessors are always
available but no turn is failed purely because of history size. Two `RuntimeOptions`
toggles control enforcement:

| Toggle | Default | Effect |
|---|---|---|
| `enforce_size_limits` | `false` | When `true`, terminates turns that would exceed 5 MiB |
| `emit_limit_exceeded_errors` | `false` | When `true`, failed turns produce `ConfigErrorKind::LimitExceeded` instead of `Application::OrchestrationFailed` |

Enable enforcement once you're confident your orchestrations roll over in time:

```rust
let runtime = Runtime::builder(provider)
.with_options(RuntimeOptions {
enforce_size_limits: true,
emit_limit_exceeded_errors: true,
..Default::default()
})
.build();
```
147 changes: 147 additions & 0 deletions docs/proposals-impl/PROGRESS-size-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Size & Shape Limits — Implementation Progress

**Spec:** [proposals/size-limits.md](../proposals/size-limits.md)
**Status:** In progress
**Last updated:** 2026-05-06

---

## Summary

Implements three new hardcoded size limits (`MAX_HISTORY_BYTES = 5 MiB`,
`MAX_PAYLOAD_BYTES = 3 MiB`, `MAX_SMALL_VALUE_BYTES = 64 KiB`), a deterministic
`OrchestrationContext::history_size_bytes()` accessor, observability metrics,
and a `ConfigErrorKind::LimitExceeded` error variant. Enforcement and error
shape are gated by two `RuntimeOptions` toggles
(`enforce_size_limits`, `emit_limit_exceeded_errors`), both defaulting `false`
in the introducing release so upgrades preserve today's behavior.

---

## Implementation Slices

Each slice is a logical unit; multiple slices may land in a single PR.

- [x] **Slice 1 — Constants + RuntimeOptions toggles**
- `MAX_HISTORY_BYTES`, `MAX_PAYLOAD_BYTES`, `MAX_SMALL_VALUE_BYTES`
in `src/runtime/limits.rs`
- `RuntimeOptions::enforce_size_limits: bool` (default `false`)
- `RuntimeOptions::emit_limit_exceeded_errors: bool` (default `false`)
- No behavior change yet; constants/fields purely additive.

- [x] **Slice 2 — `HistoryManager::total_history_bytes` counter**
- Maintain `O(1)` running total, updated on append and during replay.
- Cache per-event serialized size on the in-memory `Event` record (or in
a parallel `Vec<u32>`) so the increment is `O(1)`.
- Always-on; foundational for slices 3 and 6.

- [x] **Slice 3 — `OrchestrationContext` accessors**
- `history_size_bytes() -> usize` reads the value computed at this
turn boundary (excluding in-flight delta).
- `history_pressure() -> f32` returns `bytes / MAX_HISTORY_BYTES`
clamped to `0.0..=1.0`.
- Always-on; deterministic across replay.

- [x] **Slice 4 — `ConfigErrorKind::LimitExceeded` variant**
- Add the variant to `src/lib.rs`.
- Add `display_message` arm.
- Update `fail_orchestration_for_limit()` (or equivalent) in
`src/runtime/dispatchers/orchestration.rs` to choose the error shape
based on `RuntimeOptions::emit_limit_exceeded_errors`.
- Pre-existing limit failures (`MAX_CUSTOM_STATUS_BYTES`,
`MAX_KV_VALUE_BYTES`, `MAX_KV_KEYS`, `MAX_TAG_NAME_BYTES`) continue
to emit `Application` when toggle is off (today's shape preserved).

- [x] **Slice 5 — Tier-1 client checks**
- `Client::start_orchestration*`, `raise_event`, `enqueue_event*`,
`cancel_instance`: validate sizes and return
`Err(ClientError::Configuration(...))` when `enforce_size_limits` is on.
- Identifier checks (instance ID, orchestration name, event name,
queue name, cancel reason) use `MAX_SMALL_VALUE_BYTES`.
- Payload checks (start input, raise event payload, queue message)
use `MAX_PAYLOAD_BYTES` / `MAX_SMALL_VALUE_BYTES` per §4.1 mapping.

- [x] **Slice 6 — Tier-2 `validate_limits()` extensions**
- Per-event checks for activity input, sub-orch input, sub-orch name,
activity name, session ID, continue-as-new input, orchestration
completion output, sub-orch result, orchestration error string.
- Aggregate history-cap check: pre-persist comparison of
`total_history_bytes + sum(serialized_size(delta))` against
`MAX_HISTORY_BYTES`. On violation, drop delta in memory, ack with
minimal terminal `OrchestrationFailed` event, drop side effects.
- All gated by `enforce_size_limits`.

- [x] **Slice 7 — Tier-3 worker output check**
- Activity output size check before enqueueing `ActivityCompleted`.
- On violation, enqueue `ActivityFailed { details }` (shape per
`emit_limit_exceeded_errors`) and WARN-log size + BLAKE3 hash.
- Error string also bounded to `MAX_SMALL_VALUE_BYTES`; truncate
rather than fail.
- Add `blake3` dependency.

- [x] **Slice 8 — Metrics + WARN logs**
- `duroxide.history.bytes` (always emitted, gauge per-instance on ack).
- `duroxide.payload.bytes { kind }` (always emitted, histogram per
payload site).
- `duroxide.limit.violations { resource }` (emitted on tier-1/2/3 fail).
- `duroxide.history.terminated_oversize { orchestration_name }`
(emitted when an instance terminates with `resource = "history"`).
- 75 % / 90 % WARN log thresholds, once per execution per threshold.

- [x] **Slice 9 — Tests**
- `tests/scenarios/limits.rs` covering all 4 toggle combinations
and every test row in spec §8.
- Existing test updates where assertions on the `Application` shape
break under `(enforce=true, emit_le=true)` (these test the new
`Configuration::LimitExceeded` shape; old shape covered by
`existing_limit_failures_keep_application_when_emit_off`).

- [x] **Slice 10 — Docs**
- `docs/continue-as-new.md` — new "Anticipating the history limit"
section with `history_pressure()` worked example.
- `docs/ORCHESTRATION-GUIDE.md` — refresh limits section, document
both toggles.
- `docs/proposals/core-improvements-roadmap.md` — mark §8 superseded.
- `TODO.md` — strike "Size limits" / "Limits everywhere…" lines.

- [ ] **Slice 11 — Release housekeeping** *(at release time, not now)*
- `git mv docs/proposals/size-limits.md docs/proposals-impl/`
- Delete this PROGRESS file.
- Add `### Added` changelog entry; **no `### Breaking Changes`**
entry (defaults preserve today's behavior).

---

## Implementation Notes

### Slice 1

- Cargo is not installed in this development environment; build validation
was done via the language server (no errors reported in `limits.rs` or
`runtime/mod.rs`). User should run `cargo build --all-targets
--all-features` and `cargo nt` before merging.
- The proposal originally claimed there was a magic number `20` in
`prep_completions()` to be promoted to a `MAX_PENDING_EXTERNAL_EVENTS`
constant. Code inspection found no such constant — the only related cap
is `MAX_CARRY_FORWARD_EVENTS = 100`, which is already named. Spec was
corrected to drop that claim.

### Slices 2–9

- Implemented and 1092/1092 tests pass (`cargo nt`, 2026-05-06).
- `blake3 = "1"` added to `Cargo.toml` for tier-3 forensic hashing.
- `HistoryManager` tracks `history_bytes` (baseline) and `delta_bytes`
(incremental) using `serialized_event_size()` (serde_json byte length).
- `OrchestrationContext` exposes `history_size_bytes()` and
`history_pressure()` as always-on, deterministic accessors. The value
is set once per turn from the working history before the orchestration
function runs, so it is stable and replay-safe within a turn.
- `ConfigErrorKind::LimitExceeded` is additive; `emit_limit_exceeded_errors`
defaults `false`, preserving the `Application` shape for all existing callers.
- Tier-2 `check_tier2_size_limits()` drops the in-memory delta before acking
a terminal failure event, which required clearing `metadata.pinned_duroxide_version`
to avoid a version-pinning invariant assertion when `OrchestrationStarted`
was no longer present in the delta.
- Metrics are always emitted regardless of `enforce_size_limits`; enforcement
only gates fail/truncate behavior.

11 changes: 10 additions & 1 deletion docs/proposals/core-improvements-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,16 @@ CREATE TABLE registered_activities (

## 8. Event Size Limits

### Problem
> **Status: Superseded — implemented in `docs/proposals/size-limits.md` (2026-05-06).**
>
> The final design differs from the sketch below: limits are hardcoded constants
> (not configurable per-runtime), enforcement is gated by two `RuntimeOptions` toggles
> (`enforce_size_limits`, `emit_limit_exceeded_errors`), and the error type uses
> `ConfigErrorKind::LimitExceeded` rather than a custom `SizeLimitError` enum.
> See [`docs/ORCHESTRATION-GUIDE.md#size-limits`](../ORCHESTRATION-GUIDE.md#size-limits)
> for the authoritative reference.

### Problem (historical)

Currently, there's no enforcement on the size of individual events. Large payloads in activity inputs/outputs, orchestration inputs, or external events can:
- Cause memory pressure during replay (all events loaded into memory)
Expand Down
Loading
Loading