Release marine-atlas — versioned Parquet → S3, view-DB serving, STAC

Published

2026-08-12

Publish the v8 marine-atlas and stand up its serving, reproducibly:

  1. Data → S3 s3://oceanmetrics.io-public/marine-atlas/{ver}/:
    • tables/ — derived scoring tables (cell, taxon, dataset, model, cell_metric, zone*, metric).
    • dist_merged/ — merged per-taxon surfaces (ms_merge|…).
    • serve/model_cell.parquet — the serving-optimized surface: one file, globally sorted by mdl_key (row-group zone-map pruning) so a titiler tile does an HTTP-range point read; single file ⇒ anonymous GET (no S3 LIST/creds).
    • registry/dataset/model Parquet.
    • dist/ — raw per-dataset surfaces (~74G) — opt-in (RELEASE_RAW=1).
  2. View DB — a tiny serve.duckdb whose tables are views over the S3 Parquet (path-style HTTPS URLs). Titiler reads this; the big data never rsyncs to the server.
  3. STACstac_build(version) → deploy the catalog to the file host /stac.
  4. Deploy (RELEASE_DEPLOY=1) — rsync the KB view DB + STAC to msens1, (re)build the parallel titiler-v8 service, restart caddy. v7 serving is untouched (A/B).

1 Setup

Code
librarian::shelf(DBI, dplyr, duckdb, fs, glue, here, jsonlite, logger, quiet = T)
source(here("libs/paths.R"))
dir_atlas <- glue("{dir_big_v}/marine-atlas")
tbl_dir   <- glue("{dir_atlas}/tables");  dir_create(tbl_dir)
serve_dir <- glue("{dir_atlas}/serve");   dir_create(serve_dir)
manifest  <- here("data/manifests/release_marine-atlas.json"); dir_create(path_dir(manifest))
s3_ver    <- glue("{s3_atlas}/{ver}")                      # s3://…/marine-atlas/v8
s3_http   <- glue("https://s3.us-east-1.amazonaws.com/{sub('^s3://', '', s3_ver)}")  # path-style
do_s3     <- !nzchar(Sys.getenv("RELEASE_NO_S3"))
do_raw    <-  nzchar(Sys.getenv("RELEASE_RAW"))
do_deploy <-  nzchar(Sys.getenv("RELEASE_DEPLOY"))
do_apps   <-  do_deploy || nzchar(Sys.getenv("DEPLOY_APPS"))        # (re)deploy the v8 Shiny apps
# v7 apps are the DEFAULT live apps on a separate checkout/branch — deliberately NOT implied by
# RELEASE_DEPLOY, so a routine v8 release can never restart them. Opt in explicitly.
do_apps_v7 <- nzchar(Sys.getenv("DEPLOY_APPS_V7"))
do_s3_tbl <-  do_s3     || nzchar(Sys.getenv("RELEASE_S3_TABLES"))  # push derived tables (native_asset) without the full serve cutover
# the derived tables are ALSO served from local Parquet on the server (see the tables chunk);
# DEPLOY_TABLES=1 refreshes just that, with no S3 push and no titiler/caddy restart.
do_tables <- do_deploy || nzchar(Sys.getenv("DEPLOY_TABLES"))
# server targets (parallel titiler-v8)
srv_repo  <- "/share/github/MarineSensitivity/server"
srv_viewdb<- glue("/share/data/big/{ver}/serve.duckdb")
# cell_model + the derived tables live as LOCAL Parquet on the server (see the viewdb chunk)
srv_cellmodel <- glue("/share/data/big/{ver}/cell_model")
srv_tables    <- glue("/share/data/big/{ver}/tables")
srv_modelcell <- glue("/share/data/big/{ver}/model_cell")
srv_stac  <- glue("/share/data/derived/stac/{ver}")
titiler_v8<- glue("https://titiler-{ver}.marinesensitivity.org/msens")
stopifnot("run build_registry first" = {
  con0 <- dbConnect(duckdb(sdm_db, read_only = TRUE)); on.exit(dbDisconnect(con0, shutdown = TRUE))
  all(c("dataset", "model", "model_cell") %in% dbListTables(con0)) })

2 Stage derived tables + serving surface → Parquet

