Publish-to-HANA throughput β deep-dive findings (#2802) β
Read-only profiling spike. Scope: identify the real per-phase bottlenecks on PROD content rebuilds and recommend concrete fixes. Implementation is tracked separately.
Working copy profiled: main @ 31e34a08f (v1.39.1). PR #2801 is still OPEN β not merged here. This file's publish script is the singular --slug/PUBLISH_SLUG implementation, not the --slugs/PUBLISH_SLUGS set-scoped version from #2801.
TL;DR β
The issue's premise ("Publish-to-HANA ~250β294s even scoped") conflates two different costs. The scoped (single-slug) fast path is 9s and healthy. The ~250β365s cost is on unscoped runs (full / catalog-only / multi-slug delta), and it breaks down into two dominant phases, each ~100β165s, that the issue did not separate:
render-concepts(~95β106s every unscoped run) β the per-concept delta skip is effectively dead. ~10,620 of 10,789 concepts re-render even when only 65 tutorials changed. This is the single biggest, cleanest win.commit/append (~130β165s, the silent gap) β not logged per-phase; it is carry-forward + revert detection + a pile of per-row N+1 upserts (metadata/steps/meta/authorship/ bodytext/branchspecs).
Secondary: the issue's guessed defaults (4/25) are wrong β actual is concurrency=6, batch-size=50. The carryForwardUnchanged OOM fix (#1932) still holds β no remaining whole-corpus BLOB path. Image-refresh is not always 0β2s (one run was 361s) β root-caused and fixed in #2805 (plural slugs input not threaded to the image step).
Evidence β per-phase wall-clock from real PROD runs (2026-10-15) β
GitHub step timings for the Publish tutorial content to HANA step, plus the per-phase log lines inside it (render-concepts/render-topics/render-channels print their own ms; append and commit are derived from timestamp gaps).
| Run | Scope | Step total | append | render-concepts | topics+chan | commit+aux (gap) |
|---|---|---|---|---|---|---|
| 38821292572 | slug-targeted (1 slug) | 9s | β | skipped (!opts.slug gate) | skipped | tiny |
| 38738575919 | delta, 65/183 | 254s | ~97s | 95.3s | 15s | ~130s |
| 38816499609 | full, 1591/1591 | 365s | ~170s | 106.2s | 14s | ~164s |
Raw log anchors (run 38738575919):
12:57:34 2 batches Γ up to 50 slugs, concurrency=6 β append begins
12:59:11 render-concepts: 10620 changed, 169 skipped, 0 errored of 10789 (95308 ms)
12:59:20 render-topics: 157 changed, 2 skipped (8295 ms)
12:59:28 render-channels: 236 changed, 0 skipped (6958 ms)
13:01:38 code-check specs upserted=1 β first post-commit aux line
13:01:42 [publish-contributors] / [publish-validation-rules] done
13:01:43 Verify OK: 183 slugs match serverThe 12:59:28 β 13:01:38 = ~130s gap with no log output is the commit HTTP call (carry-forward + reverts + progress recompute) plus the sequential post-commit aux publishes. The per-row N+1 metadata upserts run during append (which is why append is already ~97β170s).
Finding 1 (PRIMARY) β render-concepts delta skip is defeated by shell churn β
srv/lib/publish-concepts.js renders every published concept, composes it into the __shell__ chrome, hashes the full composed document, and skips when that hash matches the prior stored hash:
publish-concepts.js:121βconst fullDoc = composeShell(shell, body, meta);publish-concepts.js:122βcontentHash = sha256(fullDoc)publish-concepts.js:123βif (priorHashes[key] === contentHash) { conceptsSkipped++; continue; }- prior hashes come from the ACTIVE version's stored
contentHash(:176-188).
The defeat: composeShell (packages/content/chrome-shell.js:175) prepends the entire shell before half (full <head>: content-hashed CSS <link>s, island <script> fingerprints, nav markup) and appends after to every concept doc. So the concept's full-doc hash embeds the shell. Any shell change β a CSS rebuild, an island fingerprint bump, a nav/footer edit, a Hugo asset re-hash β flips the hash of all ~10,789 concepts at once, and the per-concept skip never fires.
Proof in the data: run 38738575919 had only 65 tutorials in its delta, yet render-concepts reported 10,620 changed / 169 skipped. If the skip worked against content, ~169 would be the changed count, not the skipped count. The shell moved, so ~98% of concepts "changed" with identical concept bodies.
Cost: ~95β106s of render + gzip + multi-row INSERT, almost entirely wasted, on every non-slug-scoped publish β including catalog-only runs where zero tutorial content changed.
Recommended fix (hash the body, not the chrome): split the delta hash so concept bodies and the shell are versioned independently:
- Hash
body(the output ofrenderConceptDetail+ themetathat actually varies per concept) for the delta skip, and store that as the delta key. - Track the shell separately with a single
shellHash. When only the shell changed, the concept bodies are byte-identical β re-compose is a cheap string concat, and (better) can be done once and carried forward rather than re-hashed per concept. - Equivalent cheaper variant: if
shellHashis unchanged since the ACTIVE version, trust the stored body hashes and skip the compose+hash loop for unchanged bodies entirely.
Either way the common case (shell changed, bodies didn't) collapses from ~100s to a few seconds. render-topics (159 items) and render-channels (236) have the same latent bug but are 1/70th the corpus, so the payoff is almost entirely in concepts.
Verify-first before implementing: confirm how often the shell actually changes publish-to-publish on PROD (sample a week of __shell__ contentHashes). If it changes every run, this fix recovers ~100s every time; if rarely, the win is smaller but the correctness of the skip still matters.
Finding 2 β append/commit is the hidden ~130β165s, dominated by per-row N+1 upserts β
The commit gap is unlabeled in logs. Server side (packages/content/content-publish-session.js), commitSession (:482-737) runs sequentially on the ambient connection: revert detection (2 round trips), chunked DELETE of rejected reverts, carryForwardUnchanged, optional dual-write, an aggregate count/sum, progress recompute, 2 manifest UPDATEs, PipelineLog + PublishTimings. The heavier N+1 work runs during append (appendToSession:150-367), which is why append is already ~97β170s:
Genuine per-row SELECT-then-UPDATE-or-INSERT loops (no set-based upsert, no cds.tx batching):
upsertTutorialMetadata(:805-973) β the dominant N+1. Per slug: SELECT id by LOWER(slug), SELECT legacyId, UPDATE/INSERT Tutorials, then a nested per-step loop (SELECT + UPDATE/INSERT + sometimesgetNextLegacyIdwhich itself hits the DB), then TutorialMeta SELECT + INSERT/UPDATE. RoughlyN_slugs Γ (3 + M_stepsΓ2 + 2)sequential HANA round trips.linkTutorialAuthorship(:1013-1249) β per-slug: multiple rawdb.runSELECT/UPDATE.upsertBodyTexts(:1251-1278) andupsertBranchSpecs(:1280-1317) β per-slug SELECT-then-UPDATE-or-INSERT.
What is already batched (don't re-fix): ContentFiles BLOBs are multi-row INSERTβ¦entries() in groups of 50; DELETE-before-INSERT chunked by 500; progress recompute is one bulk recomputeTutorialProgressBulkSQL (the #382 phase-E fix); recomputeProgressForChangedTutorials slugβid is chunked by 500.
Recommended fixes (ranked):
- Batch the metadata reads. Replace the per-slug
SELECT id by LOWER(slug)/SELECT existing step/SELECT existing TutorialMetawith oneWHERE slug IN (β¦)(orlegacyId IN (β¦)) read per table at the top of the batch, build Maps, then decide insert-vs-update in memory. This alone removes the bulk of the round trips. - Batch the writes. Partition into to-insert vs to-update sets and issue multi-row
INSERTβ¦entries()+ grouped UPDATEs, matching the ContentFiles pattern already in the file. On HANA, round-trip count is the cost driver far more than row count. - Scope-gate authorship/bodytext/branchspec work to the delta slug set the same way contributors/validation-rules were bounded in delta (#2462) β verify they already are.
These are HANA-only wins (SQLite unit tests won't show them). Measure with the existing PublishTimings rows, not local tests.
Finding 3 β correct the issue's stated knobs & assumptions β
- Defaults are concurrency=6, batch-size=50, not 4/25.
scripts/publish-content.ts:732-733(--concurrencydefault'6',--batch-sizedefault'50'). The CI step passes--batch-size 50explicitly. There is noPUBLISH_CONCURRENCY/PUBLISH_BATCH_SIZEenv var β only the CLI flags. The historical "6/50 pre-#422" note matches current state. Append is only ~97β170s and is already the one concurrent phase; raising concurrency has less headroom than fixing Findings 1 & 2. Worth a controlled A/B (8β10 workers) but it is not the main lever. carryForwardUnchangedOOM fix (#1932) holds.content-publish-session.js:1358-1361reads only theslugcolumn of the previous version, diffs in Node, and:1363-1367returns early (zero BLOBs) when nothing to carry β which is the case on a full rebuild (every slug fresh). BLOBs are pulled only forcarrySlugs, chunked by 50 (:1372-1435). No remaining whole-corpus BLOB materialization. No action.- The
/content/hashes+/content/source-hashesdelta round-trip is cheap and correct β both read storedcontentHash/sourceHashcolumns only; neither decompresses or re-hashes BLOB bodies (content-store.js:1346-1393,:1410-1487). The one small inefficiency: delta mode does three sequential pre-session GETs (hashes, then hashes-again-for-pipeline-token, then source-hashes). The second GET re-fetches the same endpoint just to read theX-Pipeline-Tokenheader β fold it into the first response or run the three in parallel. Low value (seconds), low risk. - Image-refresh is NOT innocent. The issue says it is "always 0β2s"; run 38816499609 spent 361s on
Refresh tutorial images (slug-targeted)β co-equal with publish as a wall-clock cost. Root cause (#2805): theslugsplural input was not threaded to the image step. That run was aworkflow_dispatchwithmode=slug-targetedand the pluralslugs=input set (singularslugempty), but the step env wasREFRESH_SLUG: inputs.slug || dispatch_slugβ it never readinputs.slugs, soREFRESH_SLUGwas empty andbackfill-images.ts --slug ""enumerated the whole catalog (21,712 images). Fixed in #2805:backfill-images.tsnow accepts a comma-list--slug/--slugs, and the step also passesREFRESH_SLUGS: inputs.slugs || dispatch_slugs(mirroring the publish step'sPUBLISH_SLUGS). The same plural-input gap had already been fixed on the publish side.
Suggested implementation order (separate PRs) β
- Finding 1 β concept delta on body hash (biggest win, isolated to
publish-concepts.jschrome-shell.jshash boundary; byte-compatible serve output). ~100s/run.
- Finding 2 β batch metadata reads then writes in
appendToSession(HANA round-trip reduction). ~tens of seconds; higher blast radius, needs hybrid/PROD timing proof. - Finding 3 micro-wins (parallel/fold the pre-session GETs; A/B concurrency) only after 1 & 2.
Open a sibling issue for the image-refresh 361s outlier.Done β #2805, root-caused and fixed (see Finding 3 above: pluralslugsinput not threaded to the image step).
Not changed by this spike β
Read-only. No code touched. All line numbers above are against main @ 31e34a08f.
Related: #2801 (multi-slug dispatch, still OPEN) Β· #422/#423 (prior timeout work) Β·
#1893/#1932 (carry-forward OOM) Β· #382 (progress-recompute batching) Β· #2462 (contributors
slug-scoping) Β· #2727 (pipeline-token change-detection).