Serving large CalCOFI CTD profiles on ERDDAP: format × schema × granularity

NetCDF vs DuckDB vs Parquet vs CSV — memory, latency, and layout, for the CoastWatch ERDDAP

Author

CalCOFI (B. Best) — for Lynn DeWitt & the ERDDAP community

Published

2026-07-25

Summary

CalCOFI’s CTD profile data is large in long (tidy) format — one row per cast × depth × measurement_type (~53 sensor types): ctd_measurement is ~232 M rows / ~16 GB of Parquet, plus an adaptively-thinned headline table ctd_thin (~5.5 M rows). We want to serve it on ERDDAP without the memory blow-up that disabled an earlier single-file attempt. To get an apples-to-apples read, we benchmarked a controlled grid of four axes at a deliberately small 2 GB JVM heap:

  • format — NetCDF (EDDTableFromNcCFFiles), DuckDB (EDDTableFromDatabase), Parquet (EDDTableFromParquetFiles), CSV (EDDTableFromAsciiFiles)
  • schemawide (canonical sensors as columns) vs long (tidy)
  • granularitysplit (one file per cruise, 96 files) vs lumped (one file for the whole table) — for the file formats; DuckDB is granularity-agnostic
  • query — whole-dataset (load) vs subset (filtered)
TipThe recommendation, up front

Serve every dataset the same way: ERDDAP EDDTableFromDatabase over DuckDB views on the integrated CalCOFI release Parquet — long format, one backend, no per-dataset variants. Generate NetCDF as whole-dataset files outside ERDDAP for the CF/modelling audience, rather than as a second live backend.

The one honest exception is scale, not format: the full-resolution ctd_measurement (232 M rows) is queryable through this path but not bulk-downloadable — an unconstrained dump OOMs at any container size we would reasonably give ERDDAP. Bulk access to that table is what the versioned Parquet release is for. §5 measures exactly where that boundary sits.

Serving one table in two formats (or wide in one place and long everywhere else) costs more in user confusion than it buys in performance — so the axes below are reported as evidence, not as a menu of things to deploy simultaneously.

What the grid shows (three findings, the first two mapping to the two figures):

  1. Granularity is a memory lever only once a single file is large relative to the heap (Fig 1). ERDDAP’s file backends read whole matching files onto the heap. A 935 MB single ctd_wide.parquet is what OOM’d the original attempt at a 4 GB heap, and a lumped 598 MB Parquet still loads at 1.6 GB. But the effect is proportional to file size, not intrinsic to lumping: after the profile-grain fix (§1) the lumped ctd_measurement NetCDF fell from 2.1 GB to 686 MB and now loads in 19 s at a 1,079 MB heap — where the old oversized file timed out. Per-cruise splitting still loads lighter (66 MB) and prunes better, but “lumped always fails” was an artefact of a file that should never have been that big. DuckDB is flat (~65 MB in every config) because it streams.
  2. Split serves subsets fast; lumped re-reads everything (Fig 2). A per-cruise split file answers a cruise query in well under a second (one cruise → one file); a lumped file has no such pruning, so every query re-reads the whole file — a cruise dump took 74 s lumped vs 1.2 s split (even when the lumped file fits in memory). DuckDB’s on-the-fly wide pivot view is the exception that proves the rule: cheap to load but slow on broad cross-cruise scans (76 s), so keep the tidy long view live.
  3. NetCDF, the native path, is per-cruise = one dataset — but per-cast is also viable. CalCOFI has 14,290 casts (not the 5.55 M an earlier revision reported — that was the depth-scan count; see the grain correction in §1). One file per cast à la Trinidad Head is therefore perfectly serveable; we still bundle by cruise = 96 files because it loads lighter and prunes cruise-scoped queries to a single file, but the per-cast option is a choice now, not an impossibility.

Live now at https://erddap.calcofi.io (calcofi_ctd_thin, calcofi_ctd_measurement, DuckDB-long). Everything needed to reproduce is in §7.

Code
library(dplyr); library(readr); library(tidyr); library(knitr); library(stringr); library(ggplot2); library(forcats)
res <- read_csv("data/bench_erddap/results_all.csv", show_col_types = FALSE) |>
  mutate(format = factor(format, levels = c("duckdb","netcdf","parquet","csv")),
         schema = factor(schema, levels = c("wide","long")),
         granularity = factor(granularity, levels = c("na","split","lumped"),
                              labels = c("view (DB)","split (per-cruise)","lumped (1 file)")),
         table = factor(table, levels = c("thin","measurement"),
                        labels = c("ctd_thin (5.5M rows)","ctd_measurement (232M rows)")),
         loaded = load_status == "loaded")
lay_path <- "data/bench_erddap/layout_results.csv"
lay <- if (file.exists(lay_path)) read_csv(lay_path, show_col_types = FALSE) else NULL
PAL <- c("split (per-cruise)" = "#1b9e77", "lumped (1 file)" = "#d95f02", "view (DB)" = "#7570b3")

1 · Background