Code
con <- dbConnect(duckdb(sdm_db, read_only = TRUE))
dbExecute(con, "PRAGMA memory_limit='12GB'"); dbExecute(con, "PRAGMA threads=6")
[1] 0
[1] 0
Code
dbExecute(con, glue("PRAGMA temp_directory='{dir_atlas}/duckdb_tmp'"))
[1] 0
Code
# small/derived scoring tables (model_cell is released as dist_merged + serve/, not here).
# native_asset (mdl_key -> COG/PMTiles input surfaces) is included when publish_native has run.
# zone_taxon: the per-zone species list the scores app reads. It MUST ship —
# the app cannot recompute it from the served model_cell (S3 Parquet partitioned
# by mdl_id for point reads); attempting to scan it fails with an S3 IO error.
rel_tables <- c("cell", "taxon", "dataset", "model", "metric", "zone_taxon",
                "cell_metric", "zone", "zone_cell", "zone_metric", "native_asset")
rel_tables <- intersect(rel_tables, dbListTables(con))
for (t in rel_tables)
  msens::copy_atlas_parquet(con, glue("SELECT * FROM {t}"), glue("{tbl_dir}/{t}.parquet"))

# serving surface: Hive-PARTITIONED by the integer mdl_id (from the model registry), which REPLACES
# the 40-byte mdl_key string in the stored rows (compact: rows are just cell_id,val; mdl_id lives in
# the path). A titiler tile reads exactly ONE partition (serve/model_cell/mdl_id={id}/data_0.parquet)
# by exact path -> anonymous GET, no S3 LIST, no global ORDER BY (avoids the ~500GB sort spill). The
# STABLE public identifier stays mdl_key: titiler resolves mdl_key->mdl_id from the model registry,
# so mdl_id (which renumbers as models are added) never appears in a URL.
# skip the rebuild if it exists (model_cell rarely changes); RELEASE_REDO_SERVE=1 forces.
serve_mc <- glue("{serve_dir}/model_cell")
if (!dir_exists(serve_mc) || nzchar(Sys.getenv("RELEASE_REDO_SERVE"))) {
  if (dir_exists(serve_mc)) dir_delete(serve_mc)
  dbExecute(con, "SET partitioned_write_max_open_files=1024")   # ~one partition per merged model
  msens::copy_atlas_parquet(
    con, "SELECT m.mdl_id, c.cell_id, c.val FROM model_cell c JOIN model m USING (mdl_key)",
    serve_mc, partition_by = "mdl_id")
} else {
  log_info("serve/model_cell/ exists — skipping repartition (RELEASE_REDO_SERVE=1 to force)")
}
# CELL-ORIENTED surface: the same model_cell data, partitioned by a 2.5-degree
# SPATIAL TILE so it can be queried BY CELL. serve/model_cell is partitioned by
# mdl_id, which serves a titiler tile as one point read but makes any per-cell or
# per-polygon question (the scores app's clicked cell, and the Report tab's
# arbitrary area) a full scan of ~580M rows — over HTTPS that fails outright.
#
# tile = 50x50 cells on the 7200-col global grid -> 422 partitions, avg 1.4M rows
# (p95 5.0M, max 10.2M), so one cell reads one small partition. Deliberately NOT
# globally sorted: an ORDER BY over 580M rows previously spilled ~500GB.
# Callers must filter on `tile` as well as `cell_id` to get partition pruning —
# msens::cell_model_tiles() computes the tile ids for a set of cells.
cell_mc <- glue("{serve_dir}/cell_model")
if (!dir_exists(cell_mc) || nzchar(Sys.getenv("RELEASE_REDO_CELL_MODEL"))) {
  if (dir_exists(cell_mc)) dir_delete(cell_mc)
  dbExecute(con, "SET partitioned_write_max_open_files=1024")
  msens::copy_atlas_parquet(
    con,
    paste("SELECT", msens::cell_model_tile_sql("c.cell_id"), "AS tile,",
          "m.mdl_id, c.cell_id, c.val",
          "FROM model_cell c JOIN model m USING (mdl_key)"),
    cell_mc, partition_by = "tile")
  log_info("built serve/cell_model/ ({length(dir_ls(cell_mc, type='directory'))} tiles)")
} else {
  log_info("serve/cell_model/ exists — skipping (RELEASE_REDO_CELL_MODEL=1 to force)")
}

# content fingerprint of the served surface (source table = deterministic, cheaper than scanning parts)
serve_hash <- msens::hash_query(con, "SELECT mdl_key, cell_id, val FROM model_cell")
n_parts    <- length(dir_ls(serve_mc, type = "directory"))
dbDisconnect(con, shutdown = TRUE)
msens::report_table(tibble(
  file = c(glue("tables/{rel_tables}.parquet"), glue("serve/model_cell/ ({n_parts} mdl_id partitions)")),
  mb   = round(c(file_size(glue("{tbl_dir}/{rel_tables}.parquet")),
                 sum(file_size(dir_ls(serve_mc, recurse = TRUE, glob = "*.parquet")))) / 1e6, 1)),
  caption = "released Parquet (V2/zstd/80MB)")
