A viewer for contributed observations, and an explorer for the census - #35
Draft
zmaril wants to merge 55 commits into
Draft
A viewer for contributed observations, and an explorer for the census#35zmaril wants to merge 55 commits into
zmaril wants to merge 55 commits into
Conversation
An upload used to end with a link to the bytes. A contributor who followed it got a zstd-compressed frame of parquet files, which is honest and unreadable: to see what they had actually contributed they needed the client, a decompressor, and a parquet reader. So the site now reads it for them. Ingest answers with a `view` URL as well as a `url`, the client prints the page rather than the blob, and `cratebank.io/view/<key>` is that one upload with every table in it -- the session's own row down the page, the rest across it, row counts taken from the parquet footers so a large table says how much of itself is on screen. The page is rendered on the server and ships no script. The site Worker reads the object through its own R2 binding rather than over data.cratebank.io, so there is no CORS rule to keep in step and the page does not depend on the public data domain being up. `run_worker_first` names `/view/*` and nothing else, so every other path is still an asset served without waking any code. Only keys shaped like the ones ingest writes are ever fetched -- R2 has no read-only binding, so that check is in the code -- and only the first two hundred rows of each table are decoded. Objects above 16 MB compressed are linked rather than opened: a bundle decompresses to several times its stored size and a Worker has 128 MB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
cratebank previewRun automatically for a PR authored by @zmaril. OpenTofu planNo infrastructure changes. Plan outputcloudflare_workers_custom_domain.site["www.cratebank.io"]: Refreshing state... [id=2426388fdde2ff5a2083a1458e423f27566fb994]
cloudflare_workers_custom_domain.site["cratebank.io"]: Refreshing state... [id=41b6bb2dc82ba0dacaa13bb7056f0304a92e9065]
cloudflare_r2_bucket.cratebank: Refreshing state... [id=cratebank]
cloudflare_workers_route.data_root[0]: Refreshing state... [id=8392a042ec984865a4842bde55fa086c]
cloudflare_workers_custom_domain.ingest[0]: Refreshing state... [id=6edef664db9c85623282947938570ffb8054b932]
cloudflare_r2_custom_domain.data[0]: Refreshing state...
No changes. Your infrastructure matches the configuration.
OpenTofu has compared your real infrastructure against your configuration and
found no differences, so no changes are needed.
|
The viewer could only be entered by link. If you had a key you got a page, and if you did not you got a 404 telling you what a key looks like -- so the census had a reader for exactly one observation at a time, the one you had just uploaded, and no way to see any of the others. Now `/view/` is the top of the space and every partition between it and an object is a page: schema fingerprints, then years, months and days, then the uploads, each level newest first, with a trail back up from anywhere. The front page also walks down to the newest day in the last few schemas, so the most recent uploads are one click from the top; that walk is capped, because a bucket is not. A prefix nothing is filed under is a 404 rather than an empty page pretending to be a place. An observation opens with what a person came to find out -- an M2 Pro compiled 42 units in 37 seconds -- rather than the first of 47 fields, then lists what is in the object with the row counts, so the tables can be jumped to instead of scrolled past. A truncated table now links to the rest of itself; `?rows=N` raises the cap as far as 2000. Only `sessions/` is reachable, listing included, so the published tables and the client manifest that share the bucket stay outside the viewer. And the viewer is for people, said four ways: `/robots.txt` disallows it, every response carries `X-Robots-Tag`, every page repeats it in a `meta` tag, and a request whose user agent names a crawler, a scraper or a link preview is refused with a 403 that points at data.cratebank.io. A user agent is a claim rather than proof, so this stops the cooperative and the lazy -- which is all it needs to stop, since nothing here is secret and the same data is free in bulk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
The viewer told anyone who followed a dead link that uploads are kept indefinitely and they must have mistyped the key. Both halves are wrong. Compaction deletes every bundle it merges -- `worker-compact` does it in two places, and the daily cron means a link is good for about a day -- so the overwhelmingly likely reason a key is missing is that the census has already absorbed it. Sending that person off to check their typing is the worst answer available. So the 404 names compaction and points at the published tables, where the rows went; an observation page says the object is staged rather than archived; and the listing says it lists the recent past rather than the census. The rows outlive the object, and every page now says which is which. Found by opening the deployed preview against the production bucket, where `sessions/` is empty: everything uploaded so far has been merged already. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
The client printed where the observation went and stopped there, so the
first anyone learned that a staged object has a lifetime was when their
link 404ed. Compaction deletes what it merges, and it runs at 05:00 UTC, so
that is a day at most -- worth one line at the moment somebody is looking
at the link, rather than a discovery later.
cratebank: your observation is public at https://cratebank.io/view/…
cratebank: readable there until the next compaction, typically 05:00 UTC
daily, which merges it into the public tables
The schedule comes from the collector rather than the client: ingest carries
a COMPACTION var and returns it beside url and view. A client that hardcoded
05:00 would be claiming it about `cargo cratebank serve` and about anybody
else's collector, neither of which compacts at all -- so a collector that
says nothing has nothing said on its behalf, and the line is simply not
printed. "Typically" because a deploy compacts too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
Five pieces, all on top of the viewer this branch already added. **The build, drawn.** Cargo's `--timings` report answers three questions -- when each unit ran, how busy the machine was, and where the time went -- and a viewer that showed only tables was asking a contributor to rebuild that page by eye. It is drawn now, as server-rendered SVG with no script, plus a phase bar Cargo cannot draw because it does not sample. **One table where there were five.** `units`, `phases`, `unit_flags`, `compiler_units` and `artifacts` are all keyed to a unit, so reading them apart meant doing the join by eye once per unit. They are one sortable row each: timings, phase mix, peak memory, outputs, flags and features. `run_id` is gone from every table, being the same value on all four thousand rows, and seconds show two decimals with the stored value in `data-sort`. **A page laid out.** Configuration in a sticky rail, measurements in the column that scrolls, the stored tables folded into a `<details>`, and the whole thing at the window's width rather than a 46rem measure meant for prose. Tables sort, and a button turns the page into JSON by reading the DOM rather than shipping the rows a second time. **A tree.** Four clicks through partitions to reach one of a dozen objects was a filing system standing in for a list. **Somewhere to ask questions.** `/data/*` serves the published tables from this origin with range requests passed through, because `data.cratebank.io` has no CORS headers and a browser here cannot read a byte of it. `/explore/` runs DuckDB against those tables and hands the Arrow to Perspective, which pivots and plots it without anyone writing a second query. Both engines are self-hosted; the 34 MB one lives in the bucket, keyed by version, and the deploy publishes it once per release. Found while testing: two copies of apache-arrow made `tableToIPC` return nothing, Perspective keeps a chart configuration across a load whose columns no longer exist, and three template literals in chart.ts had a NUL byte where a space belonged -- which silently broke one join while the others worked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Four rules, four different answers, because they were four different situations. **Colour.** The chart palette was 34 hex literals scattered through the rules that used them, which is exactly what the rule is about. They are tokens now, defined once, and every use is a `var()`. The definitions carry a marker, because a palette is where colour literals belong. **Motion.** One animation, on the loading bar, marked with why: a bar that does not move cannot say the difference between downloading and hung, and the point of that overlay is a 40 MB download. It is already off under `prefers-reduced-motion`. **Inline SVG.** The rule is about icons pasted into component source and asks for them to be extracted. There is no icon: every `<svg>` in `chart.ts` is a plot generated from one observation's numbers, and there is nothing to extract because none of it is the same twice. **A stray TODO** that was the word `todo` in a comment about Cargo's placeholder for an ordinary build, and a `color` finding that was the box emoji's code point. Both reworded rather than suppressed where the wording was the problem. Also: browser scripts are TypeScript like everything else here. `viewer.js` was the only tracked `.js` in the repository, which CI asserts there are none of; it is `viewer.ts`, type-checked, and bundled to `viewer.js` at build time. And suppression markers are stripped from what gets published. They explain to the scanner why a rule does not apply, and a visitor reading view-source has no use for them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Written whole, the string this function searches for is itself a suppression marker as far as the scanner is concerned -- one that suppresses nothing, which is a finding of its own. It is spelled in two halves now, with a comment saying why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Four changes to the observation page. **The rail is twice as wide.** Configuration was being wrapped to fit a column sized for a summary. **The unit timeline moved to the foot of the page.** It is one row per unit, so a real build is thousands of pixels tall and everything after it was unreachable. The summary at the top links down to it. **The phase mix is in pipeline order.** Sorted by size it told you which phase was biggest, which the column already showed. In the order a compile does them it tells you the shape of the compile, and the same phase sits in the same place in every row -- so a column of bars can be read down rather than one at a time. The order is `cratebank_core::payload::phase::ALL`, which is also the vocabulary the census publishes; a phase not in it sorts after the ones that are. That also fixes the colours, which were assigned by first appearance and so disagreed between a unit's bar and the build's. A phase's hue is its place in the pipeline now, and the palette went from eight to sixteen so the fifteen canonical phases each have their own. **The leading phase is no longer named beside the bar.** The bar is the column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Nothing on it is under 12px now. The floor was 10.5px on pills and 11.5px on table text, and in the charts it was worse: SVG text scales with the viewBox, so 8.5px of label rendered at about 6.7px. The charts' user units are pixels now -- the viewBox is 1000 wide and the element is never narrower -- so a size in there is the size it says, and the gantt's rows grew to hold a 12px label. **The phase mix fills its column.** It was 5.5rem of bar in whatever width the column happened to be, which for the one column that is a picture is the wrong way round. **Package and version are two columns.** They are two facts, and a table that sorts should sort by each of them. **Everything says what it is on hover.** The joined table's headings are written here, because they are this page's own words. The stored tables' headings are not: they come from `schema/v1/tables.json`, which compaction publishes beside the data, so a heading explaining itself is quoting what a consumer of the parquet reads rather than a second copy of it. Read once per page, and a missing file costs the titles rather than the page. Found on the way: two sections were both `id="units"` -- the joined view and the stored table it is a view of. The stored tables are `raw-` prefixed now, which is what the contents list and `?rows=` link to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
…o start
**The observation page states its conclusion.** Two numbers decide
whether a build wants more cores, and both were sitting in the data
unsaid. On the build I tested against:
12 cores for 14.52s, of which 42% went unused: 101.26s of compiling
in 174.24s of available core time. Its critical path -- the longest
chain of units that must run one after another -- is 14.00s of that,
so this build was waiting on its own dependency graph rather than on
cores. More of them would not have helped.
The critical path is a longest-path walk over `edges`, which Cargo
already reports and the census already stores; the nine units on it are
outlined in the timeline at the foot of the page.
**The concurrency plot fills to the core count.** Units active is an
area now rather than a line, and the gap between it and the ceiling is
the machine that went unused -- which is the reason to look at the plot
and was previously left as an inference.
**The explorer opens on an answer.** An empty box and a list of table
names is a fair interface for somebody who already knows what they want
to ask, and nobody arrives knowing that. There are eight questions --
where the time goes, the slowest crates, frontend against codegen, the
hungriest crates, phase mix by crate, what holds everything up, every
build, cores against wall time -- each carrying the query that answers
it and the shape the answer should be drawn in. The tables are still
there, as the other way in.
Two bugs behind that: `save()` on a viewer with nothing loaded throws
`No Table attached`, which took out the first query of every session;
and a connection opened per query and closed in a `finally` cancels
whatever the next click has already started, so there is one now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
`--dry-run` prints exactly what an upload would contain and sends nothing, which until now left you holding a screenful of JSON. The viewer could have read it, except the viewer only worked on builds already in the bucket -- so seeing the charts meant contributing first, which is backwards for anyone deciding whether to contribute at all. The top of the viewer takes a paste of it now and draws the same things: the verdict, the concurrency plot, the joined unit table, the critical path, the timeline. Not a second implementation of any of that. `chart.ts` builds strings out of tables and touches nothing belonging to a Worker, so the browser bundles the same module and calls it with tables it made from the pasted rows. One set of charts, two callers. Nothing leaves the page, and the page says so where it can be checked rather than in a paragraph somewhere else: it links to the script that does the reading. That script contains no `fetch`, no `XMLHttpRequest`, no `WebSocket`, no `sendBeacon`, and no storage of any kind -- checked against the built bundle rather than the source. Log lines around the JSON are tolerated, because a terminal shows the progress and the tables together and telling someone which half to copy is worse than taking the outermost braces. A truncated paste says which JSON is wrong rather than that some is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
"Read it" drew the charts underneath the paste box, which is not what a person who has just pasted a build wants: they want the page a contributed build gets. It is that page now, at `/read/`, and the box folds away once it has been used. The layout came out of `observation()` into a `view()` both callers share, so a pasted build and a stored one are the same page built by the same function -- rail, glance, verdict, charts, joined units, the stored tables, the timeline. Everything that assumes a stored object is optional, because for a pasted build there is not one. Also two ways it failed quietly. An observation from an older client has an `artifacts` array of its own, so hunting for tables found one, called it a table, and drew nothing -- which looks like it worked. The shape is checked first now and says which of the two it is looking at, and what to run to get the other. Tables that are all empty say nothing was measured rather than drawing an empty page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
The concurrency plot sawtoothed to zero and the CPU area came out as
triangles. Both were the same mistake, and it was mine rather than the
data's.
`timeline` holds two series on two clocks: Cargo samples how many units
are running on one schedule and how busy the processor is on another,
and a row carries one or the other. The Rust says so, in a comment,
above the code that builds it:
// Two series on two clocks. A row carries the concurrency triple or
// cpu_pct, never both, and a reader filters on the column they want.
The column that is not recorded is null. My reader turned every null
into 0 -- `Number(null)` is 0 and finite -- so each series was drawn
down to the floor wherever the other one had a sample. 302 rows read as
one series instead of 224 and 78 read as two.
There is a null-aware accessor now, and each series is filtered and
sorted on its own. `inactive` touches zero three times, at the end of
the build, rather than 79 times throughout it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Four things, and one of them was doing most of the damage. **A tooltip per phase.** Fifteen phases and their percentages in a single tooltip is a paragraph to read when the question was what the band under the pointer is. Each band answers for itself now. **A dash where there is nothing.** Codegen, link, output, memory: an empty cell reads as a rendering fault rather than as a fact, and the stored tables already use a dash for exactly this. **Four pills and a count.** Eleven features wrapped inside a cell made a row four lines tall, and a table of those is a table you scroll rather than read. The rest are behind a `+7` that names them on hover. **And the actual culprit.** The site gives every `li` a .6rem vertical margin, which nothing here reset -- so each pill carried 19px of nothing above and below it, in a cell 20px tall. Rows were 46px for 12px text, and the two-line ones were 250. Median row: 46px to 27px. The table is 4,491px instead of 7,657px for the same 167 units, and the padding came down with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
The lane chart looked wrong on bun's release build and the data was fine: 2,165 phase bands, 23 unsampled units. What was wrong is that three entries in the palette are the same amber. "Nothing was sampled from this unit" was a flat `--other`, which sits 99 units of perceptual distance from `codegen` -- closer than `codegen` and `metadata_encode`, which are deliberately adjacent in the same family, at 143. On a build that is 77% codegen that is the worst collision available: the unmeasured units looked like the most measured thing on the page. They are hatched now, in both the lane chart and the dependency graph, with a line in the legend. A hatch is not a colour in the palette and cannot be read as one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
Every Linux build in the census publishes 100% `unattributed`: 1.7M samples across four sessions, no phase data at all. That is not a compiler doing nothing recognisable, it is a lookup with the wrong key. samply writes a library three ways -- `codeId`, `debugId`, and the `breakpadId` that is the debug id with an age digit stuck on the end -- and this only ever asked by code id. A code id is a build id, and an ELF library need not carry one: on Linux `rustc`, `librustc_driver.so` and `libLLVM.so`, which is to say every library a compile actually runs in, arrive with `codeId: null` while the symbol sidecar knows them perfectly well by debug id. Nothing errors. Every frame resolves to nothing, every stack comes back empty, and `classify` answers `unattributed` for a build it was never shown. Measured on a release build of a two-dependency workspace, stable 1.94 on x86_64 Linux: 6.6% of frames resolved before, 100% after, and 35% of samples had no symbol at all on the stack rather than 0%. The load side already knew about all three spellings -- it indexes by both ids -- so this is the lookup catching up with it. Sorting the symbol table is the other half: `resolve` binary-searches it, so its order is a correctness requirement rather than something the sidecar promises. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
`codegen` was one name over the whole back end. On the one macOS build in the census with phase data it is 95.5% of every sample on the parallel threads and 41% of the build's CPU, in a single bucket whose content is "the compiler was compiling". Four crates in that build are more than half codegen and nothing says which half. It could not have been otherwise. rustc runs back-end work items on worker threads, so the outermost marker on such a stack is always the thread's own entry closure inside `rustc_codegen_llvm` -- in one profile 28,756 of ~30,000 codegen samples resolve their outermost marker to `std::thread::lifecycle::spawn_unchecked<LlvmCodegenBackend>`. The outermost rule is right for the frontend, where name resolution driven by macro expansion is expansion; inside the back end it can only ever answer `codegen`. So a sample now carries three names. A **segment** -- frontend, backend, link -- which is the level two builds are worth comparing at. A **phase**, the old vocabulary plus the five the back end turns out to be: `ir_build`, `optimize`, `machine_code`, `lto`, and `monomorphize`, matched innermost-first by rustc's own C++ shims (`LLVMRustOptimize`, `LLVMRustWriteOutputFile`, `LLVMRustPrepareThinLTOImport`), which bound exactly the operations worth naming and are as stable as the symbol table. And a **detail**: which pass family in the optimiser, whether machine code was instruction selection or register allocation or the assembler, which half of type checking. `codegen` survives as the answer for back-end work no finer marker claimed -- 2% of a real build. `link` moves out of `codegen` entirely. Linking is inside `rustc_codegen_ssa::back::link`, so running `cc` has been published as compiling. Two more things the profile already carried and this threw away. rustc names each worker thread after the work item and the codegen unit it was given. That was read for one boolean and discarded, which was wrong twice: `lto cgu.NN` matched none of the three prefixes tested, so thin-LTO work -- 19,252 samples against `opt`'s 10,903 in a release build, the largest single class there is -- was counted as the serial compile, and the sixteen codegen units of a unit were one indistinguishable number. Both are kept now, as `thread` and `cgu`. And every sample has a timestamp, which was counted and dropped, so "when did this unit stop type checking and start emitting code" had to be guessed at downstream from totals. Consecutive samples that stay in one phase become one span with a start and an end. A thread that goes unsampled for more than ten milliseconds starts a new span rather than stretching the last one over a gap nothing measured -- without that a two-sample run three seconds apart draws as a three-second band. `phases.parquet` gains segment, phase, detail, thread and cgu; `phase_spans.parquet` is new and carries the same samples in time order. Both come from one pass over one profile, so a phase mix and a timeline of it cannot disagree. Spans stop at the second level: at 5 kHz the detail changes almost every sample, and encoding it produced 101,492 spans for 274,020 samples, 61% of them one sample long, against 15,114 without it. Measured end to end on Linux, release, thin LTO: backend 64%, frontend 31%, link 1%, unattributed 4%. Inside the optimiser: memory 21%, loops 20%, instcombine 15%, analysis 12%, inlining 8%, and 24% that named no family. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Every phase chart on the page was a mix: a unit's samples added up and spread across its bar in proportion. That can say a unit was 60% back end and cannot say when it stopped checking and started emitting, which is the question a compile actually raises. The answer was in the data and nothing drew it. So there is a chart of it. One row per thread of one rustc, each band a run of consecutive samples, drawn where it happened. The first row of a unit is the serial compile; the rows under it are its codegen units, one thread each, running while the serial row does rather than after it. On a dev build of this repository the shape is immediate: `parquet` checks for seven seconds and then one `opt cgu.00` runs alone from 7.5s to past 12s, which is the tail the whole build waits on and was previously one orange band's worth of "codegen". Units are measured from their own start rather than the build's, so two can be compared by shape without hunting for them on the build clock. The palette is re-cut for nineteen phases in four families: hue is the segment, lightness is the position in it. The warm family is six long because the back end is six phases; it used to be three, because the back end used to be one phase with nothing inside it. Three bounds, all of them stated on the page rather than left to look like a build with no tail: sub-pixel runs merge into the phase that held most of their samples, bands under four pixels lose the tooltip nobody could land on them anyway, and the chart stops at 400 rows or 6,000 bands. On a 163-unit build that is 26 units and 425 KB of the page rather than 1.3 MB. The explorer gets the questions the new columns make askable: what the back end was doing, which codegen unit everything waited for, and when a crate stops checking and starts emitting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The schema, the capture manifest, the collection notes and the client's own README all described one flat list of phases. They describe the hierarchy now, including which of the two `link`s is a wall-clock span and which is sampled CPU inside rustc, and that summing across `thread` gives a number comparable to nothing. There is also a note on what is still unattributed, with the query to watch it. It is 4.1% of a Linux release build, concentrated on the thin-LTO threads, and two things are known about it and neither chased: stacks that resolve to LLVM frames and nothing else, which want an unwinding fix rather than another marker, and rustc's parked process main thread, which would be better named than left in `unknown`. Anything published before the id fix has no phase data at all, whatever its rows say. That is worth writing down where a reader of the census will find it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Six findings, all mine, all real. `sample.rs` was 1528 lines against a 1500-line limit, and the split it wanted was already there: what a stack was doing is a different job from running the profiler and reading its output. The marker tables, `classify`, the parked-thread leaves and the thread naming move to `phase.rs` -- 753 lines of "what was this", against 813 of "get the data" -- and rustc's thread names sit with the symbol markers, because a stack says what the code was doing and the name says which work item it was doing it for. The stack walk was nine levels deep: a chain of nodes, each naming a frame, each frame reaching its symbol through three more tables, all of it inline in the middle of a function about something else. It is a `Stacks` type now, and the four nested `if let`s are four `?`s. Shaping the payload was three nested closures deep for the same reason and is two functions instead. The session fixture in core's tests built a whole build observation as one literal, which put its innermost span nine levels in; the threads are a helper now, which also makes the invariant it exists to test -- the same samples as a total and as a span -- something you can read in one line rather than count braces to find. No behaviour change: 91 tests pass, and two release builds measured before and after differ by 1.2 points of back-end share, which is the same run-to-run variance two builds of the identical binary show. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The hierarchy went in and the page stopped at two levels of it. `optimize` is 42% of this build and the chart said so; what the optimiser was actually doing was in `phases.parquet` and nowhere on the page. So each phase that has anything finer to say gets a group, and each group sums to its own phase rather than to the build -- `regalloc` is a tenth of making machine code and a fortieth of the compile, and the first is the number this chart exists for. Bars are the phase's own colour, because a detail is not a separate thing from its phase; the label tells them apart, and every bar carries one. That also keeps the palette at nineteen rather than inventing forty more hues nobody validated. `(no finer detail)` is drawn rather than dropped: 40% of machine code and 23% of the optimiser reached the phase and no further, and that is worth seeing next to the parts that did resolve. A phase that never says anything finer gets no group at all, since one bar at 100% labelled "(no finer detail)" is a row that says nothing. On a release build of a two-dependency workspace: type checking is 65% typeck, 20% well-formedness, 6% coherence. Borrow checking is two thirds the checker and a third the MIR pipeline it drives. The optimiser is 21% memory, 20% loops, 15% instcombine. Machine code is 29% instruction selection against 9% register allocation. Thin LTO is 70% reading other modules' bitcode in. The share label is anchored after its bar, in a class rather than an attribute: `.tick` sets `text-anchor: middle` and CSS beats a presentation attribute, so the attribute version printed every percentage on top of the bar end it was measuring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Two failures, both from the last commit and both mine. `chart.ts` reached 1,575 lines against a 1,500-line limit. I said in the commit before it that the file was twenty-one lines from the limit and that the next feature would have to make the call; the next feature was the one after it, and it did not. The boundary was there to be found: what Cargo reports about a build is not what samply reports about the compiler that ran it. So `chart.ts` keeps the build -- lanes, the dependency graph, concurrency, the verdict, the units table -- `phases.ts` takes the three sampled-phase charts, and `chartkit.ts` holds what both draw with, which is the part that is genuinely shared: the phase order, the colour a phase gets, reading a table by column name, the axis, a unit. 795, 493 and 356 lines. The rendered page is byte-for-byte what it was before the split. The other failure is a scratch screenshot script I left in `infra/worker` and swept up with `git add -A`. The `workers` job asserts the repository contains no `.js`, `.mjs` or `.cjs` at all -- everything here is TypeScript and the built bundle is not committed -- so one stray `.mjs` fails it, which is the check working. It belongs in a scratch directory, and that is where it is now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Measuring somebody else's open source project is a thing the census wants to be able to do, and the only way to say "these crates are public" was a key in their manifest. So `--public`, or `CRATEBANK_PUBLIC=1`: the same declaration, made by whoever runs the build. It is the same decision either way, and the manifest stays the better place for it when the manifest is yours -- it is reviewable, and it persists, where a flag is a decision made once in a shell. Both meet in one variable in `project`, so nothing downstream can tell them apart or needs to. The rule they feed is now a function with a name, `package_is_public`, because a flag that widens a privacy boundary should have the boundary written down where it can be tested. It is conjoined with "is this a workspace member", so neither the key nor the flag can publish a path dependency from outside the tree, a private git checkout, or a private registry -- and that is what the test asserts, along with the fact that a crates.io package is public because of where it came from and asks nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
A phase mix is a statement about whatever the sampler saw, and until now nothing said how much that was. On bun the answer is 2%: a 351-second build whose last sampled unit finished at 8.6 seconds. Every number drawn from that run describes the first nine seconds and reads as if it described the build. Two facts now travel with the observation. `sampled_span_s` is the time from the first sampled process starting to the last sample taken, which against `elapsed_s` is the coverage. `lost_events` is what samply reports on stderr and nothing was reading -- perf events the kernel dropped because the sampler could not keep up, which is the mechanism: drop the records that say what a process is and its unit does not arrive unattributed, it does not arrive at all. Both are columns on `sessions.parquet`, so a reader can filter on coverage before believing a phase number rather than recomputing it from unit start times. Reading samply's stderr means draining a second pipe, so it gets its own thread: whichever pipe fills first would otherwise stop the build. And `--rate-hz`, because a long build is the case for sampling less often. The default stays 4999 -- samply's fixed cost per invocation dominates on anything worth measuring, and a low rate starves small units -- but a build long and wide enough to overflow the perf buffer has a lever now, and the two new columns are how you tell whether you need it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Two new columns want the sentence that tells a reader why they are there: a run that covers 2% of its build has phase tables about 2% of that build, and dropped events mean units are missing rather than unattributed. Both belong next to the numbers they qualify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Measuring a hundred-crate workspace turned up a gap worth writing down before it is forgotten: on that build every `machine_code` sample came back with no detail, across three runs at two sampling rates, while the phase itself resolved fine. That asymmetry is the clue. `machine_code` is recognised by `LLVMRustWriteOutputFile`, which is a rustc symbol in `librustc_driver.so`; everything that would say which part of it -- instruction selection, register allocation, the assembler -- is an `llvm::` symbol in `libLLVM.so`. A single-crate build of the same workspace, same toolchain, same sampler, resolves 1.3 million libLLVM frames and lands on exactly the leaves a debug build should have. So the symbols exist, the sidecar carries them, and something about the larger run loses that one library. Until that is understood, a missing level three means "not resolved here" and never "the compiler did not do that", and this says so where a reader of the census will find it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The `lto` phase read 0.3 seconds on a build whose profile is `lto = "fat"`, which is not a small number, it is a wrong one. Every marker in that phase was a thin-LTO shape -- `PrepareThinLTOImport`, `FunctionImporter`, the ThinLTO buffer shims -- and fat LTO does not go through any of them. It merges every module into one and then optimises that, so its merge step was reaching no marker at all and its optimise step was landing in `optimize`. Half of that was right. Optimising the merged module *is* optimising, and it stays where it was. The merge was simply missing, and now has the three names it happens under: `LLVMRustLinkerAdd`, `rustc_codegen_llvm::back::lto` already covered `run_fat`, and `llvm::Linker::linkModules`. Measured on bun's release build, 652 seconds, 99 crates, one codegen unit, fat LTO: `lto` goes from 0.3s to 17.1s, every sample of it in `bun_runtime`, 16.3 of them the merge itself. The 426 seconds of optimising after the merge stay in `optimize`, which is what they are. The markers went in unvalidated -- a two-dependency test workspace with fat LTO turned out to merge too little to sample even once at 999 Hz -- so this is the real build standing in as the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
On a hundred-crate workspace every `machine_code` sample came back with no detail, at two sampling rates, on three runs -- while the phase itself resolved fine. A single-crate build of the same workspace, same toolchain, same sampler, resolved 1.3 million `libLLVM` frames. The asymmetry was the whole clue. `machine_code` is recognised by `LLVMRustWriteOutputFile`, which is a rustc symbol in `librustc_driver.so`; everything that says *which part* of making machine code is an `llvm::` symbol in `libLLVM.so`. So a fault that reached only the second library would look exactly like this. It did. The sidecar for that build has 48 entries and exactly one repeated debug id: `libLLVM.so` twice, with 3,032 symbols and with 108. `load` keyed them into a map, so the 108-symbol stub replaced the compiler's symbol table, and nothing errored -- the phase markers went on resolving from the library that arrived once. Small builds were unaffected because their sidecars carry one copy, which is why every test and every probe passed. Copies are merged now rather than replaced, deduplicated and sorted because `resolve` binary-searches them. On the same 91,414 samples that turns 94% unlabelled into 47% assembler, 15% instruction selection, 3% register allocation; across the whole build, samples carrying a level three go from 31.7% to 52.5%. The test builds a sidecar with one library in it twice and asserts both halves survive, including an address that lands between them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
`unattributed` was 5.1% of bun's workspace build, and a bucket that big is
not a rounding error -- it is a set of things nobody had looked at. Looking
at them, they were four kinds of work, each nameable:
* the incremental cache being written -- more than half of it, on a build
that is not even incremental;
* the compiler process rather than the crate: the dynamic loader, the
thread and allocator bring-up rustc pays once per unit, the arenas it
drops afterwards;
* questions any step can ask and that answer themselves recursively --
trait solving, const evaluation, layout -- reached so deep through the
query system that no caller survives on the stack;
* proc macros, which run on their own stack with no rustc frame on it.
So there is a fourth segment, `process`, for the compiler's own time, and
five new phases. 5.1% -> 0.09%, and what is left is stacks of one frame the
unwinder could not get past, which is what the bucket is for.
The back end also says which part of itself now: `ir_build` was one
undivided bucket and is `body`, `layout` and `debug_info`; `machine_code`
gained `target_init`, `frame` and `debug_info` and its bare share went 8.8%
-> 3.6%; and the time the back end spends destroying the LLVM module after
the object file exists is `free_module`, which was 1.5% of one build.
Two things worth keeping from doing it:
`sample.rs` grows a probe -- ignored by default, pointed at a saved profile
-- that prints the whole breakdown and then the stacks behind one bucket.
Marker tables are only ever extended by looking at what no marker claimed,
and that looking should not be a script written from scratch each time.
And a test that no marker matches a frame a thread is rooted at. Four of
them briefly did, which put 28% of a build in `startup`: the outermost rule
claims every sample on a thread whose entry frame a marker names. The
mistake is invisible in the number a marker change is usually judged by,
because the unattributed share went down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The phase tables answer "what did the compiler do" and cannot answer "did it use the machine while doing it". Those come apart: bun's release build is 722 CPU-seconds spread over 519 seconds of wall clock on four cores -- 35% -- and its last 430 seconds are one thread with three cores idle. Nothing in a phase breakdown says that. Every phase in it looks healthy. So `sessions.parquet` gains three columns, swept out of the spans that were already there: `sampled_cpu_s`, `peak_threads`, and `serial_tail_s`, the trailing seconds during which never more than one thread was working. That last one is the number that says a build cannot be made faster by adding cores, and for a fat-LTO one-codegen-unit release profile it is most of the build. The CPU figure has an independent check: the machine's own `cpu_busy_mean`, read from /proc and never near this code, says 38% where the spans say 35%. The probe prints all three now, so a saved profile can be asked the same question without a payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The paragraph at the top of a build page answers "did this use the machine" with an average -- N cores, X% unused -- which a build can pass while ending with one thread and three idle cores for most of its wall clock. `serial_tail_s` is the sharper answer, so it goes in the same paragraph when it is a real share of the build. Also notes, in both places the vocabulary is written down, that the `process` segment and the `process` thread are different columns saying different things: one is which thread a sample was on, the other is what kind of work it was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Twenty-six phases in five families only works if the ramp inside a family is ordered, and two of them were not: `mir_build` and `borrowck` sat at 0.471 and 0.464 relative luminance, which is two names for one colour and they are adjacent in the legend. The five blues sat inside 0.03 of each other and separated only by hue. Both families are respaced and the spacing is written down next to them, because it is the kind of thing that drifts back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
…-gwe6cp Take the back end apart, and say how much of the build was watched
On bun's release build 0.75% of every sample was unattributed, and nearly all of it was one thing: loop strength reduction and the scalar evolution analysis it drives. LLVM enters that pass through a free function, and the unwinder does not get past it -- so the outermost frame on the stack is `ReduceLoopStrength` and nothing above it says the optimiser is running. The dev build did not show this, because at `-O0` the pass never runs. The release build is where the census's time actually is, and this was the last bucket in it with nothing in it but a shrug. Release goes 0.75% -> 0.06% unattributed, and the samples land in `optimize / loop`, taking it from 7.0% of the build to 8.1%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The third level was thin where it mattered. A release build of bun had a
quarter of its samples sitting at a phase with no detail under it -- and
`optimize`, which is 45% of that build, was the worst of them at 10.7% bare.
Sweeping every phase at once rather than one at a time, what was in there
was not noise. It was passes with names.
* `optimize` gains `sccp`, `globalopt`, `callgraph`, `cse`, `dce` and
`lower`: 10.7% -> 0.7%.
* `machine_code` gains `analysis`, `prepare` and `emit`, and its existing
`regalloc`, `frame` and `machine_opt` gain the passes they had always
meant: 5.0% -> 1.1%.
* `macro_expand` splits into `expand`, `reparse` and `proc_macro` -- the
last being the macro's own code, which is the number to look at before
blaming a build's macros in general.
* `mir_build` splits into `thir`, `build`, `match_check` and `unsafety`;
`metadata_encode` is mostly `mir_transform`, because encoding forces
`optimized_mir` for everything a dependent might inline; `codegen`'s
leftovers were `bitcode` and, once `codegen` was allowed to carry them,
the same `debug_info` and `layout` its neighbours already had.
* `startup` splits into `dyld`, `ctxt`, `thread` and `llvm_init`, and
`resolve`, `analysis`, `lint` and `ast_lowering` get theirs.
A quarter of the build -> 3.6%, and what remains is the right leftover: the
pass manager dispatching between passes, and the allocator. Neither gets a
marker, deliberately -- matching `AnalysisManager` once charged a quarter of
the optimiser to the dispatcher instead of to the pass that asked.
Two of these were worth measuring rather than assuming. `emit` -- the object
file reaching the disk, 2.3% of a debug build in `write` alone -- is matched
innermost and could have taken samples from `asm`; it took none, because
every one of those writes already had no pass above it. And `codegen`
carrying `debug_info` moved 4.3% of a debug build out of the leftover bucket
without touching what `ir_build` and `machine_code` already reported.
The probe grows a `*` mode for the question this pass kept asking: what has
no detail, everywhere, in one walk of the profile instead of one per phase.
`phase.rs` becomes a directory because the detail table is now the biggest
of the three and was pushing the file past its limit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
The viewer had grown into a page you searched rather than read. Every change here is one thing that was being said twice, said wrongly, or not said at all. Said wrongly. A unit nothing was sampled from wore a flat amber that sat 99 units of perceptual distance from `codegen` -- closer than `codegen` and `metadata_encode`, which are deliberately adjacent, at 143. On bun's release build, which is 78% codegen, the unmeasured units looked like the most measured thing on the page. They are hatched now. The lane chart's key said "the same colours as below", which asks a reader to scroll to a different chart and match a swatch from memory in order to answer "what is all this amber"; it carries its own key, ordered by how much of the build each phase took. Its bands carry their own tooltips, because the unit's title answered "which unit is this" and never "what is the colour under my cursor". Said twice. The session's forty-odd fields sat above the charts and again at the bottom; the dozen anyone wants are in the glance, so the table goes to the bottom with every other one, and the build's own config joins the glance rather than being a second table underneath saying the same kind of thing. "What is in it" and the flags column went the same way. "One observation" was true of every page on the site and so told a reader nothing about the one they were on -- it now reads `bun · release`, and says how many units, how long, and on what. Not said at all. `target` read `lib` on 170 units we had never checked: Cargo's timings report does not carry target kinds, only a display suffix that is empty unless the target's name differs from the package's. The rustc command line does carry them, and did all along -- eleven proc macros had been drawn as plain libraries. Where nothing recorded it the box is dashed, because "almost certainly a library" is not the claim "a library". `0.0.0` on 115 of bun's units was Cargo's placeholder for a crate that is not published; the profiler records nothing for those, and nothing is the truer answer, so they read `local` and are the ones not linked to crates.io -- linking them sends a reader to someone else's crate of the same name. Columns are one unit each now: seconds to two decimals throughout, which is the precision Cargo's timings actually carry, and megabytes throughout, because a column reading 4.2 GB above 999 MB is a column where the largest number looks like the smallest. Features showed four pills, which sized the column for the four rows in 223 that have five or more; two shows every feature of 94% of units at half the width. The phase mix is last, fills its cell, sorts by the time it draws, and has a switch between proportions within a unit and widths across the build. Two bugs found by measuring rather than looking: sub-megabyte outputs were emitting a bare `<`, which is not markup and 87 of 201 outputs hit it; and an assertion that read `<aside class="rail">[\s\S]*raw-sessions` was greedy across the whole document, so it kept passing after sessions left the rail while testing nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ
The phases nothing had ever been said about, said: `parse` splits into `path` and `tokens`, `trait_solve` into `select` and `normalize`, `const_eval` into `interpret` and the solving it drags in, `resolve` gains `late`, `ir_build` gains `declare` -- the symbol names, ABIs and attributes a codegen unit needs before any of it is filled in. Two cross-cutting names, because two costs are cross-cutting. `dep_graph` is the incremental dependency graph being written *as a phase runs* rather than at the end: a third of everything type checking did that type checking itself could not explain. `hash` is the stable hashing the same system needs. Neither belongs to one phase and both were invisible inside all of them. And `metadata_encode` finally gets `encode`, which is worth having only for the contrast with the `mir_transform` beside it: writing a crate's metadata spends more time running MIR passes than writing anything. 2.8% of a release build now has no detail, from a quarter before this and the commit before it. One name was measured and taken back out. `emit` -- the write syscall -- reads well in `machine_code`, where it took nothing from `asm` because those writes had no pass above them. Inside `link` the same marker took 11,860 samples off `archive` and left it 732: there the writes *are* the archive being built, and `archive` is the better name for them. A leaf marker is only safe where the leaf is all there is, and that is now written next to it. What is left unnamed is the pass manager dispatching between passes, the allocator, and `memcpy`. None of them will get a marker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
Segment, phase and detail are three grains of one question -- why was the compiler here -- and they nest. This is a different question that does not nest with them, so it gets a column rather than a fourth level: what was running. It is worth a column because the answer is "not a compiler pass" 24% of the time on a release build and 45% on a debug one, and none of that was visible. It is spread across two dozen phases in slices too thin to notice, and the phase it is spread across is never wrong: `optimize` really was optimising, inside a `DenseMap`. The two builds have completely different shapes, which is the finding: concern release debug pass_manager 6.5% 0.8% alloc 5.0% 3.9% hashmap 4.8% 5.9% memcpy 2.8% 7.0% intern 2.1% 5.7% query 1.3% 4.5% io 0.8% 11.6% hash 0.2% 3.5% total 24.2% 45.0% A release build's largest single concern is LLVM choosing what to run next. A debug build's is the machine waiting on its disk -- 11.6% of every sample, which no compiler flag addresses. # The rule, and why it is the weak one A concern is the leaf frame or nothing. Not the innermost match on the stack; the leaf. The stronger rule -- a named concern claiming everything beneath it, so a `memcpy` inside a hasher counts as hashing -- was measured and rejected. Across ten concerns it changed nothing worth having: every one moved by less than a percentage point except `dep_graph`, which moved seventeen-fold. That one number is the entire case for it, and it is the one to trust least, because claiming a subtree from an enclosing frame is how `pass_manager` first measured 57% of a build. The pass manager is on the stack of every sample the optimiser takes, so "matched anywhere" meant "matched always". Leaf-anchored has no such failure mode and no parameter to tune, and a test pins the case: a sample inside `InstCombine` is `instcombine` with no concern, even with the pass manager one frame up. # What moved, and what did not `dep_graph` and `hash` were details until now, which was the wrong column: they are machinery, not what the phase set out to do, and as details they competed with the phase's real details for one slot. They move here. That is the only thing that changed in the phase tables, and it is accounted for exactly: three `hash` detail rows, 852 samples, redistributed into `lower`, `typeck` and `encode` in the same phases they were already in. 852 out, 852 in, nothing crossed a phase boundary. Nothing else moved a sample, which is the property the axis has to have -- summing over `concern` gives the phases back. And `dep_graph` turns out to be almost nothing: 0.3% of a debug build and none at all of a release one. It is the concern that prompted this and the smallest thing in it. What it cannot answer -- what incrementality costs -- wants a build run twice and the elapsed times compared, which is a thing this census is shaped to do and no attribution of one run can replace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015iY4ffQbZC8ix4g4fs1BR5
…-gwe6cp Label everything the stacks can name, and add the axis they could not
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An upload used to end with a link to the bytes. Following it got you a
zstd-compressed frame of parquet files — honest, and unreadable without the
client, a decompressor and a parquet reader.
Now the site reads it for you:
Four commits: the viewer, then somewhere to walk around in it, then the truth
about how long any of it lasts, then saying that truth at the moment it
matters.
1 — Show a contributor the observation they just uploaded
crates/worker-ingestanswers withviewalongsideurl, from a newVIEW_BASEbinding. Each field is absent when its domain is not bound, asurlalready was.crates/clientprintsviewwhen the collector offers one andurlotherwise, so
cargo cratebank serveis unaffected.infra/worker/site.tsis a new Worker on the site. It reads R2 throughits own
BUCKETbinding,bundle.tsdecompresses and unframes,render.tsrenders,
view.cssstyles.build-site.tsalso writesdist/site/viewer.css, which the pages linkrather than inline.
2 — Make the viewer somewhere you can walk around
/view/used to be a 404 explaining what a key looks like. It is now the topof the space, and every partition between it and an object is a page: schema
fingerprints, then years, months and days, then the uploads — each level
newest first, with a trail back up from anywhere. The front page also walks
down to the newest day in the last few schemas so recent uploads are one click
from the top; that walk is capped, because a bucket is not. A prefix nothing is
filed under is a 404 rather than an empty page pretending to be a place.
An observation opens with what a person came to find out — an M2 Pro compiled
42 units in 37.4 s on dev — rather than the first of 47 fields, then lists
what is in the object with row counts, so tables can be jumped to instead of
scrolled past. Truncated tables link to the rest of themselves;
?rows=Nraises the 200-row cap as far as 2000.
Bots are refused, four ways:
/robots.txtdisallows/view/,X-Robots-Tagis on every response, every page repeats it in a robotsmetatag, and a request whose user agent names a crawler, a scraper or a link
preview gets a 403 pointing at
data.cratebank.io. The regex deliberatelyavoids a bare
botmatch —CUBOT NOTE 20is a phone, not a crawler — and istested against six bot agents and four browser ones. A user agent is a claim
rather than proof, so this stops the cooperative and the lazy; what it protects
is not secrecy (every byte is public) but the cost of serving pages nobody
reads.
3 — Say what a staged object's lifetime actually is
Opening the deployed preview against the production bucket showed
sessions/empty:
worker-compactdeletes every bundle it merges, so with a dailycron a viewer link is good for about a day.
The viewer had been telling anyone who followed a dead link that uploads are
kept indefinitely and they must have mistyped the key. Both halves were wrong,
and sending that person off to check their typing is the worst answer
available. So the 404 now names compaction and points at the published tables
where the rows went, an observation page says the object is staged rather than
archived, and the listing says it shows the recent past rather than the census.
4 — Tell a contributor how long their link is good for
The lifetime is now said where somebody is actually looking at the link: a
second line under the URL the client prints.
The schedule comes from the collector, not the client. Ingest carries a
COMPACTIONvar and returns it besideurlandview. A client thathardcoded
05:00 UTCwould be claiming it aboutcargo cratebank serveandabout anybody else's collector, neither of which compacts at all — so a
collector that says nothing has nothing said on its behalf and the line is
simply not printed. "Typically" because a deploy compacts too.
COMPACTIONmust be kept in step with the cron incrates/worker-compact/wrangler.jsonc. Nothing enforces that; both arecommented to point at each other.
Decisions worth a look
data.cratebank.io. Same origin, so no CORS rule to keepin step, and the pages do not depend on the public data domain. It costs the
site Worker a bucket binding.
run_worker_first: ["/view/*"]. Every other path is still an assetserved without waking any code.
sessions/is ever read or listed, so the publishedtables and the client manifest sharing the bucket stay outside the viewer;
200 rows per table by default, counts taken from parquet footers; objects
over 16 MB compressed are linked rather than opened.
Testing
npm test— 15 tests: framing, row counts, escaping, routing, key and prefixshapes, percent-encoding, an object that will not open, browsing, the bot
refusal, and the header on every response.
cargo test --workspace --all-features(56),clippy -D warnings,fmt --check, andcargo check --target wasm32-unknown-unknownfor theingest Worker.
wrangler dev: a bundle fromcratebank_corePOSTed tothe ingest Worker, the returned
viewURL opened on the site Worker, allnine tables rendered from R2; then browsing across three objects in two
schemas,
?rows=, the empty-prefix 404. Checked light and dark. The realingest Worker was re-run after commit 4 to confirm
compactioncomes back inthe JSON.
serve without waking the Worker, the viewer answers, Googlebot gets a 403
without the bucket ever being touched,
robots.txtandX-Robots-Tagarelive.
Still open, for the repo owner
Both are about the same gap and neither is mine to decide:
bundles (unbounded storage, and compaction would need to remember what it
has read) or rebuilding the page from the published tables.
README.mdandsite.mdsaysessions.parquetcarries the key of theblob each row came from. It has no such column and nothing writes one, so
there is no path from a published row back to its object — which is also
what blocks option (2) above.
Leaving the link as a receipt and saying so plainly is what this PR does.
5 — The build, drawn
Cargo's
--timingsreport answers three questions — when each unit ran, how busy the machine was, and where the time went. A contributor has seen that page already; it is the thing they ran to make this object. A viewer that showed only tables was asking them to rebuild it by eye.All three are drawn now, from the stored tables, as server-rendered SVG with no script — an SVG
<title>is a tooltip in every browser, and a chart that needs JavaScript is a chart that does not work in acurl.And then two Cargo cannot draw, because it does not sample:
parquet v59.2.0Worth staring at: Cargo says 100% frontend, the samples say 71% codegen. Not a contradiction — Cargo's "frontend" is wall time up to rmeta, and a unit with no rmeta split reports all of it that way. The samples are what actually ran. That gap is the argument for collecting phases at all.
A fully cached build says so rather than drawing 167 zero-width rows. That will be the common case now the organisation contributes from CI with warm caches.
6 — One table where there were five
units,phases,unit_flags,compiler_unitsandartifactsare all keyed to a unit, so reading them apart meant doing the join by eye, five times, for every unit you were curious about.Artifacts join on
unit_index— exact, not by name; the only table that can. Sorted longest first and every column sorts, which is what replaced a separate "slowest units" list: a table you can sort already is one.clone-impls, default, derive, extra-traits, fullis a sentence you have to read; five pills is a shape you count.run_idis gone from every table — identical on all 4,000-odd rows, and in the rail once.data-sortso sorting and the JSON download keep the precision the page does not need.The charts were being built from the first 200 rows.
phaseshas 1,957 and the display cap applied to the join too — 18 phase bars instead of 165. The tables a join needs are read in full now while the raw sections still show 200 with show them all. A gantt drawn from a tenth of the data is worse than one that admits it cannot draw.7 — A page, rather than a list of tables
Two columns. Configuration in a sticky rail — the glance,
sessions,build_config, the contents — because checking a chart against which rustc was this is the thing the single column made you scroll back up for. Measurements in the column that scrolls. The stored tables fold into a<details>: still the object, still what the JSON download reads, no longer in the way.Full width.
mainwas capped at 46rem by the site's stylesheet — a measure for prose. This is a page of tables and charts, where every rem is another column on screen.Sorting is
sortable-tablesort, 1.7 KB of vanilla JavaScript copied out ofnode_modulesat build time, not a CDN link. A page that reaches off its own origin to sort a table stops sorting when someone else's host has a bad day.Download as JSON reads the DOM:
Every row is already on the page, so embedding them again would double the page to save a walk over it — and it makes the download honest, containing exactly what is on screen, with
?rows=changing both together.8 — A tree
/view/was one level of partitions per page: four clicks to reach one of a dozen objects, which is a filing system standing in for a list. It is the whole staged keyspace now, as nested<details open>— no script, and every object one click from the top.One
listwithout a delimiter returns keys rather than one level of them. That is affordable because compaction deletes what it merges: this is a day or two of uploads, never the history of the census.9 —
/data/*, and somewhere to ask questionsThe published tables had no CORS headers. Checked before assuming:
Range requests work, so partial parquet reads would be efficient — but a browser on this site could not fetch a byte.
/data/*serves the same objects from this origin with the request's own headers passed to R2, which is what keeps a query to three or four ranged reads instead of a download. The allowlist is shapes rather than names, so a new table needs no edit here, andsessions/is deliberately excluded — staged objects belong to the viewer./explore/runs DuckDB against those tables and hands the Arrow to Perspective, which pivots, filters and plots it in a toolbar — 13 chart types, no second query written. Every table is a view over the parquet, not a copy, so opening the page costs one footer read each and a query costs only the columns it names.Both engines are self-hosted; nothing is fetched from a CDN and no query leaves the tab. Perspective's 3.4 MB of wasm is a static asset. DuckDB's 34 MB is past what an asset may be, so it lives in the bucket keyed by its version — an immutable URL caches for a year, an overwritten one cannot cache at all — and the deploy publishes it once per DuckDB release, checking the public URL because that is the one the browser will use.
A loading screen covers the panel while ~40 MB arrives, with a real percentage from DuckDB's own progress handler, an indeterminate sweep when there is no share to report, and no animation under
prefers-reduced-motion.Four bugs, none findable without running it
Missing perspective-client.wasm. The plain ESM builds want every wasm module initialised by hand with a URL; getting it wrong is a blank page. The.inline.jsbuilds carry their own.worker.table is not a function.perspective.worker()resolves to the client; it is not one.Failed to open RecordBatchStreamReader. Two copies ofapache-arrow— duckdb-wasm pins^17, I had installed 21 — sotableToIPCdid not recognise the table and returned nothing. Pinned to 17 and deduped. Also"file"→"stream"; Perspective's reader never looks for a footer.phase, handed a result with nophase, drew a hundredd="…vNaNh…"paths and reported nothing. A changed shape resets to the table before loading — resetting after still let one frame render against the old config, which is why the first fix did not take.And one that had nothing to do with the browser: three template literals in
chart.tshad a NUL byte where a space belonged. Invisible in an editor, type-checked fine, and it stayed hidden because both sides of the memory and phase joins had it — so those worked, and the flags join, written later with a real space, was the one that silently returned nothing.Also corrected
site.mdclaimedsessions.parquetcarries the key of the blob each row came from. It has no such column and has not since the bundle format landed, which is item (2) of Still open above — now stated where it is read, and the viewer links to the bytes it read instead. The viewer link on the front page is its own section and relative, and both the site and the viewer have a favicon.Checked
tscclean on both projects, 15 viewer tests, 79 Rust tests, clippy clean with all features./explore/driven in a browser end to end: 0 console errors, four benign warnings from Perspective noting it is in inline mode.Not checked
Nothing tests the explorer or the
/data/*guards. The two are worth a test each before this is relied on.🤖 Generated with Claude Code
https://claude.ai/code/session_01PWfUsPpGVt97QK8RYUF5U7
https://claude.ai/code/session_01361quprG7tUgaVFKAmKtJZ