CalCOFI has occupied the California Current quarterly since 1949. The CTD cast files (https://calcofi.org/data/oceanographic-data/ctd-cast-files/) are ~1 GB as a single wide Parquet. We ingest them into a tidy long schema (ingest_calcofi_ctd-cast, https://calcofi.io/workflows/ingest_calcofi_ctd-cast.html): each sensor reading on its own row keyed by measurement_type, convenient for analysis but multiplying row count. Tables (release v2026.06.08 / supplemental v2026.04.08):

table rows depth scans casts Parquet note
ctd_cast 5.55 M 5.55 M 14,290 128 MB one row per depth scan; carries time,lat,lon
ctd_thin 5.55 M 0.42 M 7,155 155 MB adaptively-thinned headline table
ctd_measurement 232 M 5.33 M 14,008 ~16 GB full long table (96 cruise_key partitions)

The long tables are keyed by ctd_cast_uuid and carry no coordinates — time/latitude/longitude live in ctd_cast, so every representation re-joins them.

WarningGrain correction (2026-07-25) — read this before reusing earlier numbers

An earlier revision of this page reported “~5.55 M casts” and used it to argue that per-cast NetCDF was infeasible. That figure was the depth-scan count, not the cast count: ctd_cast_uuid is md5(cruise_key|cast_key|cast_dir|datetime_utc) with a per-scan timestamp, so one station occupation carries hundreds of them.

CalCOFI actually has 14,290 casts — 7,218 station occupations (75 per cruise, the standard grid) across 96 cruises, each occupation contributing a down- and an up-cast. Three consequences, all reflected below:

  1. Per-cast files are feasible after all (§3, §5) — 14 k files is an ordinary ERDDAP collection, so the option Lynn asked about is genuinely on the table.
  2. The NetCDF artifacts were rebuilt: profiles are now keyed by (cruise_key, ord_occ, cast_dir). The old files stored one single-point profile per scan (rowSize == 1), which is not a profile at all. The corrected collections are ~3× smallerctd_measurement split went 2.1 GB → 687 MB, lumped 2.24 GB → 686 MB.
  3. Because the lumped file shrank below the heap, the load-memory finding was re-measured rather than assumed (§4).

2 · How ERDDAP serves files — and the NetCDF “per-cruise = one dataset” point

A persistent point of confusion: per-cruise files do not mean a dataset (or form) per cruise. Every ERDDAP …FromFiles type points at a directory (fileDir + fileNameRegex) and aggregates all matching files into one datasetID — one Data Access Form, one .das, with sliders/min-max rolled up across the whole collection. The per-cruise split is purely storage: 96 files versus 14,290 per-cast ones, while the user sees a single dataset either way. (Both are indexable — ERDDAP routinely serves collections of that size. Per-cruise wins on load time and on pruning a cruise query to one file, not on feasibility.) ERDDAP prunes by cruise_key (a subsetVariable) and by each file’s time/lat/lon min-max, so a cruise-scoped query opens one file and a broad spatial query opens many.

The flip side is the memory model: for file backends ERDDAP reads each matching file fully onto the JVM heap before filtering. That is the entire origin of the memory problem — a single 935 MB ctd_wide.parquet OOM’d a 4 GB heap and dropped the dataset from every LoadDatasets run. The benchmark below isolates that as the granularity axis. DuckDB is the exception: via JDBC it streams a filtered ResultSet from the underlying Parquet (predicate pushdown + partition pruning + disk spill), so the whole table is never on the heap.

ImportantTwo DuckDB gotchas

Serve time as a real TIMESTAMP in the view (not epoch double) or the DuckDB JDBC driver NPEs on every query that selects time. And EDDTableFromDatabase does not auto-compute variable ranges — set actual_range (+ geospatial_*) or the form shows no sliders. (NetCDF computes these from the files automatically.)

3 · The benchmark grid

Isolated ERDDAP (custom image with the DuckDB JDBC jar) on our host, 2 GB heap. We serve the same canonical data every way and run the same-intent queries: q1 .das; q2 one cruise + temperature (subset); q3 one variable (salinity) over depth 0–200 m across all cruises (subset, cross-file); q4 whole-cruise dump. In wide schema a query selects a named column; in long it filters measurement_type — chosen so q2/q3 return the same rows either way.

Code
res |>
  arrange(table, schema, format, granularity) |>
  transmute(table, format, schema, granularity, load = load_status,
            `load heap MB` = load_peak_heap_mb,
            `q2 cruise ms` = na_if(q2_cruise_ms, -1),
            `q3 var+depth ms` = na_if(q3_var200_ms, -1),
            `q4 dump ms` = na_if(q4_dump_ms, -1)) |>
  kable()
table format schema granularity load load heap MB q2 cruise ms q3 var+depth ms q4 dump ms
ctd_thin (5.5M rows) duckdb wide view (DB) loaded 66 535 2154 630
ctd_thin (5.5M rows) netcdf wide split (per-cruise) loaded 66 61 901 71
ctd_thin (5.5M rows) netcdf wide lumped (1 file) loaded 66 100 593 274
ctd_thin (5.5M rows) parquet wide split (per-cruise) loaded 66 204 2897 361
ctd_thin (5.5M rows) parquet wide lumped (1 file) loaded 1546 1495 2052 3295
ctd_thin (5.5M rows) csv wide split (per-cruise) loaded 1057 143 2759 109
ctd_thin (5.5M rows) csv wide lumped (1 file) loaded 1141 2331 2906 6180
ctd_thin (5.5M rows) duckdb long view (DB) loaded 66 686 1699 1807
ctd_thin (5.5M rows) parquet long lumped (1 file) loaded 66 12830 13896 34356
ctd_thin (5.5M rows) csv long lumped (1 file) timeout 1635 NA NA NA
ctd_measurement (232M rows) duckdb wide view (DB) loaded 65 4188 76548 3008
ctd_measurement (232M rows) netcdf wide split (per-cruise) loaded 66 248 5326 716
ctd_measurement (232M rows) netcdf wide lumped (1 file) loaded 1079 527 4510 2577
ctd_measurement (232M rows) parquet wide split (per-cruise) loaded 1849 1007 23546 1230
ctd_measurement (232M rows) parquet wide lumped (1 file) loaded 1655 19619 23221 73675
ctd_measurement (232M rows) duckdb long view (DB) loaded 63 4863 23896 99139
ctd_measurement (232M rows) parquet long split (per-cruise) timeout 1981 NA NA NA

Finding 1 — load memory: lumped vs split (and DuckDB streams)

Code
res |>
  mutate(lab = ifelse(loaded, paste0(load_peak_heap_mb), toupper(load_status))) |>
  ggplot(aes(format, load_peak_heap_mb, fill = granularity)) +
  geom_col(position = position_dodge2(preserve = "single"), width = 0.85) +
  geom_text(aes(label = lab, colour = loaded),
            position = position_dodge2(width = 0.85, preserve = "single"),
            vjust = -0.3, size = 2.6, show.legend = FALSE) +
  geom_hline(yintercept = 2048, linetype = "dashed", colour = "grey45") +
  facet_grid(schema ~ table) +
  scale_fill_manual(values = PAL, name = "granularity") +
  scale_colour_manual(values = c(`TRUE` = "grey20", `FALSE` = "#d95f02")) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.18))) +
  labs(x = NULL, y = "peak load heap (MB); dashed = 2 GB cap") +
  theme_minimal(base_size = 11) + theme(legend.position = "top")
Figure 1: Peak JVM heap during dataset load at a 2 GB cap, by format and granularity. DuckDB (purple) stays ~65 MB whatever the schema/size because it streams. The file formats rise with what ERDDAP must read; the lumped 2.1 GB measurement NetCDF blows past the cap and times out (labeled). Bars are labeled with peak MB, or the failure mode.

Finding 2 — query latency: split serves subsets fast, lumped re-reads everything

Code
lbl_ms <- function(x) ifelse(x >= 1000, paste0(round(x/1000, 1), "s"), paste0(round(x), "ms"))
res |>
  filter(loaded) |>
  transmute(table, format, granularity,
            `q2 cruise` = na_if(q2_cruise_ms, -1),
            `q3 var+depth` = na_if(q3_var200_ms, -1),
            `q4 cruise dump` = na_if(q4_dump_ms, -1)) |>
  pivot_longer(starts_with("q"), names_to = "query", values_to = "ms") |>
  filter(!is.na(ms)) |>
  ggplot(aes(format, ms, fill = granularity)) +
  geom_col(position = position_dodge2(preserve = "single"), width = 0.8) +
  geom_text(aes(label = lbl_ms(ms)),
            position = position_dodge2(width = 0.8, preserve = "single"),
            vjust = -0.3, size = 2.2) +
  facet_grid(query ~ table) +
  scale_fill_manual(values = PAL, name = "granularity") +
  scale_y_log10(expand = expansion(mult = c(0, 0.25))) +
  labs(x = NULL, y = "median latency (log scale)") +
  theme_minimal(base_size = 10) + theme(legend.position = "top")
Figure 2: Median latency (log scale) for loaded cells. SPLIT files serve cruise-scoped queries (q2, q4) fastest — one cruise = one file. LUMPED files re-read the whole file on every query (q4 cruise-dump up to 74 s). DuckDB shows TWO purple bars — its wide-pivot and long views; the wide view is slow on the broad cross-cruise scan (q3: 76.5 s wide vs 23.9 s long) because it aggregates the whole table on the fly, while it’s faster on the cruise dump (q4: 3 s wide vs 99 s long, fewer rows).

Reading the grid.

  • Load memory (Fig 1). DuckDB ≈ 65 MB in every cell — wide or long, thin or 232 M rows — because it never holds a file on-heap. The file formats scale with what ERDDAP reads: per-cruise split measurement loads at ~1.1–1.8 GB (it indexes 96 files), and the lumped 2.1 GB measurement NetCDF exceeds the cap and times out (ERDDAP starts shedding requests under memory pressure). The smaller 598 MB lumped Parquet squeaks in at 1.6 GB — granularity bites once the single file is large relative to the heap.
  • Query latency (Fig 2). Split files serve cruise-scoped q2/q4 in well under a second (one cruise_key → one file). Lumped files have no such pruning, so every query re-reads the whole file — the lumped-Parquet cruise dump took 74 s vs 1.2 s split, and lumped CSV/Parquet q2 are seconds, not milliseconds. DuckDB-wide’s broad q3 is 76 s because the pivot view aggregates the whole 232 M-row table on the fly; the DuckDB-long view does the same q3 in 24 s and is what we serve live.
  • Apples-to-apples format (wide, split). With schema and granularity held fixed, NetCDF-split and Parquet-split are the fast file formats (sub-second cruise queries); DuckDB is lightest on memory but slowest on broad aggregation; CSV works only at thin scale.
  • Schema (wide vs long). For the materialized file formats it’s close. For DuckDB the on-the-fly wide pivot is slower than long on cross-cruise scans — a reason to keep the tidy long view live and reach for wide only behind a materialization.

