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-06-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 ~216 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)

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

  1. Granularity — not format — is the memory lever (Fig 1). ERDDAP’s file backends read whole matching files onto the heap, so a single lumped 2.1 GB wide NetCDF can’t be served at a 2 GB heap (ERDDAP sheds requests, then times out), while the same data split into 96 per-cruise files loads at ~1.1 GB. This — not the file format — is what sank the original single-file ctd_wide. DuckDB sidesteps the whole axis (~65 MB in every config) by streaming from the Parquet rather than loading files onto the heap.
  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. CalCOFI has ~5.55 M casts, so one file per cast (à la Trinidad Head) is infeasible; bundling by cruise = 96 files, served as one aggregated ERDDAP dataset (one form), loads small and serves cruise-scoped queries fastest.

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

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 (216M 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 casts Parquet note
ctd_cast 5.55 M 5.55 M 128 MB one row per cast; carries time,lat,lon
ctd_thin 5.55 M 0.42 M 155 MB adaptively-thinned headline table
ctd_measurement 216 M 5.36 M ~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. The 5.55 M casts figure is what makes per-cast NetCDF infeasible (§3).

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: it’s how we go from “5.55 M per-cast files ERDDAP can’t index” to “96 files it can,” while the user still sees a single dataset. 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 879 53 1282 122
ctd_thin (5.5M rows) netcdf wide lumped (1 file) loaded 973 384 743 877
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 (216M rows) duckdb wide view (DB) loaded 65 4188 76548 3008
ctd_measurement (216M rows) netcdf wide split (per-cruise) loaded 1099 412 7459 809
ctd_measurement (216M rows) netcdf wide lumped (1 file) timeout 2044 NA NA NA
ctd_measurement (216M rows) parquet wide split (per-cruise) loaded 1849 1007 23546 1230
ctd_measurement (216M rows) parquet wide lumped (1 file) loaded 1655 19619 23221 73675
ctd_measurement (216M rows) duckdb long view (DB) loaded 63 4863 23896 99139
ctd_measurement (216M 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 216 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 216 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 · Recommendations

  • The memory question is “lumped vs split,” not “which format.” Never serve the big table as one file via a file backend — split it per cruise (96 files). DuckDB sidesteps the question entirely by streaming.
  • For CoastWatch hosting, NetCDF is the most native fit — per-cruise CF profile files served as one EDDTableFromNcCFFiles dataset; small load heap, fast cruise-scoped queries, auto ranges/sliders, source .nc downloadable. We generate them from the same pipeline (96 files/table). Costs: canonical sensors only, ~2.3 GB duplicated, regenerate on update.
  • To keep the tidy long table queryable as-is, DuckDB-over-Parquet is the lightest path: a 274 KB .db of views on stock ERDDAP (only the driver jar added), serving 216 M rows within ~65 MB, always reflecting the live Parquet. Keep it long — the wide pivot view is slow on broad scans. This runs live today.
  • Avoid EDDTableFromParquetFiles on a single large file (whole-file on-heap reads), and avoid lumped anything for the big table.
  • Open for you, Lynn: (a) per-cruise NetCDF we generate, (b) Parquet + the small DuckDB .db, or (c) wait on native Parquet/DuckDB (#447 / #448)? Any is producible from the same pipeline.

6 · 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 15 879 2965 4 53 1282 122 181648 12100017 1181671 879 3107
thin_netcdf_wide_lumped thin netcdf wide lumped 2g loaded 18 973 3546 5 384 743 877 181648 12100017 1181671 1493 3689
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 29 1099 4725 5 412 7459 809 2572481 127774023 16467016 1099 4789
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 timeout 302 2044 6868 -1 -1 -1 -1 0 0 0 2044 6868
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.
#' @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.ctd_cast_uuid                                  AS profile_id,
       epoch(any_value(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 t.ctd_cast_uuid, t.depth_m
     ORDER BY t.ctd_cast_uuid, 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.ctd_cast_uuid AS profile_id, t.cruise_key,
       epoch(any_value(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 t.ctd_cast_uuid, t.cruise_key, t.depth_m
     ORDER BY t.ctd_cast_uuid, 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"