Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/calm-ravens-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@typeonce/effect-machine": patch
---

Reduce pull request performance-check latency while preserving focused type,
runtime, and memory regression coverage.
6 changes: 3 additions & 3 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@

- [ ] `pnpm check`
- [ ] Relevant example checks, when examples changed
- [ ] Reviewed the automated type-performance report, when the public TypeScript API or inference changed
- [ ] Reviewed the automated runtime-performance report, when runtime behavior changed
- [ ] Automated type-performance measurement passed or was not required
- [ ] Automated runtime- and memory-performance measurement passed or was not required

<!--
The type- and runtime-performance workflows post and update their base-versus-PR comparisons automatically.
The type- and runtime-performance workflows measure only relevant changes and post their base-versus-PR comparisons automatically.
Do not copy a manually measured table into this description.
-->
21 changes: 16 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: CI

on:
pull_request:
push:
branches: [main]

concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
Expand Down Expand Up @@ -49,11 +47,18 @@ jobs:
discover-examples:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.examples.outputs.matrix }}
matrix: ${{ steps.examples.outputs.examples }}
required: ${{ steps.examples.outputs.examples_required }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: examples
run: echo "matrix=$(node scripts/list-examples.mjs)" >> "$GITHUB_OUTPUT"
run: >-
node scripts/ci-changes.mjs
--base ${{ github.event.pull_request.base.sha }}
--head ${{ github.event.pull_request.head.sha }}
--github-output "$GITHUB_OUTPUT"

example:
needs: discover-examples
Expand Down Expand Up @@ -92,7 +97,13 @@ jobs:
- name: Require every example check to pass
env:
DISCOVERY_RESULT: ${{ needs.discover-examples.result }}
EXAMPLES_REQUIRED: ${{ needs.discover-examples.outputs.required }}
EXAMPLE_RESULT: ${{ needs.example.result }}
run: |
test "$DISCOVERY_RESULT" = "success"
test "$EXAMPLE_RESULT" = "success"
if test "$EXAMPLES_REQUIRED" = "true"; then
test "$EXAMPLE_RESULT" = "success"
else
test "$EXAMPLE_RESULT" = "skipped"
echo "Examples are not affected by this pull request." >> "$GITHUB_STEP_SUMMARY"
fi
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ name: Release
on:
push:
branches: [main]
paths:
- ".changeset/**"
- "package.json"
- "pnpm-lock.yaml"
- "src/**"

concurrency:
group: release-${{ github.ref }}
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/runtime-performance-comment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ jobs:
comment:
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
github.event.workflow_run.conclusion != 'cancelled'
runs-on: ubuntu-latest
steps:
- name: Check out trusted reporting code
uses: actions/checkout@v7

- name: Download performance report
id: download
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: runtime-performance-report
Expand All @@ -34,13 +36,15 @@ jobs:
run-id: ${{ github.event.workflow_run.id }}

- name: Render report from validated benchmark data
if: steps.download.outcome == 'success'
run: >-
node scripts/compare-runtime-performance.mjs
report/base
report/head
> "$RUNNER_TEMP/report.md"

- name: Create or update pull request comment
if: steps.download.outcome == 'success'
uses: actions/github-script@v9
env:
REPORT_PATH: ${{ runner.temp }}/report.md
Expand Down
55 changes: 49 additions & 6 deletions .github/workflows/runtime-performance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,25 @@ permissions:
contents: read

jobs:
runtime-performance:
name: runtime-performance
changes:
runs-on: ubuntu-latest
outputs:
required: ${{ steps.classify.outputs.runtime_performance }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: classify
run: >-
node scripts/ci-changes.mjs
--base ${{ github.event.pull_request.base.sha }}
--head ${{ github.event.pull_request.head.sha }}
--github-output "$GITHUB_OUTPUT"

measure:
needs: changes
if: needs.changes.outputs.required == 'true'
name: runtime-performance measurement
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
Expand Down Expand Up @@ -52,6 +69,7 @@ jobs:
pnpm --dir base build

- name: Measure base and pull request
id: benchmark
shell: bash
run: |
set -euo pipefail
Expand Down Expand Up @@ -79,16 +97,41 @@ jobs:
measure base "$reports/base/5.json"
measure head "$reports/head/5.json"

node head/scripts/compare-runtime-performance.mjs \
"$reports/base" \
"$reports/head" \
> "$RUNNER_TEMP/runtime-performance-report.md"
if ! node head/scripts/compare-runtime-performance.mjs \
--check \
"$reports/base" \
"$reports/head" \
> "$RUNNER_TEMP/runtime-performance-report.md"; then
cat "$RUNNER_TEMP/runtime-performance-report.md" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
cat "$RUNNER_TEMP/runtime-performance-report.md" >> "$GITHUB_STEP_SUMMARY"

- name: Upload report for the comment workflow
if: always() && steps.benchmark.outcome != 'skipped'
uses: actions/upload-artifact@v7
with:
name: runtime-performance-report
path: ${{ runner.temp }}/runtime-performance
if-no-files-found: error
retention-days: 7

runtime-performance:
if: always()
needs: [changes, measure]
name: runtime-performance
runs-on: ubuntu-latest
steps:
- name: Require relevant runtime performance to pass
env:
CHANGE_RESULT: ${{ needs.changes.result }}
MEASUREMENT_REQUIRED: ${{ needs.changes.outputs.required }}
MEASUREMENT_RESULT: ${{ needs.measure.result }}
run: |
test "$CHANGE_RESULT" = "success"
if test "$MEASUREMENT_REQUIRED" = "true"; then
test "$MEASUREMENT_RESULT" = "success"
else
test "$MEASUREMENT_RESULT" = "skipped"
echo "Runtime performance is not affected by this pull request." >> "$GITHUB_STEP_SUMMARY"
fi
4 changes: 4 additions & 0 deletions .github/workflows/type-performance-comment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
uses: actions/checkout@v7

- name: Download performance report
id: download
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: type-performance-report
Expand All @@ -34,13 +36,15 @@ jobs:
run-id: ${{ github.event.workflow_run.id }}

- name: Render report from validated benchmark data
if: steps.download.outcome == 'success'
run: >-
node scripts/compare-type-performance.mjs
report/before.json
report/after.json
> "$RUNNER_TEMP/report.md"

- name: Create or update pull request comment
if: steps.download.outcome == 'success'
uses: actions/github-script@v9
env:
REPORT_PATH: ${{ runner.temp }}/report.md
Expand Down
41 changes: 39 additions & 2 deletions .github/workflows/type-performance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,25 @@ permissions:
contents: read

jobs:
type-performance:
name: type-performance
changes:
runs-on: ubuntu-latest
outputs:
required: ${{ steps.classify.outputs.type_performance }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: classify
run: >-
node scripts/ci-changes.mjs
--base ${{ github.event.pull_request.base.sha }}
--head ${{ github.event.pull_request.head.sha }}
--github-output "$GITHUB_OUTPUT"

measure:
needs: changes
if: needs.changes.outputs.required == 'true'
name: type-performance measurement
runs-on: ubuntu-latest
steps:
- name: Check out base
Expand Down Expand Up @@ -66,3 +83,23 @@ jobs:
${{ runner.temp }}/after.json
if-no-files-found: error
retention-days: 7

type-performance:
if: always()
needs: [changes, measure]
name: type-performance
runs-on: ubuntu-latest
steps:
- name: Require relevant type performance to pass
env:
CHANGE_RESULT: ${{ needs.changes.result }}
MEASUREMENT_REQUIRED: ${{ needs.changes.outputs.required }}
MEASUREMENT_RESULT: ${{ needs.measure.result }}
run: |
test "$CHANGE_RESULT" = "success"
if test "$MEASUREMENT_REQUIRED" = "true"; then
test "$MEASUREMENT_RESULT" = "success"
else
test "$MEASUREMENT_RESULT" = "skipped"
echo "Type performance is not affected by this pull request." >> "$GITHUB_STEP_SUMMARY"
fi
7 changes: 3 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"test": "vitest run",
"test:types": "tstyche",
"check:architecture": "node --test scripts/check-architecture.test.mjs && node scripts/check-architecture.mjs",
"check:ci": "node --test scripts/ci-changes.test.mjs scripts/runtime-performance-regression.test.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"perf:types": "pnpm build && node scripts/type-performance.mjs",
"perf:runtime": "pnpm build && node --expose-gc scripts/runtime-performance.mjs",
Expand All @@ -63,7 +64,7 @@
"docs:site:serve": "node scripts/api-reference-site/serve.mjs",
"test:consumer": "node scripts/test-consumer.mjs",
"pack:check": "node scripts/pack-check.mjs",
"check": "pnpm format:check && pnpm check:architecture && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check",
"check": "pnpm format:check && pnpm check:architecture && pnpm check:ci && pnpm docs:api:check && pnpm docs:site:check && pnpm typecheck && pnpm build && pnpm test && pnpm test:types && pnpm test:consumer && pnpm pack:check",
"changeset": "changeset",
"version-packages": "changeset version",
"release": "pnpm build && changeset publish"
Expand All @@ -82,9 +83,7 @@
"tstyche": "7.2.1",
"typedoc": "0.28.20",
"typescript": "6.0.3",
"vitest": "4.1.10",
"xstate-v5": "npm:xstate@5.32.5",
"xstate-v6": "npm:xstate@6.0.0-alpha.36"
"vitest": "4.1.10"
},
"packageManager": "pnpm@10.17.1",
"engines": {
Expand Down
66 changes: 18 additions & 48 deletions perf/runtime/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Runtime performance

Run the local runtime benchmark suite against the compiled package and the
pinned XState comparison versions:
Run the local runtime benchmark suite against the compiled package:

```sh
pnpm perf:runtime
Expand All @@ -15,64 +14,33 @@ The command reports:
- repeated child lookup and delivery to one running child;
- machine start-and-stop throughput;
- parent-with-child start-and-stop throughput;
- Effect-only lifecycle reference points for a suspended fiber, a queue worker,
a minimal actor shell, and a two-shell family;
- Effect-only coordination reference points for an owner-only mutable snapshot,
a synchronized snapshot, and a terminal `Deferred` latch;
- generic and compiled raw-process lifecycle throughput;
- heap and resident-memory growth at 100, 500, and 1,000 live units, including
a raw generic process, a raw compiled process, an idle statechart, two
independent statecharts, a parent with one child, that relationship with
child-registry observation active, and an invoked child whose active snapshots
are observed;
- lower-bound memory profiles for Effect itself: a suspended fiber, a queue
with a waiting fiber, a minimal mailbox/state/completion actor shell, and a
minimal two-shell family.

The comparison dependencies use package aliases, so XState 5 and 6 can be
loaded by the same process:

- `xstate-v5`: `xstate@5.32.5`, the stable v5 baseline;
- `xstate-v6`: `xstate@6.0.0-alpha.31`, the latest published v6 alpha available
when the harness was added.

All implementations use the same flat counter topology, immutable events, and
terminal fence. The XState adapter uses `assign` in v5 and the v6 transition
function API because `assign` is not exported by that alpha.
are observed.

The burst benchmark reports useful counter increments per second. It enqueues
one final fence event after all counter events and awaits the machine's terminal
output, so the measured duration also amortizes that fence and terminal
cleanup. This measures complete queue drainage, not only the enqueue time
returned by `MachineRef.send`.

Results are informational. Compare runs on the same machine while it is idle,
using the same Node.js and dependency versions. Tinybench warms each scenario
before collecting samples, and the memory measurements force garbage
collection before every observation. Each implementation's memory curve runs
in a fresh child process so garbage from one library cannot distort another
library's baseline.

These scenarios compare observable work, not identical internals. Effect
Machine plans transitions synchronously and validates schema-backed state and
events, while its running machine provisions Effect queues, fibers,
synchronization, change publication, and child/invoke lifecycle machinery.
XState's counter is a smaller synchronous actor. Treat the comparison as an
application-level cost baseline, not a claim that the libraries provide the
same runtime guarantees.
Compare runs on the same machine while it is idle, using the same Node.js and
dependency versions. Tinybench warms each scenario before collecting samples,
and the memory measurements force garbage collection before every observation.
Each memory profile runs in a fresh child process so garbage from one profile
cannot distort another profile's baseline.

The fitted heap slope is the primary idle-capacity metric. Compare adjacent
profiles to attribute retained memory: raw process to idle statechart isolates
statechart machinery, two independent machines to parent-with-child isolates
relationship bookkeeping, while the two observed parent-child profiles isolate
registry and invoked-snapshot observation. Invoked snapshot mapping uses a
direct, state-scoped delivery path; its profile measures the retained callback
and mapping state rather than a general `changes` stream subscription. The
Effect profiles are primitive lower bounds, not feature-equivalent competitors.
The Effect throughput reference points similarly bound individual runtime
operations rather than predicting a complete machine by themselves. In
particular, the owner-only mutable snapshot is safe only when one process fiber
owns active state writes; terminal arbitration and externally visible
observation still require separate coordination.
and mapping state rather than a general `changes` stream subscription.

Resident memory is reported as a raw diagnostic because V8 and the
operating-system allocator can reuse already committed pages. The
capacity-per-GiB value is a linear estimate that excludes shared process
Expand Down Expand Up @@ -105,9 +73,11 @@ benchmark workflow has read-only repository access; a separate trusted
`workflow_run` workflow validates the uploaded JSON before receiving permission
to update the comment.

The implementation lives in `scripts/runtime-performance.mjs`; the Effect
Machine fixture is in `perf/runtime/counter.mjs`, and the comparison adapter is
in `perf/runtime/xstate.mjs`. Effect runtime reference fixtures are in
`perf/runtime/effect-runtime.mjs`. Add cross-library scenarios only when every
implementation performs equivalent observable work and the result is consumed
and checked so the JavaScript engine cannot discard it.
The required pull request check rejects throughput decreases above both 15%
and three times the observed process-level median absolute deviation. Heap per
unit uses the same variability rule with a 20% floor. RSS remains informational
because hosted-runner and allocator behavior makes it substantially noisier.

The implementation lives in `scripts/runtime-performance.mjs`, and the Effect
Machine fixture is in `perf/runtime/counter.mjs`. Every scenario consumes and
checks its result so the JavaScript engine cannot discard the measured work.
Loading