4 · Partitioning & ordering within the long table (Lynn’s question)

“I wonder how organizing each sensor reading on its own row affects a query for data within a lat/lon/depth box.” — Lynn DeWitt

Beyond split-vs-lumped, within the long table we measured (in DuckDB, the serving engine) the same bounding-box query — temperature_ave, 32–34°N, 119–121°W, 0–200 m (~393 k rows) — across four physical layouts:

  • A raw Parquet partitioned by cruise_key, joined to ctd_cast at query time (live now); B denormalized, partitioned by cruise_key; C denormalized, partitioned by measurement_type; D = C plus rows sorted by latitude,longitude,depth within each partition.
Code
lay |>
  transmute(layout, `build s` = build_secs, size, files = nfiles,
            `query s (median)` = query_secs_median, `result rows` = result_rows) |>
  kable()
layout build s size files query s (median) result rows
A_prodview_cruise_join 0 (view over cruise-partitioned raw + JOIN) 95 0.875 393271
B_denorm_cruise 188 17G 95 0.223 393271
C_denorm_type 166 17G 53 0.104 393271
D_type_spatialsort 686 17G 53 0.075 393271
Code
lay |>
  mutate(layout = fct_rev(factor(layout))) |>
  ggplot(aes(layout, query_secs_median)) +
  geom_col(width = 0.7, fill = "#7570b3") +
  geom_text(aes(label = paste0(formatC(query_secs_median, format="f", digits=3), " s")),
            hjust = -0.15, size = 3) +
  coord_flip() + scale_y_continuous(expand = expansion(mult = c(0, 0.18))) +
  labs(x = NULL, y = "median query (s)") + theme_minimal(base_size = 11)
Figure 3: The same ~393k-row bbox query across four physical layouts of the long table (DuckDB on SSD). ~12× spread, but every layout is already sub-second.

Decomposing the ~12× spread: removing the query-time join ≈ 4× (A→B); partitioning by measurement_type ≈ 2× (B→C, a one-variable query opens 1 of 53 partitions); spatial ordering ≈ 1.4× (C→D, row-group skipping). The trade-off is the one you raised: partitioning by measurement_type helps single-variable queries but slows “all variables in a region.” Every layout is already sub-second, so for the long table this is an optimization, not a rescue.

5 · Download breadth and the real memory limit

Every query in §3 is bounded — one cruise, or a depth slice. None of them reproduce what actually breaks for users: pressing Submit on the Data Access Form with nothing constrained. This section varies breadth alone — same dataset, same backend, no row filter — across four requests: metadata only (.das), one variable, three variables, and all variables, and sweeps the container memory limit to find where each dataset stops working.

ImportantThe JVM heap is not the constraint — and that is the whole point

Across every measurement below the JVM heap never exceeds ~1.7 GB, including in both out-of-memory failures. The memory that kills these requests is the DuckDB JDBC driver’s allocation outside the Java heap. Two consequences:

  • Raising ERDDAP_MEMORY (-Xmx) cannot fix an OOM here. It is the knob that looks like it should, which is why the failure is confusing in practice.
  • The bench container therefore carries a hard cgroup cap (mem_limit in docker-compose.bench.yml), not just a heap size. Without it an unconstrained query against ctd_measurement exhausted a 16 GB host and took the live services down — the cap turns that into a recorded status=oom instead.

Memory is reported as cgroup anon (true allocation), not memory.current: the latter includes reclaimable page cache from reading Parquet and overstates the requirement roughly threefold.

Two methodological notes, both learned the hard way:

  • Time-to-first-byte, not total time. A backend that streams emits almost immediately no matter how large the result; one that materializes first shows a long TTFB and then either dies or dumps at line speed.
  • Small datasets prove nothing about large ones. The five non-CTD ERDDAP datasets (casts, dic, euphausiids, zooplankton, phytoplankton) are 0.4–23 MB complete — they are event/sample tables, not measurement tables at CTD scale. They all serve full downloads comfortably at ~1.6 GB, but that is a statement about their size, not about the serving backend.
Code
dl_path <- "data/bench_erddap/download_results.csv"
dl <- if (file.exists(dl_path)) {
  read_csv(dl_path, show_col_types = FALSE) |>
    filter(query != "load") |>
    mutate(query   = factor(query, levels = c("allvars_das", "onevar_1", "onevar_3", "allvars"),
                            labels = c(".das", "1 var", "3 vars", "all vars")),
           backend = paste0(format, "/", schema),
           cap_gb  = as.numeric(sub("g$", "", mem_cap)),
           ok      = status %in% c("ok", "capped"),
           anon_gb = peak_anon_mb / 1024,
           MB      = bytes / 1048576)
} else NULL

Which datasets serve a complete, unconstrained download? One row per dataset × container cap; oom means the kernel killed the container at that cap.

Code
dl |>
  select(table, backend, mem_cap, query, status) |>
  pivot_wider(names_from = query, values_from = status) |>
  left_join(dl |> group_by(table, mem_cap) |>
              # guard the empty case: a cell with no "all vars" row (run still in
              # flight, or skipped) must read NA, not max()'s -Inf
              summarise(`peak anon GB` = round(max(anon_gb), 2),
                        `all-vars MB`  = {v <- MB[query == "all vars"]
                                          if (length(v)) round(max(v), 0) else NA_real_},
                        .groups = "drop"),
            by = c("table", "mem_cap")) |>
  arrange(table, mem_cap) |>
  kable()
table backend mem_cap all vars .das 1 var 3 vars peak anon GB all-vars MB
casts parquet/wide 4g ok ok ok ok 1.59 8
ctd_measurement duckdb/long 4g oom ok ok oom 3.90 0
ctd_measurement duckdb/long 5g NA ok ok oom 4.22 NA
ctd_measurement duckdb/long 6g oom ok ok oom 5.97 0
ctd_thin duckdb/long 4g oom ok ok ok 3.43 0
ctd_thin duckdb/long 5g ok ok ok ok 4.08 999
ctd_thin duckdb/long 6g ok ok ok ok 4.16 999
ctd_thin_nc netcdf/wide 4g ok ok ok ok 1.57 79
dic parquet/wide 4g ok ok ok ok 1.53 0
euphausiids parquet/wide 4g ok ok ok ok 1.52 1
phytoplankton parquet/wide 4g ok ok ok ok 1.60 23
zooplankton parquet/wide 4g ok ok ok ok 1.65 20
Code
dl |>
  filter(peak_anon_mb > 0) |>
  ggplot(aes(query, anon_gb, fill = ok)) +
  geom_hline(aes(yintercept = cap_gb), linetype = "dashed", colour = "grey55") +
  geom_col(width = 0.75) +
  geom_text(aes(label = ifelse(ok, "", "OOM")), vjust = -0.35, size = 2.3,
            colour = "#d95f02") +
  facet_grid(mem_cap ~ table) +
  scale_fill_manual(values = c(`TRUE` = "#1b9e77", `FALSE` = "#d95f02"),
                    name = NULL, labels = c(`TRUE` = "served", `FALSE` = "OOM")) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.2))) +
  labs(x = NULL, y = "peak off-heap allocation (GB)") +
  theme_minimal(base_size = 9) +
  theme(legend.position = "top", axis.text.x = element_text(angle = 25, hjust = 1))
Figure 4: Peak OFF-HEAP allocation (cgroup anon) as a request widens from metadata to every variable. Dashed lines are the container caps tested. Bars crossing their cap are OOM (orange). The five small event tables sit flat at the ~1.5 GB JVM baseline; ctd_thin climbs steeply with breadth and needs >4 GB for a full dump; ctd_measurement exceeds even 6 GB from three variables onward. Heap is never the binding constraint.

