Skip to content

perf(ci): cache everything, unserialize typecheck, fan out the archive probes - #286

Merged
Makisuo merged 5 commits into
mainfrom
perf/ci-speedup
Jul 30, 2026
Merged

perf(ci): cache everything, unserialize typecheck, fan out the archive probes#286
Makisuo merged 5 commits into
mainfrom
perf/ci-speedup

Conversation

@Makisuo

@Makisuo Makisuo commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Why

CI was 7–22 min per PR. The turbo step reported this on every single run:

Tasks:    59 successful, 59 total
Cached:    0 cached, 59 total
Time:    6m1.346s

There was no actions/cache anywhere in .github/workflows/ except Swatinem/rust-cache. So .turbo/cache died with the runner, bun install ran cold in ~13 job executions per push, and Playwright re-downloaded three browser engines every run.

Measured baseline (gh run view --json jobs):

Job Time Runner
Local archive native 7m09s (worst 21m30s) ubuntu-latest
TypeScript 6m44s (worst 10m09s) blacksmith-4vcpu
Service Map Perf 4m13s blacksmith-4vcpu

What changed

Caching.turbo/cache, the bun module store (new .github/actions/bun-install composite), and Playwright browsers. Blacksmith routes actions/cache to its own colocated backend, so no vendor-specific action is needed. install-deps stays unconditional: apt paths aren't covered by ~/.cache/ms-playwright, so the combined --with-deps form would skip them on a cache hit and break webkit.

Turbo graph — dropped ^typecheck from typecheck.dependsOn. No tsconfig here uses composite/references and every typecheck is tsc --noEmit, which emits nothing downstream — so the edge was ordering-only and serialized a topological wave across ~26 packages. ^build is the real edge and stays. TypeScript job moved to 8 vCPU with --concurrency=10; 15-way concurrency on 4 cores was thrash plus OOM risk once the DAG flattens.

Archive probes — 7 sequential probes on ubuntu-latest → a 7-way matrix on Blacksmith, timeout 90 → 20 min. Each leg rebuilds the bundle rather than sharing one via artifact: dist/ is ~403 MB and build-local-binary.sh only takes ~14s.

Path filteringdorny/paths-filter gates the CLI probes, perf, ClickHouse E2E and slack-agent. Knip stays unfiltered (whole-repo dead-code check). A CI passed aggregator collapses the fan-in, since a skipped job never reports a status.