released Parquet (V2/zstd/80MB)
file mb
tables/cell.parquet 392.2
tables/taxon.parquet 1.0
tables/dataset.parquet 0.0
tables/model.parquet 1.1
tables/metric.parquet 0.0
tables/zone_taxon.parquet 5.0
tables/cell_metric.parquet 70.9
tables/zone.parquet 0.0
tables/zone_cell.parquet 0.2
tables/zone_metric.parquet 0.0
tables/native_asset.parquet 0.7
serve/model_cell/ (17763 mdl_id partitions) 3266.5

3 Sync data → S3

Code
sync <- function(local, prefix, del = FALSE, tries = 4) {
  if (!dir_exists(local)) { log_warn("skip (missing): {local}"); return(invisible()) }
  args <- c("s3", "sync", local, glue("{s3_ver}/{prefix}"), "--only-show-errors", "--no-progress")
  if (del) args <- c(args, "--delete")   # prune stale S3 keys not present locally
  # retry transient network errors: `aws s3 sync` is resumable (re-uploads only missing/changed),
  # so retrying the same command picks up where a dropped connection left off.
  for (i in seq_len(tries)) {
    st <- system2("aws", args, stdout = TRUE, stderr = TRUE)
    if (is.null(attr(st, "status")) || attr(st, "status") == 0) {
      log_info("synced {path_file(local)} -> {s3_ver}/{prefix}{if (del) ' (--delete)' else ''}")
      return(invisible())
    }
    log_warn("sync {prefix} attempt {i}/{tries} failed (transient?); retrying …")
  }
  stop(paste(tail(st, 20), collapse = "\n"))
}
# derived tables (incl native_asset, which the species-app native/model toggle reads) can be
# pushed on their own via RELEASE_S3_TABLES, without the multi-GB serve/dist_merged cutover.
if (do_s3_tbl) sync(tbl_dir, "tables")
if (do_s3) {
  # serve is Hive-partitioned by mdl_id, which RENUMBERS when the model set changes (e.g. the
  # bird crosswalk merged 25 taxa) -> --delete prunes stale partitions + the legacy single file.
  sync(serve_dir,                       "serve", del = TRUE)
  sync(glue("{dir_atlas}/dist_merged"), "dist_merged")
  sync(glue("{dir_atlas}/registry"),    "registry")
  if (do_raw) sync(glue("{dir_atlas}/dist"), "dist")
} else if (do_s3_tbl) {
  log_info("RELEASE_S3_TABLES: synced tables/ only (serve/dist_merged/registry NOT pushed)")
} else log_info("RELEASE_NO_S3 set — staged locally only")

4 Public-read CORS for browser DuckDB-WASM (query.html)