Reading it. ctd_thin in long format needs between 4 and 5 GB for a full download (OOM at 4 GB, completes at 5 GB using ~4.1 GB, delivering ~1 GB of CSV in ~90 s). ctd_measurement is the wall: a single variable streams fine (309 MB in ~18 s), but three variables OOM at 4, 5 and 6 GB. More memory does not rescue it, which is why §6.2 routes bulk access to the Parquet release instead of engineering around it.

6 · Recommendations

The benchmarks above explore a wide space, but the operational answer is deliberately narrow. Every additional way of serving the same table is a question a user has to answer before they can start (“full or thinned? DuckDB or NetCDF? wide or long?”), and that cost is paid by every user forever, while the performance difference is paid once by us. So the axes are evidence; the deployment is one path.

6.1 · One serving path: DuckDB views over the integrated release Parquet

Serve every dataset through EDDTableFromDatabase over DuckDB views on the CalCOFI integrated release Parquet, in long format. Concretely:

  • One backend. A 274 KB .db of views on stock ERDDAP (only the DuckDB JDBC jar added). Nothing is copied or re-materialized — the views read the same release Parquet that calcofi4r and calcofi.io/query read, so ERDDAP cannot drift from the published release.
  • One schema — long. measurement_type / measurement_value matches the integrated database, which is long and clustered by measurement type. A wide variant for CTD alone would make the largest dataset the odd one out, and the wide pivot view is slower on broad cross-cruise scans anyway (§3, Finding 2).
  • No per-dataset special cases. The five non-CTD datasets already serve this way in effect; folding CTD in removes the exception rather than adding one.

Size the container, not the heap. From §5, the JVM heap never exceeds ~1.7 GB, so -Xmx is the wrong dial; the requirement is DuckDB’s off-heap allocation. Give the ERDDAP container ≥ 5 GB (mem_limit, with memswap_limit equal so the cap is real). At 4 GB the thinned CTD full download OOMs; at 5 GB it completes using ~4.2 GB.

6.2 · The one scale limit, stated plainly

ctd_measurement (232 M rows) is queryable but not bulk-downloadable through ERDDAP. A single variable across the whole record works (309 MB in ~18 s); three variables or all variables OOM even at a 6 GB cap. This is not a format problem to engineer around — it is a 10-GB-class result set, and no memory limit we would sensibly grant ERDDAP changes that.

That is fine, because bulk access already has a better answer: the versioned Parquet release, browsable at …/ducklake/releases/index.html, readable in place without downloading:

SELECT * FROM read_parquet(
  'https://storage.googleapis.com/calcofi-db/ducklake/releases/v2026.07.17/parquet/obs.parquet')
WHERE measurement_type = 'temperature_ave' LIMIT 10;

The right guidance for users is therefore: ERDDAP for subsetting, Parquet for bulk — one sentence, no format menu.

6.3 · NetCDF as dataset-level output, not a second live backend

Generate CF NetCDF per dataset, outside ERDDAP, published alongside the Parquet release rather than mounted as a parallel EDDTableFromNcCFFiles dataset. Rationale:

  • It serves the audience that actually wants it (ocean/climate modellers, CF tooling) without making every other user choose a format.
  • It is the only representation that can carry a dataset’s one-to-many structuresampleobsobs_attribute plus event-level effort — in a single self-describing file. ERDDAP’s tabledap can only ever return one flat table, so a relational dataset must be either denormalized (repeating effort on every row, inviting double-counting) or split across several datasets the user re-joins by hand. NetCDF-4 groups and ragged arrays avoid both.
  • The generator already exists (libs/erddap_netcdf.R); only the destination changes. Cost is ~740 MB per full regeneration (687 MB ctd_measurement + 56 MB ctd_thin).
  • Caveat worth stating to any recipient: CF covers the profile/trajectory case cleanly, but there is no CF standard for a tow → net → taxon → size-bin hierarchy. Such files are CF-compliant where CF applies and netCDF-4 elsewhere.

Granularity is a free choice here: per-cruise (96 files) or per-cast (14,290 files) — both are ordinary collection sizes (§1 grain correction).

6.4 · What we are deliberately not doing

rejected why
Wide schema for CTD makes the largest dataset the only wide one; slower on broad scans (§3)
NetCDF and DuckDB as live ERDDAP backends same table served two ways — the confusion cost exceeds the benefit
EDDTableFromParquetFiles on one large file whole-file on-heap reads; this is what OOM’d the original ctd_wide (§3, Finding 1)
Raising ERDDAP_MEMORY to fix OOMs the heap is not the constraint (§5); it cannot work

7 · Reproduce

Everything runs in the CalCOFI server containers (../server/), reading from and writing to the shared /share/data/erddap-duckdb tree, mounted identically into rstudio (build), the live erddap, and the bench erddap_bench — no host-specific paths.

Code (all in CalCOFI/workflows):

file role
libs/erddap_netcdf.R per-cruise + lumped CF NetCDF builders + EDDTableFromNcCFFiles XML
libs/erddap_duckdb.R .db view builder + EDDTableFromDatabase XML
libs/erddap.R shared dataVariable / type / units helpers
scripts/gen_ctd_netcdf.R · gen_ctd_wide.R · gen_ctd_lumped.R build the split/lumped, wide/long serving artifacts
scripts/gen_bench_datasets.R emit the per-cell <dataset> blocks (datasetID = cell)
scripts/bench_erddap.sh the format×schema×granularity matrix → results_all.csv
scripts/bench_parquet_layout.sh the partitioning/ordering experiment → layout_results.csv
server/erddap/Dockerfile · docker-compose.bench.yml ERDDAP + duckdb_jdbc; isolated bench instance

Data & artifacts are browsable / downloadable via the Caddy file server at file.calcofi.io/erddap-ctd. Live datasets: https://erddap.calcofi.io/erddap/tabledap/calcofi_ctd_thin.html · https://erddap.calcofi.io/erddap/tabledap/calcofi_ctd_measurement.html.

Code
read_csv("data/bench_erddap/results_all.csv", show_col_types = FALSE) |> kable(caption = "results_all.csv (the full grid)")
results_all.csv (the full grid)
cell table format schema granularity heap load_status load_secs load_peak_heap_mb load_peak_rss_mb q1_das_ms q2_cruise_ms q3_var200_ms q4_dump_ms q2_bytes q3_bytes q4_bytes query_peak_heap_mb query_peak_rss_mb
thin_netcdf_wide_split thin netcdf wide split 2g loaded 10 66 2607 4 61 901 71 181648 12016943 1085605 582 2668
thin_netcdf_wide_lumped thin netcdf wide lumped 2g loaded 10 66 2786 4 100 593 274 181648 12017007 1085605 874 3006
thin_parquet_wide_split thin parquet wide split 2g loaded 10 66 2599 5 204 2897 361 181648 12100017 1181674 1142 2678
thin_parquet_wide_lumped thin parquet wide lumped 2g loaded 18 1546 2991 4 1495 2052 3295 181648 12100017 1181674 1662 3268
thin_csv_wide_split thin csv wide split 2g loaded 21 1057 2816 4 143 2759 109 181648 12100017 1181674 1057 2840
thin_csv_wide_lumped thin csv wide lumped 2g loaded 22 1141 3022 4 2331 2906 6180 181648 12100017 1181674 1456 3393
thin_duckdb_wide thin duckdb wide na 2g loaded 10 66 3257 4 535 2154 630 181648 12100017 1181674 1117 3257
meas_netcdf_wide_split measurement netcdf wide split 2g loaded 10 66 2624 5 248 5326 716 2572481 126747037 15103498 1004 2908
meas_duckdb_wide measurement duckdb wide na 2g loaded 10 65 3302 5 4188 76548 3008 2572481 127774023 16467019 730 7931
meas_parquet_wide_split measurement parquet wide split 2g loaded 67 1849 4036 5 1007 23546 1230 2572481 127774023 16467019 1849 4131
meas_netcdf_wide_lumped measurement netcdf wide lumped 2g loaded 19 1079 3720 4 527 4510 2577 2572481 126747287 15103498 1498 3720
meas_parquet_wide_lumped measurement parquet wide lumped 2g loaded 98 1655 4521 4 19619 23221 73675 2572481 127774023 NA 1655 4636
meas_parquet_long_split measurement parquet long split 2g timeout 241 1981 5933 -1 -1 -1 -1 0 0 0 1981 5933
meas_duckdb_long measurement duckdb long na 2g loaded 11 63 3018 8 4863 23896 99139 5122086 140611985 612216909 1555 7705
thin_parquet_long_lumped thin parquet long lumped 2g loaded 10 66 2696 3 12830 13896 34356 181646 11728431 11071126 1730 3660
thin_csv_long_lumped thin csv long lumped 2g timeout 203 1635 3500 -1 -1 -1 -1 0 0 0 1635 3500
thin_duckdb_long thin duckdb long na 2g loaded 5 66 2993 8 686 1699 1807 181646 11728431 11071126 1124 3017
Code
if (!is.null(lay)) kable(lay, caption = "layout_results.csv")
layout_results.csv
layout build_secs size nfiles query_secs_median scan_rows result_rows
A_prodview_cruise_join 0 (view over cruise-partitioned raw + JOIN) 95 0.875 see explain_A_prodview_cruise_join.txt 393271
B_denorm_cruise 188 17G 95 0.223 see explain_B_denorm_cruise.txt 393271
C_denorm_type 166 17G 53 0.104 see explain_C_denorm_type.txt 393271
D_type_spatialsort 686 17G 53 0.075 see explain_D_type_spatialsort.txt 393271