Correctness fixes found along the way

  • deploy-prd/deploy-stg fired on push: main with their own concurrency groups, racing ci.ymla red CI still shipped to production. Both now gate on workflow_run of CI with conclusion == 'success', pinned to head_sha.
  • ci.yml never typechecked tsconfig.alchemy.json (every apps/*/alchemy.run.ts) — only the root typecheck script chains it, and CI calls turbo directly.
  • The Rust base-branch build ran in a $RUNNER_TEMP worktree, outside rust-cache's workspaces: apps/ingest scope, so it cold-compiled every dependency on every PR.
  • 14 of 17 workflows had no timeout-minutes, including all three deploys.

Verifying

The second run on this branch is the one that matters — the first only populates the cache. Watch for Cached: 0 cached flipping to a high hit count, and confirm Web bundle budget still passes against a restored apps/web/dist.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Makisuo added 3 commits July 31, 2026 00:56
…robes

CI was 7-22 min per PR with zero caching anywhere. The turbo step reported
'Cached: 0 cached, 59 total' on every single run.

- turbo.json: drop `^typecheck` from typecheck.dependsOn. No tsconfig here uses
  composite/references and every typecheck is `tsc --noEmit`, so the edge was
  ordering-only and serialized a topological wave across ~26 packages.
- Cache .turbo/cache and the bun module store (new .github/actions/bun-install
  composite). Blacksmith routes actions/cache to its own colocated backend, so
  no vendor-specific action is needed.
- Cache Playwright browsers, keyed on the resolved version. install-deps stays
  unconditional — apt paths aren't covered by ~/.cache/ms-playwright, so the
  combined --with-deps form would skip them on a cache hit and break webkit.
- TypeScript job to 8 vCPU with --concurrency=10; the job is now a wide fan
  rather than a chain, and 15-way concurrency on 4 cores was thrash + OOM risk.
- local-archive-native: 7 sequential probes on ubuntu-latest -> a 7-way matrix
  on Blacksmith, timeout 90 -> 20. Each leg rebuilds the bundle rather than
  sharing one via artifact: dist/ is ~403MB and the build only takes ~14s.
- Typecheck tsconfig.alchemy.json, which CI never covered.
- timeout-minutes on every job.
…ry job

- deploy-prd/deploy-stg triggered on `push: main` with their own concurrency
  groups, racing ci.yml — a red CI still shipped to production. Both now trigger
  on workflow_run of CI and require conclusion == 'success'. Everything pins
  workflow_run.head_sha, since github.sha on that event is the branch head rather
  than the commit CI ran against; workflow_dispatch keeps working as the manual
  escape hatch.
- ingest-rust-tests: the base-branch comparison build ran in a $RUNNER_TEMP
  worktree, outside Swatinem/rust-cache's `workspaces: apps/ingest` scope, so it
  cold-compiled every dependency on every PR. Point it at the head's target dir,
  which the cache does cover. Head binaries are already copied out beforehand.
- cargo test/build now pass --locked; Cargo.lock is committed but was never
  enforced (verified in sync via `cargo metadata --locked`).
- timeout-minutes on all remaining jobs — 14 of 17 workflows ran on the 6h
  default, including all three deploys.
Every job ran on every PR, so a CSS-only change still paid ~7 min of native
archive probes and a full ClickHouse E2E matrix.

A `changes` job (dorny/paths-filter) gates the native CLI probes, service-map
perf, ClickHouse E2E, slack-agent and the TypeScript job. Knip stays unfiltered
because it is a whole-repo dead-code check and a filter would make it unsound.
The slack filter deliberately includes apps/api — the approval-list drift canary
fails when a mutating MCP tool lands on the API side without being mirrored.

All filter outputs are forced true on push to main: paths-filter compares against
the previous commit on push, which is the wrong base for a merge commit.

Because a skipped job never reports a status, individual jobs are not safe to use
as required checks once filtered. Added a 'CI passed' aggregator that always runs
and fails only on failure/cancelled — skipped is intentionally not a failure.
main currently has no branch protection or rulesets, so nothing needs changing
today; if protection is ever added, require exactly 'CI passed'.

Also added .github/actionlint.yaml declaring the Blacksmith runner labels, so
actionlint reports real findings instead of an unknown-label per runs-on.
@pullfrog

pullfrog Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Ingest Rust Test + Benchmark Results

Commit: 5750effd256acc37e0f8706b33968c5776bc9648

Load Benchmark — tinybird mode, median of 3 run(s) vs main

Metric main (median) PR (median) Delta
Requests/sec 7478.83 11493.86 +53.7% better
Rows/sec 74788.33 114938.56 +53.7% better
p50 latency 8.12 ms 5.30 ms -34.8% better
p95 latency 16.20 ms 10.46 ms -35.4% better
p99 latency 21.33 ms 15.14 ms -29.0% better
Export catch-up 0.026 s 0.026 s -0.3% better
Max RSS 15.29 MiB 16.24 MiB +6.2% worse
Failures 0 0 same

Same code path on both sides (same LOAD_TEST_INGEST_MODE), so the delta column is meaningful. Numbers come from ubuntu-latest, which is noisy — treat single-digit-percent deltas as noise.

PR load benchmark JSON (per-iteration)
[
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 13,
    "duration_seconds": 0.220816483,
    "export_catchup_seconds": 0.02612211,
    "request_rps": 9057.294875944564,
    "row_rps": 90572.94875944564,
    "p50_ms": 6.764,
    "p95_ms": 13.713,
    "p99_ms": 19.491,
    "max_rss_mb": 20.28125,
    "max_cpu_percent": 20.0,
    "avg_cpu_percent": 20.0
  },
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 11,
    "duration_seconds": 0.174006011,
    "export_catchup_seconds": 0.025639989,
    "request_rps": 11493.855807084734,
    "row_rps": 114938.55807084734,
    "p50_ms": 5.3,
    "p95_ms": 10.458,
    "p99_ms": 15.14,
    "max_rss_mb": 16.23828125,
    "max_cpu_percent": 16.6,
    "avg_cpu_percent": 16.6
  },
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 11,
    "duration_seconds": 0.160514972,
    "export_catchup_seconds": 0.026285547,
    "request_rps": 12459.896887375715,
    "row_rps": 124598.96887375714,
    "p50_ms": 4.913,
    "p95_ms": 9.65,
    "p99_ms": 10.726,
    "max_rss_mb": 14.953125,
    "max_cpu_percent": 16.6,
    "avg_cpu_percent": 16.6
  }
]
main load benchmark JSON (per-iteration)
[
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 13,
    "duration_seconds": 0.268456025,
    "export_catchup_seconds": 0.026317507,
    "request_rps": 7450.009736231475,
    "row_rps": 74500.09736231474,
    "p50_ms": 8.249,
    "p95_ms": 16.338,
    "p99_ms": 23.855,
    "max_rss_mb": 15.29296875,
    "max_cpu_percent": 16.6,
    "avg_cpu_percent": 16.6
  },
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 13,
    "duration_seconds": 0.259783152,
    "export_catchup_seconds": 0.026190088,
    "request_rps": 7698.7286689015145,
    "row_rps": 76987.28668901515,
    "p50_ms": 7.838,
    "p95_ms": 15.597,
    "p99_ms": 21.329,
    "max_rss_mb": 14.87890625,
    "max_cpu_percent": 40.0,
    "avg_cpu_percent": 40.0
  },
  {
    "ingest_mode": "tinybird",
    "requests": 2000,
    "successes": 2000,
    "failures": 0,
    "rows_sent": 20000,
    "rows_exported": 20000,
    "imports": 13,
    "duration_seconds": 0.267421419,
    "export_catchup_seconds": 0.025926635,
    "request_rps": 7478.832501445967,
    "row_rps": 74788.32501445967,
    "p50_ms": 8.123,
    "p95_ms": 16.197,
    "p99_ms": 18.733,
    "max_rss_mb": 15.58203125,
    "max_cpu_percent": 33.3,
    "avg_cpu_percent": 33.3
  }
]

WAL-acked microbench (cargo bench --bench ingest_bench)

   Compiling maple-ingest v0.1.0 (/home/runner/_work/maple/maple/apps/ingest)
    Finished `bench` profile [optimized] target(s) in 17.19s
     Running benches/ingest_bench.rs (target/release/deps/ingest_bench-f6ec3881b8bd23cb)
Gnuplot not found, using plotters backend
test ingest_accept/logs_10_rows_wal_ack ... bench:      118853 ns/iter (+/- 23773)
test ingest_accept/traces_10_spans_wal_ack ... bench:      162639 ns/iter (+/- 40786)

cargo test


test result: ok. 66 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.74s

     Running unittests src/bin/load_test.rs (target/debug/deps/load_test-3a24fd5dee8f57f0)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/maple_ingest-b89fa52019d5d48f)

running 60 tests
test autumn::tests::allowed_only_no_balance_field ... ok
test autumn::tests::flat_hardcap_depleted_blocks ... ok
test autumn::tests::flat_hardcap_with_remaining_allows ... ok
test autumn::tests::flat_sub_one_gb_remaining_still_allows ... ok
test autumn::tests::flat_overage_allows ... ok
test autumn::tests::flat_unlimited_allows ... ok
test autumn::tests::nested_balance_object_with_remaining_allows ... ok
test autumn::tests::nested_balance_object_depleted_blocks ... ok
test autumn::tests::nested_overage_allows ... ok
test autumn::tests::null_balance_no_subscription_blocks ... ok
test autumn::tests::unrecognized_shape_returns_none ... ok
test tests::api_error_from_pipeline_maps_variants_to_status ... ok
test tests::a_browser_sessions_cap_blocks_replay_without_touching_other_signals ... ok
test tests::caller_fault_rejections_do_not_mark_the_span_error ... ok
test tests::api_error_kind_maps_status_to_stable_label ... ok
test tests::clickhouse_destination_is_terminal_in_dual_mode ... ok
test tests::clickhouse_destination_uses_native_pipeline_even_in_forward_mode ... ok
test tests::clickhouse_target_resolver_rejects_password_over_http ... ok
test tests::clickhouse_target_resolver_decrypts_current_schema_password ... ok
test tests::clickhouse_target_resolver_requires_current_schema ... ok
test tests::cloudflare_ndjson_payload_parses_multiple_records ... ok
test tests::cloudflare_timestamps_support_rfc3339_unix_and_unix_nano ... ok
test tests::cloudflare_log_record_maps_body_severity_and_attributes ... ok
test tests::cloudflare_validation_payload_is_detected ... ok
test tests::data_loss_labels_match_the_kinds_the_gateway_emits ... ok
test tests::data_loss_rejections_mark_the_span_error_even_at_4xx ... ok
test tests::empty_export_request_is_accepted ... ok
test tests::enrichment_applies_to_a_payload_with_no_resource ... ok
test tests::enrichment_overwrites_tenant_fields ... ok
test tests::extract_ingest_key_returns_sentinel_literal_unchanged ... ok
test tests::decrypt_aes256_gcm_matches_node_crypto_fixture ... ok
test tests::hash_is_deterministic ... ok
test tests::lenient_log_payload_is_accepted ... ok
test tests::malformed_payload_is_rejected_with_the_parser_reason ... ok
test autumn::tests::fail_open_decisions_are_not_cached ... ok
test tests::numeric_nanos_no_longer_silently_empty_a_metric ... ok
test tests::pipeline_error_fault_split_matches_http_status ... ok
test tests::rejection_reason_is_bounded ... ok
test tests::replay_budget_disabled_when_limit_is_zero ... ok
test tests::replay_budget_scopes_totals_per_session_and_org ... ok
test tests::replay_budget_truncates_session_at_its_ceiling ... ok
test autumn::tests::fails_open_on_transport_error ... ok
test tests::resolve_connector_refreshes_routing_before_auth_cache_expires ... ok
test tests::resolve_ingest_key_carries_spend_pause_state_from_the_key_lookup ... ok
test tests::resolve_ingest_key_keeps_stale_schema_on_managed_native_path ... ok
test tests::replay_metadata_is_rejected_and_unmetered_when_sessions_are_capped ... ok
test tests::native_request_emits_a_span_per_pipeline_stage ... ok
test tests::resolve_ingest_key_returns_none_when_hash_missing ... ok
test tests::resolve_ingest_key_returns_self_managed_true_when_active_settings_row ... ok
test tests::resolve_ingest_key_returns_self_managed_false_when_no_settings_row ... ok
test tests::sentinel_token_matches_only_exact_literal ... ok
test tests::server_faults_stay_error ... ok
test tests::spec_compliant_payloads_still_decode ... ok
test tests::spend_blocks_signal_pauses_the_whole_org_or_just_the_capped_signal ... ok
test tests::tinybird_destination_keeps_forward_mode_on_forward_path ... ok
test tests::resolve_ingest_key_refreshes_routing_before_auth_cache_expires ... ok
test tests::resolve_ingest_key_serves_last_known_routing_when_refresh_fails ... ok
test tests::forward_mode_switches_ready_org_to_clickhouse_without_forwarding_again ... ok
test autumn::tests::usage_after_a_successful_flush_gets_a_fresh_key ... ok
test autumn::tests::a_failed_flush_retries_under_the_same_idempotency_key ... ok

test result: ok. 60 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.03s

   Doc-tests maple_ingest

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit 2697034 · View workflow run

Caught by the new tsconfig.alchemy.json check in CI, which had never run before.

CreateAlertingWorkerOptions declared `chatFlue: { workerName: string }`, but
createChatFlueWorker returns a not-yet-applied worker resource whose workerName
is `Output<string, never>` — resolved at apply time by the service binding. The
root alchemy.run.ts passed exactly that value, so the config only typechecked by
never being typechecked.
@pullfrog

pullfrog Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

…stall

~/.bun/install/cache is ~739MB for this repo. Restoring and re-saving it costs
more than bun spends fetching packages:

  knip install                34s cold -> 57s cached
  local-checkpoint-native     44s cold -> 113s cached

Keep the composite action as the single place install behaviour lives, with the
measurement recorded so this does not get re-added on intuition. The turbo cache
is unaffected and is where the actual win is (59/59 hits, 6m01s -> 2.2s).
@pullfrog

pullfrog Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@Makisuo

Makisuo commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

Measured results

Verified across 4 runs on this branch. Numbers are the full suite — this PR touches turbo.json and .github/**, which are in the shared base filter anchor, so every job ran. A PR scoped to one area will skip more.

before after (warm)
Wall clock 7m09s – 22m 4m48s
TypeScript job 6m44s (worst 10m09s) 1m17s
turbo step 6m01s, 0 cached / 59 1.8s, 59 cached / 59>>> FULL TURBO
Local archive native 7m09s (worst 21m30s) 2m21s (slowest of 7 legs)

Cold-cache runs land at ~6m, so even a first-run-on-a-branch beats the old typical.

The critical path is now Service Map Perf at 4m24s, of which 155s is the perf gate itself.

Two things did not go as planned, and are corrected here

The bun store cache was a regression and has been removed. ~/.bun/install/cache is ~739MB for this repo; restoring and re-saving it cost more than bun spends fetching:

knip install              34s cold ->  57s cached
local-checkpoint-native   44s cold -> 113s cached

The composite action stays as the single place install behaviour lives, with the measurement recorded in it so this doesn't get re-added on intuition.

The Playwright cache is a wash, not a win. Browser install now takes 0s, but the 12s cache restore plus the irreducible 24s apt install-deps adds up to about the 38s download it replaced. Kept anyway — it removes a network dependency from the critical path — but it is not where the time went.

Cache portability confirmed

Web bundle budget passes against an apps/web/dist restored entirely from cache, with chunk hashes byte-identical to the pre-change baseline (assets/index-DBM1wOsy.js). No absolute paths leaked into the artifacts.

Flake soak

The archive matrix is the riskiest change — crash/GC probes simulate process kills against a bun --compile binary talking to chDB over bun:ffi, and they moved off ubuntu-latest onto Blacksmith. 4 consecutive green runs, 28/28 legs. The plan called for 5; one short.

One real bug this surfaced

The new tsconfig.alchemy.json check failed on its first run — CreateAlertingWorkerOptions.chatFlue was declared { workerName: string } while createChatFlueWorker returns a worker whose workerName is Output<string, never>. The config only typechecked because it was never typechecked. Fixed in 613ed0e.

@Makisuo
Makisuo merged commit 6578edf into main Jul 30, 2026
23 checks passed
@Makisuo
Makisuo deleted the perf/ci-speedup branch July 30, 2026 23:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant