---
title: "Release marine-atlas — versioned Parquet → S3, view-DB serving, STAC"
msens:
target_name: release_marine_atlas
workflow_type: release
# releases the scored+registry+native tables and the serve surface — must run AFTER the
# whole chain, NOT just [auto] (grid+ingest). These 4 terminals transitively pull in
# merge -> score -> registry -> native so release never runs on a stale model_cell.
dependency: [build_registry, score_zone_metrics, publish_native, build_common_names]
output: data/manifests/release_marine-atlas.json
editor_options:
chunk_output_type: console
---
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. **STAC** — `stac_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).
## Setup
```{r}
#| label: setup
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)) })
```
## Stage derived tables + serving surface → Parquet
```{r}
#| label: stage
con <- dbConnect(duckdb(sdm_db, read_only = TRUE))
dbExecute(con, "PRAGMA memory_limit='12GB'"); dbExecute(con, "PRAGMA threads=6")
dbExecute(con, glue("PRAGMA temp_directory='{dir_atlas}/duckdb_tmp'"))
# 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)")
```
## Sync data → S3
```{r}
#| label: s3
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")
```
## Public-read CORS for browser DuckDB-WASM (`query.html`)
The workflows [`query.html`](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.
```{r}
#| label: s3-cors
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)")
```
## Build the tiny view DB (views over the S3 Parquet)
```{r}
#| label: viewdb
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)"))
# 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"))
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)")
```
## Emit STAC catalog (tile links → titiler-{ver})
```{r}
#| label: stac
suppressWarnings(try(library(msens), silent = TRUE))
source(here("../msens/R/stac.R")) # override any stale install with the current (v8) generators
stac_dir <- glue("{dir_atlas}/stac")
cfg <- stac_cfg(ver); cfg$titiler_base <- titiler_v8
con <- dbConnect(duckdb(sdm_db, read_only = TRUE))
stac_build(version = ver, dir_out = stac_dir, cfg = cfg, con = con)
dbDisconnect(con, shutdown = TRUE)
log_info("STAC catalog: {stac_dir}/{ver}/collection.json")
```
## Deploy to msens1 (parallel titiler-{ver})
```{r}
#| label: deploy
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")
}
```
## 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.
```{r}
#| label: tables-local
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)}")
}
```
## 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.
```{r}
#| label: deploy-api
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)")
}
```
## 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`.
```{r}
#| label: deploy-apps
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)}")
}
```
## 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.
```{r}
#| label: deploy-apps-v7
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)}")
}
```
## Manifest
```{r}
#| label: manifest
# 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"))
```