The per-cruise NetCDF builder and the benchmark harness, in full:

Code
# erddap_netcdf.R — publish CalCOFI long CTD tables to ERDDAP as the NATIVE
# ERDDAP path: per-cruise CF "profile" NetCDF files served by EDDTableFromNcCFFiles
# (Discrete Sampling Geometry, contiguous ragged array). This is the "NetCDF per
# cast à la Trinidad Head" option Lynn DeWitt raised — except CalCOFI has ~5.55M
# casts, so one file PER CAST is infeasible; we bundle casts BY CRUISE (96 files).
#
# The long tables (one row per cast x depth x measurement_type) are pivoted to
# WIDE (one column per canonical measurement_type) per cruise, joined to ctd_cast
# for time/lat/lon, and written as CF profiles: profile = cast, obs = depth level.
# ERDDAP reads each file's per-file index and only opens matching files, so memory
# stays low (contrast EDDTableFromParquetFiles, which loads whole files on-heap).
#
# Reuses erddap.R helpers (.erddap_datavar_xml, .xml_escape, %||%).
# Requires: DBI, duckdb, glue, ncdf4. Generate in the rstudio container, reading
# Parquet from and writing NetCDF to the shared /share/data tree (so the paths
# resolve identically when ERDDAP later reads them). See bench_erddap_ctd.qmd.

# CF/NetCDF default double fill (recognized by ERDDAP as the missing value).
.NC_FILL_DOUBLE <- 9.969209968386869e36