The workflows query.html page runs DuckDB-WASM in the browser and range-reads the public tables/*.parquet directly. That needs the bucket’s CORS to allow the Range request header: a bare AllowedMethods:[GET] rule returns 403 on the preflight (Access-Control-Request-Headers: range). One-time + idempotent; gated behind RELEASE_CORS=1 so a normal release leaves CORS untouched. The policy is a committed file (data/s3/cors_public.json) — public-read only, no ACL change. Roll back with the 2-line {AllowedMethods:[GET], AllowedOrigins:[*]} prior config.

Code
cors_file <- here("data/s3/cors_public.json")
if (nzchar(Sys.getenv("RELEASE_CORS")) && file_exists(cors_file)) {
  bucket <- sub("^s3://([^/]+)/.*$", "\\1", s3_ver)                    # oceanmetrics.io-public
  system2("aws", c("s3api", "put-bucket-cors", "--bucket", bucket,
                   "--cors-configuration", glue("file://{cors_file}")))
  log_info("applied public-read CORS (GET/HEAD + Range header) to s3://{bucket} for browser DuckDB-WASM")
} else log_info("s3 CORS unchanged (set RELEASE_CORS=1 to (re)apply data/s3/cors_public.json)")

5 Build the tiny view DB (views over the S3 Parquet)

Code
viewdb <- glue("{serve_dir}/serve.duckdb")
if (file_exists(viewdb)) file_delete(viewdb)
cv <- dbConnect(duckdb(viewdb))
# view sources: the released S3 Parquet (production) OR the local staged Parquet when
# RELEASE_NO_S3 (a local dry-run — nothing was pushed, so an S3 view would point at nothing).
if (do_s3 || do_s3_tbl) {   # S3 views whenever the tables live on S3 (even a tables-only push)
  dbExecute(cv, "INSTALL httpfs; LOAD httpfs;")
  # tables: single files via explicit path-style HTTPS -> anonymous GET + HTTP range (the dotted
  # bucket breaks virtual-hosted TLS, so never use s3://… for these).
  tbl_src <- glue("{s3_http}/tables")
  # model_cell glob needs S3 LIST -> the aws extension + credential chain (server has creds).
  dbExecute(cv, "INSTALL aws; LOAD aws; SET s3_url_style='path';")
  dbExecute(cv, "CREATE OR REPLACE SECRET atlas_s3 (TYPE s3, PROVIDER credential_chain, REGION 'us-east-1');")
  mc_glob <- glue("s3://{sub('^s3://', '', s3_ver)}/serve/model_cell/*/*.parquet")
} else {
  tbl_src <- tbl_dir                                 # local staged tables
  mc_glob <- glue("{serve_mc}/*/*.parquet")          # local partitioned serve
}
# scoring tables store the metric in `val`; the Shiny apps still reference `value` (the pipeline
# value->val rename never fully propagated), so expose `val AS value` on these views as a
# back-compat alias — both column names resolve, no per-app query rewrites needed.
val_tbls <- c("cell_metric", "zone_metric", "zone")
for (t in rel_tables) {
  extra <- if (t %in% val_tbls) ", val AS value" else ""
  dbExecute(cv, glue("CREATE VIEW {t} AS SELECT *{extra} FROM read_parquet('{tbl_src}/{t}.parquet')"))
}
# model_cell is Hive-partitioned by the integer mdl_id (stored rows are cell_id,val). The HOT tile
# path reads one partition by EXACT path in titiler (factory mdl_key->mdl_id fast-path, anonymous
# GET, no LIST). This glob VIEW is for ad-hoc/statistics queries; it joins the `model` view back to
# the STABLE mdl_key so ad-hoc queries can select by mdl_key, not the internal id.
dbExecute(cv, glue("CREATE VIEW model_cell AS
  SELECT m.mdl_key, mc.mdl_id, mc.cell_id, mc.val, mc.val AS value
  FROM read_parquet('{mc_glob}', hive_partitioning = true) mc
  JOIN model m USING (mdl_id)"))
[1] 0
Code
# cell_model — the SAME rows partitioned by a 2.5-degree spatial tile, for the
# per-CELL and per-POLYGON questions model_cell cannot answer (clicked cell,
# Report-tab area). Deliberately a LOCAL path on the server, never S3: those
# queries touch many partitions, and over HTTPS that is exactly the pattern that
# fails. It is small enough to keep on disk (1.4 GB across 422 tiles).
# NOTE the view is created against the LOCAL staged path here. DuckDB validates
# a read_parquet() glob when the view is CREATED, so a server path cannot be
# used from the laptop — those files do not exist until the deploy chunk rsyncs
# them. The deploy chunk therefore REPLACES this view server-side, pointing at
# the server's local copy.
dbExecute(cv, glue("CREATE VIEW cell_model AS
  SELECT cm.tile, cm.mdl_id, cm.cell_id, cm.val, cm.val AS value
  FROM read_parquet('{cell_mc}/*/*.parquet', hive_partitioning = true) cm"))
[1] 0
Code
dbDisconnect(cv, shutdown = TRUE)
log_info("view DB {viewdb} ({round(file_size(viewdb)/1e3,1)} KB) — {if (do_s3 || do_s3_tbl) 'S3' else 'LOCAL'} sources, model_cell by mdl_id, cell_model by spatial tile (local)")

7 Deploy to msens1 (parallel titiler-{ver})

Code
if (!do_deploy) {
  log_info("RELEASE_DEPLOY unset — skipping server deploy (S3 data + local view DB/STAC ready)")
} else {
# 1. the KB view DB + the STAC version subtree (data stays on S3 — no big rsync)
system2("ssh", c("msens", glue("mkdir -p {path_dir(srv_viewdb)} {srv_stac}")))
# ... EXCEPT cell_model, which is deliberately local on the server: the per-cell /
# per-polygon queries it serves touch many partitions, which is precisely what
# fails over HTTPS. ~1.4 GB, so rsync only what changed (--delete prunes stale
# tiles if the grid or model set ever changes).
system2("rsync", c("-aq", "--delete", glue("{cell_mc}/"), glue("msens:{srv_cellmodel}/")))
log_info("synced cell_model -> msens:{srv_cellmodel}")
system2("rsync", c("-aq", viewdb, glue("msens:{srv_viewdb}")))
# repoint cell_model at the server's LOCAL copy (the staged view still names the
# laptop path). Done here, after the rsync, because DuckDB validates the glob at
# CREATE VIEW time — it must run where the files actually are.
fix_view <- glue(
  "docker exec rstudio Rscript -e ",
  shQuote(glue(
    "con <- DBI::dbConnect(duckdb::duckdb('{srv_viewdb}')); ",
    "DBI::dbExecute(con, \"CREATE OR REPLACE VIEW cell_model AS SELECT cm.tile, cm.mdl_id, ",
    "cm.cell_id, cm.val, cm.val AS value FROM read_parquet('{srv_cellmodel}/*/*.parquet', ",
    "hive_partitioning = true) cm\"); ",
    "cat('CELLMODEL_OK', DBI::dbGetQuery(con, 'SELECT count(*) n FROM cell_model')$n, '\\n'); ",
    "DBI::dbDisconnect(con, shutdown = TRUE)")))
out_cm <- system2("ssh", c("msens", shQuote(fix_view)), stdout = TRUE, stderr = TRUE)
if (!any(grepl("CELLMODEL_OK", out_cm))) stop(paste(tail(out_cm, 15), collapse = "\n"))
log_info("cell_model view repointed on server: {grep('CELLMODEL_OK', out_cm, value = TRUE)}")
system2("rsync", c("-aq", glue("{stac_dir}/{ver}/"), glue("msens:{srv_stac}/")))
# add this version as a child of the STAC root (keep other versions)
system2("ssh", c("msens", shQuote(glue(
  "python3 - <<'PY'\nimport json\np='/share/data/derived/stac/catalog.json'\nc=json.load(open(p))\nh=[l['href'] for l in c['links'] if l.get('rel')=='child']\nif './{ver}/collection.json' not in h:\n c['links'].append({{'rel':'child','href':'./{ver}/collection.json'}}); json.dump(c,open(p,'w'),indent=2)\nPY"))))
# 2. update the server repo + REBUILD & FORCE-RECREATE titiler-{ver} (so a new factory always
# takes effect even if compose sees no config change), restart caddy. Explicit fetch + ff-only
# with `set -e` so a stale/failed pull FAILS LOUDLY instead of silently serving the old factory
# (a silent `git pull --quiet` once left the mdl_key factory undeployed for days).
deploy_out <- system2("ssh", c("msens", shQuote(glue(
  "set -e; cd {srv_repo} && git fetch --quiet origin && git merge --ff-only origin/main && ",
  "docker compose up -d --build --force-recreate titiler-{ver} && docker compose restart caddy && ",
  "echo DEPLOY_OK $(git rev-parse --short HEAD)"))), stdout = TRUE, stderr = TRUE)
log_info("titiler-{ver} deploy: {paste(tail(deploy_out, 2), collapse=' | ')}")
if (!any(grepl("DEPLOY_OK", deploy_out))) stop(paste(tail(deploy_out, 15), collapse = "\n"))
# 3. smoke test — the stable-mdl_key fast-path (factory resolves mdl_key->mdl_id, reads one
# partition). Hit tilejson.json (not /statistics, which requires a geometry) → expect 200.
c0 <- dbConnect(duckdb(sdm_db, read_only = TRUE))
mk <- dbGetQuery(c0, "SELECT mdl_key FROM model WHERE ds_key='ms_merge' ORDER BY mdl_id LIMIT 1")$mdl_key
dbDisconnect(c0, shutdown = TRUE)
code <- system2("curl", c("-s","-o","/dev/null","-w","%{http_code}","--retry","3",
  glue("{titiler_v8}/tilejson.json?mdl_key={URLencode(mk, reserved = TRUE)}")), stdout = TRUE)
log_info("titiler-{ver} /tilejson.json?mdl_key={mk} smoke test: HTTP {code} (expect 200)")
if (!identical(code, "200")) log_warn("titiler-{ver} merged-surface smoke test != 200 — check the factory")
}

8 Serve the derived tables from LOCAL Parquet (versioned sync)

S3 stays the published artifact; serving reads a server-local copy. Every app interaction re-reads each Parquet footer over HTTPS, which puts a ~140 ms floor under every query — worst for the small, interactive ones. Benchmarked on msens1 itself (same region as the bucket, so best-case S3), running the scores app’s real queries:

query rows cold S3 cold local warm S3 warm local
Program-areas panel (app.R:1488) 20 0.364 s 0.015 s 0.147 s 0.008 s
cell layer, full study area 623,212 1.267 s 0.244 s 0.247 s 0.180 s
species table for one Program Area 3,232 0.468 s 0.041 s 0.054 s 0.012 s
clicked-cell environment 1 0.710 s 0.175 s 0.150 s 0.068 s

18–24× on the interactive queries, and cold is what every new app session pays. The whole set is 445 MB and syncs in ~14 s, so there is no reason to pay that. Same rsync-then-repoint pattern as cell_model, and for the same reason: DuckDB validates a read_parquet() path at CREATE VIEW time, so the repoint must run where the files are. Versioned by {ver} on both ends, so a version bump lands in its own directory and the views follow it.

model_cell deliberately stays on S3 — it is the per-model point-read surface titiler uses, not something the apps scan.

Code
if (!do_tables) {
  log_info("RELEASE_DEPLOY/DEPLOY_TABLES unset — leaving the server's table views as they are")
} else {
  system2("ssh", c("msens", glue("mkdir -p {srv_tables} {srv_modelcell}")))
  system2("rsync", c("-aq", "--delete", glue("{tbl_dir}/"), glue("msens:{srv_tables}/")))
  log_info("synced tables -> msens:{srv_tables}")
  # model_cell too — the LAST remote table. The v8 species app reads it in two
  # places (mdl_bbox() for fit_bounds, and the clicked-cell value), and both are
  # degrading silently today: the S3 GLOB needs LIST credentials the app
  # container lacks, so the tryCatch around each returns NULL/NA and the app
  # quietly falls back to the study-area extent and a blank value. 3.3 GB.
  #
  # Safe for tiles: titiler builds its own read_parquet path from the
  # SERVE_MODEL_CELL env var (server/titiler/factory.py) and only uses this DB
  # for the `model` registry — it never reads this view.
  system2("rsync", c("-aq", "--delete", glue("{serve_mc}/"), glue("msens:{srv_modelcell}/")))
  log_info("synced model_cell -> msens:{srv_modelcell}")

  # Repoint the views — into a COPY, then swap it in atomically.
  #
  # Editing serve.duckdb in place needs a WRITE lock, and every running Shiny
  # session holds a read-only one, so a live server fails with
  #   Could not set lock on file ".../serve.duckdb": Conflicting lock is held
  # (it only ever succeeded when no app happened to be open). Copying first
  # needs no lock — readers never mutate the file — and `mv` is atomic within
  # the filesystem: in-flight sessions keep serving from the old inode, and the
  # restart.txt touch rolls them onto the new one.
  #
  # `val AS value` is preserved (the apps still reference `value`), and each
  # repoint is verified with a real count so a half-synced directory FAILS
  # LOUDLY here rather than being discovered later as an empty app panel.
  #
  # The model_cell probe must draw its mdl_id from model_cell, NOT from `model`.
  # `model` registers every model including the raw per-dataset ones, and only
  # merged models have cells -- so `min(mdl_id) FROM model` is a raw model whose
  # count is legitimately 0. That probe therefore returned 0 on a PERFECTLY
  # HEALTHY release and would have returned 0 on a broken one too: a check that
  # cannot distinguish the two states verifies nothing. It printed 0 beside
  # "VIEWS_OK" for the 580,568,326-row v8 release.
  repoint <- glue(
    "vt <- strsplit('cell_metric,zone_metric,zone', ',')[[1]]; ",
    "tt <- strsplit('{paste(rel_tables, collapse = \",\")}', ',')[[1]]; ",
    "con <- DBI::dbConnect(duckdb::duckdb('{srv_viewdb}.new')); ",
    "for (t in tt) DBI::dbExecute(con, sprintf(",
    "\"CREATE OR REPLACE VIEW %s AS SELECT *%s FROM read_parquet('{srv_tables}/%s.parquet')\", ",
    "t, if (t %in% vt) ', val AS value' else '', t)); ",
    "DBI::dbExecute(con, \"CREATE OR REPLACE VIEW model_cell AS SELECT m.mdl_key, ",
    "mc.mdl_id, mc.cell_id, mc.val, mc.val AS value FROM read_parquet(",
    "'{srv_modelcell}/*/*.parquet', hive_partitioning = true) mc JOIN model m USING (mdl_id)\"); ",
    "zm <- DBI::dbGetQuery(con, 'SELECT count(*) n FROM zone_metric')$n; ",
    "mc <- DBI::dbGetQuery(con, 'SELECT count(*) n FROM model_cell WHERE mdl_id = ",
    "(SELECT min(mdl_id) FROM model_cell)')$n; ",
    "stopifnot(\'zone_metric view is empty\' = zm > 0, ",
    "\'model_cell view returned no rows for its own first mdl_id\' = mc > 0); ",
    "cat('VIEWS_OK', length(tt), zm, mc, '\\n'); ",
    "DBI::dbDisconnect(con, shutdown = TRUE)")
  v8_apps <- c("species", "scores")
  cmd_rp <- glue(
    "set -e; ",
    "cp -f {srv_viewdb} {srv_viewdb}.new; ",                       # no lock needed
    "docker exec rstudio Rscript -e {shQuote(repoint)}; ",
    "mv -f {srv_viewdb}.new {srv_viewdb}; ",                       # atomic swap
    "{paste(sprintf('touch /share/github/MarineSensitivity/apps_v8/%s/restart.txt', v8_apps), collapse='; ')}")
  out_rp <- system2("ssh", c("msens", shQuote(cmd_rp)), stdout = TRUE, stderr = TRUE)
  if (!any(grepl("VIEWS_OK", out_rp))) stop(paste(tail(out_rp, 20), collapse = "\n"))
  log_info("views repointed to local Parquet + v8 apps reloaded: {grep('VIEWS_OK', out_rp, value = TRUE)}")
}

9 Deploy the plumber API (DEPLOY_API=1)

The /report and /species.csv endpoints run in the plumber container, which is a separate image from rstudio — the DEPLOY_APPS msens reinstall never reached it. Left unpinned, its msens layer cached at 0.5.0 while the package moved to 0.12.0, so /report?ver=v8 answered Cannot open database "…/v8/sdm.duckdb" (no serve.duckdb fallback, msens 0.9.1) and drawn areas had no cell_model support (0.9.0). server/plumber/Dockerfile now pins msens by commit and asserts the version at build time, so this cannot recur silently.

Rebuild is required (not just a restart): msens lives in the image, not on a volume.

Code
if (!nzchar(Sys.getenv("DEPLOY_API"))) {
  log_info("DEPLOY_API unset — leaving the plumber API as it is")
} else {
  api_co <- "/share/github/MarineSensitivity/api"
  cmd_api <- glue(
    "set -e; ",
    "cd {api_co}    && git fetch --quiet origin && git merge --ff-only origin/main; ",
    "cd {srv_repo}  && git fetch --quiet origin && git merge --ff-only origin/main; ",
    # --force-recreate, same reason the titiler deploy uses it: plumber.R is
    # parsed ONCE at container start, and the api repo is a bind mount, so a
    # source-only change leaves compose seeing no image change and the container
    # keeps serving the old code indefinitely. (report.qmd is read per-render, so
    # it updates without this — which made the gap easy to miss: the mdl_key fix
    # took effect while the mapsp_base fix silently did not.)
    "docker compose up -d --build --force-recreate plumber; ",
    "echo API_OK $(git -C {api_co} rev-parse --short HEAD)")
  out_api <- system2("ssh", c("msens", shQuote(cmd_api)), stdout = TRUE, stderr = TRUE)
  if (!any(grepl("API_OK", out_api))) stop(paste(tail(out_api, 20), collapse = "\n"))
  # assert the container actually carries the msens the endpoints need
  chk <- system2("ssh", c("msens", shQuote(glue(
    "docker exec plumber Rscript -e ",
    shQuote("cat('MSENS', as.character(packageVersion('msens')), '\\n')")))),
    stdout = TRUE, stderr = TRUE)
  log_info("plumber API deployed: {grep('API_OK', out_api, value = TRUE)} | {grep('MSENS', chk, value = TRUE)}")
  if (!any(grepl("MSENS 0\\.(1[2-9]|[2-9][0-9])", chk)))
    stop("plumber msens < 0.12.0 — the v8 drawn-area path needs cells_in_polygon(poly, con)")
}

10 Deploy the v8 Shiny apps (reload only apps_v8; v7 untouched)

Reproducible app deploy — no ad-hoc ssh. Pull the apps_v8 checkout (MarineSensitivity/apps@main, symlinked as /share/shiny_apps/{species,scores}_v8) and reload only the v8 apps via Shiny Server’s per-app restart.txt. The v7 apps are a separate checkout (/share/shiny_apps/{species,scores}mapsp/mapgl) sharing the same container; they are not restarted, so v7 serving is uninterrupted. Gated by RELEASE_DEPLOY (full release) or the granular DEPLOY_APPS=1.

Code
if (!do_apps) {
  log_info("RELEASE_DEPLOY/DEPLOY_APPS unset — skipping Shiny app deploy")
} else {
  apps_co  <- "/share/github/MarineSensitivity/apps_v8"   # main branch; symlinked as *_v8
  msens_co <- "/share/github/MarineSensitivity/msens"
  v8_apps  <- c("species", "scores")
  touches  <- paste(sprintf("touch %s/%s/restart.txt", apps_co, v8_apps), collapse = "; ")
  # `set -e` + explicit ff-only fetch (a silent `git pull --quiet` once left the app + msens
  # stale for hours). ALSO reinstall msens into the shiny/rstudio container: a stale container
  # msens lacked `cell_tile_url(mdl_key=)` and crashed the merged surface. Reload only the v8 apps.
  cmd <- glue("set -e; ",
              "cd {apps_co}  && git fetch --quiet origin && git merge --ff-only origin/main; ",
              "cd {msens_co} && git fetch --quiet origin && git merge --ff-only origin/main && ",
              "docker exec rstudio R CMD INSTALL --no-multiarch {msens_co} >/dev/null 2>&1; ",
              "{touches}; echo APPS_OK $(git -C {apps_co} rev-parse --short HEAD)")
  out <- system2("ssh", c("msens", shQuote(cmd)), stdout = TRUE, stderr = TRUE)
  if (!any(grepl("APPS_OK", out))) stop(paste(tail(out, 15), collapse = "\n"))
  log_info("deployed v8 apps [{paste(v8_apps, collapse=', ')}] + reinstalled msens; v7 apps untouched: {tail(out,1)}")
}

11 Deploy the v7 Shiny apps (opt-in — normally left alone)

The v7 apps are a separate checkout (/share/github/MarineSensitivity/apps on branch v7, symlinked as /share/shiny_apps/{species,scores}mapsp/mapgl). They are the default live apps, so they stay untouched by a routine v8 release — hence a dedicated DEPLOY_APPS_V7=1 flag that RELEASE_DEPLOY deliberately does not imply.

Use it when a change must reach both generations — e.g. the usage-analytics instrumentation, which is only meaningful if it covers the apps people actually use today. msens is installed into the shared container by the v8 chunk above; v7 picks it up from the same library.

Code
if (!do_apps_v7) {
  log_info("DEPLOY_APPS_V7 unset — v7 apps left untouched (the default)")
} else {
  apps_v7_co <- "/share/github/MarineSensitivity/apps"   # branch v7; symlinked as scores/species
  v7_apps    <- c("mapsp", "mapgl")                      # = species / scores
  touches_v7 <- paste(sprintf("touch %s/%s/restart.txt", apps_v7_co, v7_apps), collapse = "; ")
  # ff-only against origin/v7 (NOT main — this checkout tracks the v7 branch)
  cmd_v7 <- glue("set -e; ",
                 "cd {apps_v7_co} && git fetch --quiet origin && git merge --ff-only origin/v7; ",
                 "{touches_v7}; echo APPSV7_OK $(git -C {apps_v7_co} rev-parse --short HEAD)")
  out_v7 <- system2("ssh", c("msens", shQuote(cmd_v7)), stdout = TRUE, stderr = TRUE)
  if (!any(grepl("APPSV7_OK", out_v7))) stop(paste(tail(out_v7, 15), collapse = "\n"))
  log_info("deployed v7 apps [{paste(v7_apps, collapse=', ')}]: {tail(out_v7,1)}")
}

12 Manifest

Code
# content-addressed: fingerprint of the served model_cell surface (run-mode flags like
# pushed_s3/deployed are side effects, not content, so they're excluded).
msens::write_manifest(
  manifest, target = "release_marine_atlas", content_hash = serve_hash,
  stats = list(ver = ver, s3 = s3_ver, n_tables = length(rel_tables),
               tables = paste(sort(rel_tables), collapse = ","),
               serve = "serve/model_cell/ (partitioned by mdl_id)", n_partitions = n_parts,
               titiler = titiler_v8),
  force = msens::force_target("release_marine_atlas"))