#' Pivot one cruise of a long CTD table to a wide (cast x depth) data.frame.
#'
#' GRAIN GOTCHA: `ctd_cast_uuid` is the DEPTH-SCAN key, not the cast key — it is
#' md5(cruise_key|cast_key|cast_dir|datetime_utc) with a per-scan datetime, so one
#' station occupation carries hundreds of them. Keying CF profiles by it yields
#' one single-point "profile" per scan (rowSize == 1 everywhere), which is not a
#' profile at all. The cast-level key is `ord_occ` (station occupation order
#' within a cruise), disambiguated by `cast_dir` since a single occupation can
#' hold both a down- and an up-cast. See the same trap in the ctd_thin RDP design
#' and the ctd-viz cruise_stats panel (CalCOFI/workflows v2026.05.14).
#'
#' @return data.frame ordered by (profile_id, depth): columns profile_id, time
#'   (epoch s), latitude, longitude, line, sta, depth, then one per `vars`.
.ctd_cruise_wide <- function(con, data_dir, table, cruise_key, vars) {
  val <- vapply(vars, function(v) glue::glue(
    "MAX(t.measurement_value) FILTER (WHERE t.measurement_type='{v}')::DOUBLE AS \"{v}\""),
    character(1))
  sql <- glue::glue(
    "SELECT
       t.cruise_key || ':' || c.ord_occ || ':' || c.cast_dir  AS profile_id,
       epoch(MIN(c.datetime_start_utc))::DOUBLE               AS time,
       any_value(c.latitude)::DOUBLE                          AS latitude,
       any_value(c.longitude)::DOUBLE                         AS longitude,
       any_value(c.line)                                      AS line,
       any_value(c.sta)                                       AS sta,
       t.depth_m::DOUBLE                                      AS depth,
       {paste(val, collapse=',\n       ')}
     FROM read_parquet('{data_dir}/{table}/**/*.parquet', hive_partitioning=true) t
     JOIN read_parquet('{data_dir}/ctd_cast.parquet') c USING (ctd_cast_uuid)
     WHERE t.cruise_key = '{cruise_key}'
     GROUP BY profile_id, t.depth_m
     ORDER BY profile_id, t.depth_m")
  DBI::dbGetQuery(con, sql)
}

#' Write one cruise's wide data.frame to a CF contiguous-ragged Profile NetCDF.
.write_cruise_nc <- function(df, cruise_key, out_file, vars, title, summary,
                             units_lookup = list(), longname_lookup = list(),
                             institution = "CalCOFI", force_v4 = FALSE) {
  prof_ids  <- unique(df$profile_id)          # df is ordered by profile_id, so contiguous
  nprof     <- length(prof_ids)
  nobs      <- nrow(df)
  rowSize   <- as.integer(table(factor(df$profile_id, levels = prof_ids)))
  first_idx <- match(prof_ids, df$profile_id) # first obs row of each profile
  STRLEN    <- 64L
  FILL      <- .NC_FILL_DOUBLE

  d_obs  <- ncdf4::ncdim_def("obs",     "", seq_len(nobs),  create_dimvar = FALSE)
  d_prof <- ncdf4::ncdim_def("profile", "", seq_len(nprof), create_dimvar = FALSE)
  d_str  <- ncdf4::ncdim_def("name_strlen", "", seq_len(STRLEN), create_dimvar = FALSE)

  # profile-level (indexed by profile)
  v_pid   <- ncdf4::ncvar_def("profile_id", "", list(d_str, d_prof), prec = "char")
  v_cru   <- ncdf4::ncvar_def("cruise_key", "", list(d_str, d_prof), prec = "char")
  v_line  <- ncdf4::ncvar_def("line",       "", list(d_str, d_prof), prec = "char")
  v_sta   <- ncdf4::ncvar_def("sta",        "", list(d_str, d_prof), prec = "char")
  v_time  <- ncdf4::ncvar_def("time", "seconds since 1970-01-01T00:00:00Z", d_prof, prec = "double")
  v_lat   <- ncdf4::ncvar_def("latitude",  "degrees_north", d_prof, prec = "double")
  v_lon   <- ncdf4::ncvar_def("longitude", "degrees_east",  d_prof, prec = "double")
  v_rs    <- ncdf4::ncvar_def("rowSize",   "",              d_prof, prec = "integer")
  # obs-level (indexed by obs)
  v_depth <- ncdf4::ncvar_def("depth", "m", d_obs, prec = "double", missval = FILL)
  v_data  <- lapply(vars, function(v) ncdf4::ncvar_def(
    v, as.character(units_lookup[[v]] %||% ""), d_obs, prec = "double", missval = FILL))

  nc <- ncdf4::nc_create(out_file, c(list(v_pid, v_cru, v_line, v_sta, v_time,
                                          v_lat, v_lon, v_rs, v_depth), v_data),
                         force_v4 = force_v4)  # nc4 for big lumped files (nc3 size limit)
  on.exit(ncdf4::nc_close(nc), add = TRUE)

  # cruise_key per profile: from df if present (lumped, many cruises) else the scalar
  cru_vec <- if ("cruise_key" %in% names(df)) df$cruise_key[first_idx] else rep(cruise_key, nprof)
  ncdf4::ncvar_put(nc, v_pid,  prof_ids)
  ncdf4::ncvar_put(nc, v_cru,  cru_vec)
  ncdf4::ncvar_put(nc, v_line, df$line[first_idx])
  ncdf4::ncvar_put(nc, v_sta,  df$sta[first_idx])
  ncdf4::ncvar_put(nc, v_time, df$time[first_idx])
  ncdf4::ncvar_put(nc, v_lat,  df$latitude[first_idx])
  ncdf4::ncvar_put(nc, v_lon,  df$longitude[first_idx])
  ncdf4::ncvar_put(nc, v_rs,   rowSize)
  ncdf4::ncvar_put(nc, v_depth, df$depth)
  for (i in seq_along(vars)) ncdf4::ncvar_put(nc, v_data[[i]], df[[vars[i]]])

  # CF DSG attributes
  ncdf4::ncatt_put(nc, "profile_id", "cf_role",   "profile_id")
  ncdf4::ncatt_put(nc, "profile_id", "long_name", "CTD cast UUID")
  ncdf4::ncatt_put(nc, "cruise_key", "long_name", "CalCOFI cruise key")
  ncdf4::ncatt_put(nc, "line",       "long_name", "CalCOFI line")
  ncdf4::ncatt_put(nc, "sta",        "long_name", "CalCOFI station")
  ncdf4::ncatt_put(nc, "time", "standard_name", "time")
  ncdf4::ncatt_put(nc, "time", "long_name", "Time")
  ncdf4::ncatt_put(nc, "time", "axis", "T")
  ncdf4::ncatt_put(nc, "latitude",  "standard_name", "latitude")
  ncdf4::ncatt_put(nc, "latitude",  "long_name", "Latitude");  ncdf4::ncatt_put(nc, "latitude", "axis", "Y")
  ncdf4::ncatt_put(nc, "longitude", "standard_name", "longitude")
  ncdf4::ncatt_put(nc, "longitude", "long_name", "Longitude"); ncdf4::ncatt_put(nc, "longitude", "axis", "X")
  ncdf4::ncatt_put(nc, "depth", "standard_name", "depth")
  ncdf4::ncatt_put(nc, "depth", "long_name", "Depth")
  ncdf4::ncatt_put(nc, "depth", "positive", "down"); ncdf4::ncatt_put(nc, "depth", "axis", "Z")
  ncdf4::ncatt_put(nc, "rowSize", "long_name", "Number of observations for this profile")
  ncdf4::ncatt_put(nc, "rowSize", "sample_dimension", "obs")   # -> contiguous ragged array
  for (v in vars) {
    ln <- as.character(longname_lookup[[v]] %||% gsub("_", " ", tools::toTitleCase(v)))
    ncdf4::ncatt_put(nc, v, "long_name", ln)
    ncdf4::ncatt_put(nc, v, "coordinates", "time latitude longitude depth")
  }
  # globals
  g <- list(
    featureType = "profile", cdm_data_type = "Profile",
    cdm_profile_variables = "profile_id, time, latitude, longitude, cruise_key, line, sta",
    Conventions = "CF-1.6, COARDS, ACDD-1.3", institution = institution,
    infoUrl = "https://calcofi.org", license = "CC-BY 4.0",
    title = title, summary = summary,
    cruise_key = if (length(unique(cru_vec)) == 1L) cru_vec[1] else "multiple",
    source = "CalCOFI CTD cast files (https://calcofi.org)",
    creator_name = "CalCOFI", creator_url = "https://calcofi.org")
  for (nm in names(g)) ncdf4::ncatt_put(nc, 0, nm, g[[nm]])
  invisible(c(nprof = nprof, nobs = nobs))
}

#' Build per-cruise CF Profile NetCDF for a long CTD table.
#' @param data_dir base dir with {table}/ (hive by cruise_key) + ctd_cast.parquet.
#' @param out_dir  destination for <cruise_key>.nc (created if absent).
#' @param vars     canonical measurement_type names -> wide NetCDF variables.
#' @param cruises  optional subset of cruise_keys (default: all partitions).
#' @param con      a live DuckDB DBI connection (caller-managed; reused so the
#'   :memory: instance lifecycle stays in one place).
#' @param overwrite re-write existing .nc (default FALSE -> resumable).
#' @return data.frame(cruise_key, nprof, nobs, file, mb) for files written/seen.
build_ctd_netcdf <- function(con, data_dir, out_dir, table, vars, title, summary,
                             units_lookup = list(), longname_lookup = list(),
                             cruises = NULL, mem_limit = "2GB", threads = 2,
                             tmp_dir = "/share/data/erddap-duckdb/tmp",
                             overwrite = FALSE, verbose = TRUE) {
  if (!dir.exists(out_dir)) dir.create(out_dir, recursive = TRUE)
  if (is.null(cruises)) {
    parts <- list.files(file.path(data_dir, table), pattern = "^cruise_key=")
    cruises <- sort(sub("^cruise_key=", "", parts))
  }
  cruises <- setdiff(cruises, "__HIVE_DEFAULT_PARTITION__")  # NULL-cruise hive bucket
  for (s in c(glue::glue("SET memory_limit='{mem_limit}'"),
              glue::glue("SET threads={threads}"),
              glue::glue("SET temp_directory='{tmp_dir}'"),
              "SET preserve_insertion_order=false"))
    try(DBI::dbExecute(con, s), silent = TRUE)
  rows <- vector("list", length(cruises))
  for (i in seq_along(cruises)) {
    ck   <- cruises[i]
    f    <- file.path(out_dir, paste0(ck, ".nc"))
    if (file.exists(f) && !overwrite) {
      rows[[i]] <- data.frame(cruise_key = ck, nprof = NA, nobs = NA,
                              file = f, mb = round(file.size(f) / 1048576, 2))
      if (verbose) cat(sprintf("[%d/%d] %s  (exists, skip)\n", i, length(cruises), ck))
      next
    }
    df  <- .ctd_cruise_wide(con, data_dir, table, ck, vars)
    if (nrow(df) == 0L) {                       # no casts join to ctd_cast — skip
      if (verbose) cat(sprintf("[%d/%d] %s  (0 rows, skip)\n", i, length(cruises), ck))
      next
    }
    dims <- .write_cruise_nc(df, ck, f, vars, title, summary, units_lookup, longname_lookup)
    rows[[i]] <- data.frame(cruise_key = ck, nprof = dims["nprof"], nobs = dims["nobs"],
                            file = f, mb = round(file.size(f) / 1048576, 2))
    if (verbose) cat(sprintf("[%d/%d] %s  profiles=%d obs=%d  %.1f MB\n",
                             i, length(cruises), ck, dims["nprof"], dims["nobs"],
                             file.size(f) / 1048576))
    rm(df); gc(FALSE)
  }
  do.call(rbind, rows)
}

#' Build ONE lumped CF Profile NetCDF holding ALL cruises (the "lumped" granularity
#' level for the split-vs-lumped experiment). Same CF structure as the per-cruise
#' files but cruise_key varies per profile. Pulls the whole wide pivot into R, so
#' it's heavier than the per-cruise path — fine for the benchmark's one-time build.
#' @return c(nprof, nobs) written.
build_ctd_netcdf_lumped <- function(con, data_dir, out_file, table, vars, title, summary,
                                    units_lookup = list(), longname_lookup = list(),
                                    mem_limit = "3GB", threads = 2,
                                    tmp_dir = "/share/data/erddap-duckdb/tmp") {
  for (s in c(glue::glue("SET memory_limit='{mem_limit}'"), glue::glue("SET threads={threads}"),
              glue::glue("SET temp_directory='{tmp_dir}'"), "SET preserve_insertion_order=false"))
    try(DBI::dbExecute(con, s), silent = TRUE)
  val <- vapply(vars, function(v) glue::glue(
    "MAX(t.measurement_value) FILTER (WHERE t.measurement_type='{v}')::DOUBLE AS \"{v}\""), character(1))
  df <- DBI::dbGetQuery(con, glue::glue(
    "SELECT t.cruise_key || ':' || c.ord_occ || ':' || c.cast_dir AS profile_id, t.cruise_key,
       epoch(MIN(c.datetime_start_utc))::DOUBLE AS time,
       any_value(c.latitude)::DOUBLE AS latitude, any_value(c.longitude)::DOUBLE AS longitude,
       any_value(c.line) AS line, any_value(c.sta) AS sta, t.depth_m::DOUBLE AS depth,
       {paste(val, collapse = ',\n       ')}
     FROM read_parquet('{data_dir}/{table}/**/*.parquet', hive_partitioning=true) t
     JOIN read_parquet('{data_dir}/ctd_cast.parquet') c USING (ctd_cast_uuid)
     GROUP BY profile_id, t.cruise_key, t.depth_m
     ORDER BY profile_id, t.depth_m"))
  if (!dir.exists(dirname(out_file))) dir.create(dirname(out_file), recursive = TRUE)
  dims <- .write_cruise_nc(df, NA_character_, out_file, vars, title, summary,
                          units_lookup, longname_lookup, force_v4 = TRUE)
  rm(df); gc(FALSE)
  dims
}

#' EDDTableFromNcCFFiles <dataset> block for the per-cruise Profile NetCDF.
#' Reuses .erddap_datavar_xml() for the per-variable blocks (source erddap.R first).
erddap_nccf_dataset_xml <- function(
    dataset_id, title, summary, file_dir, vars,
    units_lookup = list(), longname_lookup = list(), range_lookup = list(),
    institution = "CalCOFI", reload_minutes = 10080, global_atts = list()) {

  # column -> (duckdb-ish) type for .erddap_datavar_xml()'s dataType mapping
  prof_cols <- c(profile_id = "VARCHAR", time = "TIMESTAMP", latitude = "DOUBLE",
                 longitude = "DOUBLE", cruise_key = "VARCHAR", line = "VARCHAR",
                 sta = "VARCHAR")
  obs_cols  <- c(depth = "DOUBLE", setNames(rep("DOUBLE", length(vars)), vars))
  cols <- c(prof_cols, obs_cols)

  blocks <- vapply(names(cols), function(col) {
    b <- .erddap_datavar_xml(
      col, cols[[col]],
      units     = as.character(units_lookup[[col]]    %||% ""),
      long_name = as.character(longname_lookup[[col]] %||% ""),
      actual_range = range_lookup[[col]])
    if (col == "profile_id")            # CF discrete-geometry instance variable
      b <- sub("    <addAttributes>\n",
               "    <addAttributes>\n      <att name=\"cf_role\">profile_id</att>\n",
               b, fixed = TRUE)
    b
  }, character(1))

  glob <- c(
    cdm_data_type = "Profile", featureType = "Profile",
    cdm_profile_variables = "profile_id, time, latitude, longitude, cruise_key, line, sta",
    subsetVariables = "cruise_key, line, sta",
    Conventions = "CF-1.6, COARDS, ACDD-1.3", institution = institution,
    infoUrl = "https://calcofi.org", sourceUrl = "(local files)", license = "CC-BY 4.0",
    creator_name = "CalCOFI", creator_url = "https://calcofi.org",
    title = title, summary = summary,
    standard_name_vocabulary = "CF Standard Name Table v79")
  for (nm in names(global_atts)) glob[nm] <- as.character(global_atts[[nm]])
  glob_xml <- paste0('    <att name="', names(glob), '">', .xml_escape(glob), "</att>", collapse = "\n")

  glue::glue(
    '<dataset type="EDDTableFromNcCFFiles" datasetID="{dataset_id}" active="true">\n',
    '  <reloadEveryNMinutes>{reload_minutes}</reloadEveryNMinutes>\n',
    '  <updateEveryNMillis>0</updateEveryNMillis>\n',
    '  <fileDir>{file_dir}</fileDir>\n',
    '  <fileNameRegex>.*\\.nc</fileNameRegex>\n',
    '  <recursive>true</recursive>\n',
    '  <pathRegex>.*</pathRegex>\n',
    '  <metadataFrom>last</metadataFrom>\n',
    '  <standardizeWhat>0</standardizeWhat>\n',
    '  <accessibleViaFiles>true</accessibleViaFiles>\n',
    '  <addAttributes>\n{glob_xml}\n  </addAttributes>\n',
    '{paste(blocks, collapse = "\\n")}\n',
    '</dataset>')
}
Code
#!/usr/bin/env bash
# Benchmark CTD serving backends on an isolated ERDDAP (port 8091).
# For each (approach x table) cell: splice its <dataset> block into the bench
# datasets.xml, recreate the container with a clean JVM at the chosen heap,
# measure the load phase (does it bind without OOM; peak JVM heap + container
# RSS; load seconds), then run representative queries (median latency, bytes,
# peak heap/RSS). Appends one row per cell to a results CSV.
#
# Usage: bench_erddap.sh <heap e.g. 2g> <cell1 cell2 ...>
#   cells: thin_duckdb thin_parquet thin_csv meas_duckdb meas_parquet
set -u
HEAP="${1:-2g}"; shift || true
CELLS=("$@"); [ ${#CELLS[@]} -eq 0 ] && CELLS=(thin_duckdb thin_parquet thin_csv meas_duckdb meas_parquet)
LOAD_TIMEOUT="${LOAD_TIMEOUT:-300}"   # seconds to wait for a dataset to bind
MIN_AVAIL_MB="${MIN_AVAIL_MB:-1500}"  # host-memory guard (no swap on this VM)

BENCH=/share/data/erddap-bench
BLOCKS=/share/github/CalCOFI/workflows/data/bench_erddap
CONTENT=$BENCH/content/datasets.xml   # owned by container uid 1000 -> write via sudo
HEADER=$BENCH/bench/header.xml         # dir we own
COMPOSE=(docker compose -f /share/github/CalCOFI/server/docker-compose.bench.yml -p erddap-bench)
BASE=http://localhost:8091/erddap/tabledap
TS=$(date +%Y%m%d_%H%M%S)
RES=$BENCH/bench/results_${TS}.csv
mkdir -p "$BENCH/bench"
echo "cell,table,format,schema,granularity,heap,load_status,load_secs,load_peak_heap_mb,load_peak_rss_mb,q1_das_ms,q2_cruise_ms,q3_var200_ms,q4_dump_ms,q2_bytes,q3_bytes,q4_bytes,query_peak_heap_mb,query_peak_rss_mb" > "$RES"
echo "results -> $RES (heap=$HEAP, cells: ${CELLS[*]})"

# JVM heap used (MB) via jcmd; 0 if unavailable (e.g. mid-OOM)
heap_mb() { docker exec erddap_bench jcmd 1 GC.heap_info 2>/dev/null \
  | grep -oE 'used [0-9]+K' | head -1 | grep -oE '[0-9]+' | awk '{printf "%d", $1/1024}'; }
# container RSS (MB) via cgroup v2
rss_mb() { local b; b=$(docker exec erddap_bench cat /sys/fs/cgroup/memory.current 2>/dev/null); \
  [ -n "$b" ] && awk -v b="$b" 'BEGIN{printf "%d", b/1048576}' || echo 0; }

assemble() { # $1=block file -> bench datasets.xml (sudo: dir owned by container uid)
  local tmp; tmp=$(mktemp)
  { sed -n '1,/BENCH: dataset block spliced/p' "$HEADER"
    cat "$1"
    printf '\n</erddapDatasets>\n'; } > "$tmp"
  sudo cp "$tmp" "$CONTENT"; rm -f "$tmp"
}

# median of N curl time_total (ms) for a URL; echoes "median_ms last_bytes"
timeit() { local url="$1" n="$2" t bytes; local -a arr=()
  for i in $(seq 1 "$n"); do
    read -r t bytes < <(curl -s -o /dev/null --max-time 180 -w '%{time_total} %{size_download}' "$url" 2>/dev/null || echo "999 0")
    arr+=("$t"); done
  bytes=${bytes:-0}
  printf '%s\n' "${arr[@]}" | sort -n | awk -v b="$bytes" '{a[NR]=$1} END{m=(NR%2)?a[(NR+1)/2]:(a[NR/2]+a[NR/2+1])/2; printf "%d %s", m*1000, b}'
}

# write a static header file once (everything up to the splice marker)
cat > "$HEADER" <<'HDR'
<?xml version="1.0" encoding="UTF-8" ?>
<erddapDatasets>
<cacheMinutes>60</cacheMinutes>
<decompressedCacheMaxGB>10</decompressedCacheMaxGB>
<drawLandMask>over</drawLandMask>
<logLevel>info</logLevel>
<loadDatasetsMinMinutes>1</loadDatasetsMinMinutes>
<loadDatasetsMaxMinutes>60</loadDatasetsMaxMinutes>
<nGridThreads>1</nGridThreads>
<nTableThreads>1</nTableThreads>
<!-- BENCH: dataset block spliced -->
HDR

for cell in "${CELLS[@]}"; do
  # cell name == datasetID == block file, encoding all four axes, e.g.
  # meas_parquet_wide_lumped / thin_duckdb_wide / thin_netcdf_wide_split
  DID="$cell"; BLOCK="$BLOCKS/${cell}.xml"
  case "$cell" in *thin*) TABLE=thin;; *meas*) TABLE=measurement;; *) TABLE=?;; esac
  case "$cell" in *netcdf*) APP=netcdf;; *parquet*) APP=parquet;; *csv*) APP=csv;; *duckdb*) APP=duckdb;; esac
  case "$cell" in *wide*) SCHEMA=wide;; *) SCHEMA=long;; esac
  case "$cell" in *lumped*) GRAN=lumped;; *split*) GRAN=split;; *) GRAN=na;; esac
  echo "================ CELL $cell  (table=$TABLE $APP/$SCHEMA/$GRAN, heap=$HEAP) ================"
  [ -f "$BLOCK" ] || { echo "  MISSING block $BLOCK, skip"; continue; }

  avail=$(free -m | awk '/Mem:/{print $7}')
  if [ "${avail:-0}" -lt "$MIN_AVAIL_MB" ]; then echo "  ABORT cell: host avail ${avail}MB < ${MIN_AVAIL_MB}MB"; break; fi
  rm -rf "$BENCH"/data/* 2>/dev/null   # cold: clear datasetInfo/cache/logs
  assemble "$BLOCK"
  BENCH_ERDDAP_MEMORY="$HEAP" "${COMPOSE[@]}" up -d --force-recreate erddap_bench >/dev/null 2>&1

  # wait for tomcat
  for i in $(seq 1 40); do [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8091/erddap/index.html)" = 200 ] && break; sleep 2; done

  # LOAD phase: poll .das until loaded / failed / timeout, sampling heap+rss
  # Poll until loaded / OOM / timeout. File datasets (Parquet/CSV) DEFER on first
  # start to build fileTable.nc, then load on ERDDAP's own follow-up cycle — just
  # keep polling (do NOT touch the reload flag: that triggers a reloadASAP churn
  # loop that never settles). A truly broken dataset times out; OOM stops early.
  t0=$(date +%s); lph=0; lpr=0; status=timeout
  while [ $(( $(date +%s) - t0 )) -lt "$LOAD_TIMEOUT" ]; do
    h=$(heap_mb); r=$(rss_mb); [ "${h:-0}" -gt "$lph" ] && lph=$h; [ "${r:-0}" -gt "$lpr" ] && lpr=$r
    code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/${DID}.das")
    if [ "$code" = 200 ]; then    # confirm STABLE (file datasets briefly flap 200/503 while settling)
      sleep 3; c2=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/${DID}.das")
      sleep 3; c3=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/${DID}.das")
      [ "$c2" = 200 ] && [ "$c3" = 200 ] && { status=loaded; break; }
    fi
    if docker exec erddap_bench bash -lc 'grep -lqE "OutOfMemoryError" /erddapData/logs/log.txt 2>/dev/null'; then status=OOM; break; fi
    sleep 2
  done
  load_secs=$(( $(date +%s) - t0 ))
  echo "  load: status=$status secs=$load_secs peak_heap=${lph}MB peak_rss=${lpr}MB"

  q1=-1; q2=-1; q3=-1; q4=-1; q2r=0; q3r=0; q4r=0; qph=$lph; qpr=$lpr
  if [ "$status" = loaded ]; then
    CRU='%221998-04-31JD%22'; T1='%22temperature_ave%22'; T2='%22salinity_ave_corr%22'
    read -r q1 _   < <(timeit "$BASE/${DID}.das" 5)
    h=$(heap_mb); r=$(rss_mb); [ "${h:-0}" -gt "$qph" ] && qph=$h; [ "${r:-0}" -gt "$qpr" ] && qpr=$r
    # Same query INTENT across backends; NetCDF is WIDE (sensors are columns, no
    # measurement_type), so it selects the named variable instead of filtering type.
    if [ "$SCHEMA" = wide ]; then
      read -r q2 q2r < <(timeit "$BASE/${DID}.csv?time,depth,temperature_ave&cruise_key=$CRU" 3)
      h=$(heap_mb); r=$(rss_mb); [ "${h:-0}" -gt "$qph" ] && qph=$h; [ "${r:-0}" -gt "$qpr" ] && qpr=$r
      read -r q3 q3r < <(timeit "$BASE/${DID}.csv?time,latitude,longitude,depth,salinity_ave_corr&depth%3E=0&depth%3C=200" 2)
      h=$(heap_mb); r=$(rss_mb); [ "${h:-0}" -gt "$qph" ] && qph=$h; [ "${r:-0}" -gt "$qpr" ] && qpr=$r
    else
      read -r q2 q2r < <(timeit "$BASE/${DID}.csv?time,depth,measurement_value&cruise_key=$CRU&measurement_type=$T1" 3)
      h=$(heap_mb); r=$(rss_mb); [ "${h:-0}" -gt "$qph" ] && qph=$h; [ "${r:-0}" -gt "$qpr" ] && qpr=$r
      read -r q3 q3r < <(timeit "$BASE/${DID}.csv?time,latitude,longitude,depth,measurement_value&measurement_type=$T2&depth%3E=0&depth%3C=200" 2)
      h=$(heap_mb); r=$(rss_mb); [ "${h:-0}" -gt "$qph" ] && qph=$h; [ "${r:-0}" -gt "$qpr" ] && qpr=$r
    fi
    read -r q4 q4r < <(timeit "$BASE/${DID}.csv?&cruise_key=$CRU" 2)
    h=$(heap_mb); r=$(rss_mb); [ "${h:-0}" -gt "$qph" ] && qph=$h; [ "${r:-0}" -gt "$qpr" ] && qpr=$r
    echo "  q1_das=${q1}ms q2_cruise=${q2}ms(${q2r}B) q3_var200=${q3}ms(${q3r}B) q4_dump=${q4}ms(${q4r}B) peak_heap=${qph}MB peak_rss=${qpr}MB"
  fi
  echo "$cell,$TABLE,$APP,$SCHEMA,$GRAN,$HEAP,$status,$load_secs,$lph,$lpr,$q1,$q2,$q3,$q4,$q2r,$q3r,$q4r,$qph,$qpr" >> "$RES"
done
echo "=== DONE -> $RES ==="; cat "$RES"