Skip to content

Commit 97c4371

Browse files
authored
fix(triple-store): harden Fuseki/TDB2 against boot-time 500s (#1057)
* fix(triple-store): harden Fuseki/TDB2 against boot-time 500s A persistent HTTP 500 on the trivial boot probe (`ASK { ?s ?p ?o }`) was crashing the whole engine/Dagster import. Root cause: the `ds` TDB2 dataset becomes unreadable after an unclean shutdown (OOM-kill or disk-full mid-write), while the container healthcheck only probed the web root — so Fuseki reported healthy and the app booted against a dead store. Adapter (naas-abi-core): - `ApacheJenaTDB2._test_connection` now retries transient 500/503/connection errors with backoff and raises a domain `RequestError` carrying Fuseki's real response body, instead of a bare `HTTPError` that killed the import. - 4 new unit tests (29 pass). Compose (root dev stack + naas-abi-cli deploy template): - Healthcheck now probes the dataset (authenticated `ASK` against `/ds/query`) so a broken TDB2 dataset marks the container unhealthy; adds `start_period`. - `JVM_ARGS=-Xmx2g` + `mem_limit: 4g` so an OOM can't Docker-kill the JVM mid-write (the unclean kill that corrupts TDB2). - Dropped `pull_policy: always` so a restart uses the cached image. - Image kept on `stain/jena-fuseki:latest` (Apache publishes no prebuilt image; moving off it is a separate backup + dump-and-reload migration). Recoverability + deploy propagation (naas-abi-cli): - New `scripts/fuseki_backup.sh` and a deploy-template `docker/fuseki/backup.sh` (TDB2 backup + compaction via the admin API) so `abi deploy local --regenerate` ships the helper into deployments. - Regression test asserts the rendered compose carries the hardening (12 pass). * fix(triple-store): lower Fuseki healthcheck start_period to 20s
1 parent 776fe67 commit 97c4371

7 files changed

Lines changed: 461 additions & 13 deletions

File tree

docker-compose.yml

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,15 +268,28 @@ services:
268268
# Stores structured knowledge as RDF triples for semantic reasoning
269269
# TDB2 provides transactional storage with SPARQL query/update endpoints
270270
fuseki:
271+
# Staying on stain/jena-fuseki (the older community image) for now — this is
272+
# only about making the *current* store reliable. Apache does NOT publish a
273+
# prebuilt Fuseki image on Docker Hub (the official path is the build-your-own
274+
# jena-fuseki-docker kit), so moving off this is a real migration (build an
275+
# image + back up + dump-and-reload the TDB2 data), tracked separately.
276+
# `pull_policy: always` dropped on purpose: a boot now uses the locally cached
277+
# image instead of requiring a registry pull every start (one less thing that
278+
# can fail a restart). Re-add it if you'd rather always re-pull.
271279
image: stain/jena-fuseki:latest
272-
pull_policy: always
273280
restart: unless-stopped
274281
ports:
275282
- 3030:3030
276283
volumes:
277284
- fuseki_data:/fuseki
285+
# Cap Docker memory so the JVM can't balloon and get OOM-killed mid-write —
286+
# an unclean kill is what leaves TDB2's node table/journal corrupt. Keep
287+
# mem_limit comfortably above JVM_ARGS -Xmx: TDB2 memory-maps its files
288+
# off-heap, so it needs headroom on top of the Java heap. Tune to host/data.
289+
mem_limit: 4g
278290
environment:
279291
- ADMIN_PASSWORD=${FUSEKI_ADMIN_PASSWORD}
292+
- JVM_ARGS=-Xmx2g
280293
command: >-
281294
bash -c '
282295
mkdir -p /fuseki/configuration /fuseki/databases/ds &&
@@ -309,10 +322,21 @@ services:
309322
" > /fuseki/configuration/ds.ttl &&
310323
/jena-fuseki/fuseki-server'
311324
healthcheck:
312-
test: ["CMD-SHELL", "curl -fsS http://localhost:3030/ >/dev/null || exit 1"]
325+
# Probe the DATASET, not just the web root. `curl /` returns 200 even when
326+
# the `ds` dataset failed to attach or is corrupt, which lets dependents
327+
# start against a dead store and then 500 on the first query. `ASK { ?s ?p
328+
# ?o }` short-circuits at the first triple (cheap) but opens TDB2 storage,
329+
# so a broken dataset correctly marks the container unhealthy. Authenticated
330+
# because the server runs with ADMIN_PASSWORD; `$$` is a literal `$` in the
331+
# container shell (Compose would otherwise treat `$ADMIN_PASSWORD` as a
332+
# host-side variable).
333+
test: ["CMD-SHELL", "curl -fsS -u admin:$$ADMIN_PASSWORD 'http://localhost:3030/ds/query?query=ASK%20%7B%20%3Fs%20%3Fp%20%3Fo%20%7D' >/dev/null || exit 1"]
313334
interval: 10s
314-
timeout: 5s
335+
timeout: 10s
315336
retries: 10
337+
# Grace window for TDB2 to attach/recover before probes count; the
338+
# interval×retries budget below still covers slower recoveries.
339+
start_period: 20s
316340
networks:
317341
- abi-network
318342

libs/naas-abi-cli/naas_abi_cli/cli/deploy/local_test.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,45 @@ def test_setup_local_deploy_regenerate_without_backup(tmp_path: Path) -> None:
211211
)
212212

213213
assert not (tmp_path / ".abi-backups").exists()
214+
215+
216+
def _service_block(compose_text: str, service: str, next_service: str) -> str:
217+
start = compose_text.index(f"\n {service}:")
218+
end = compose_text.index(f"\n {next_service}:", start)
219+
return compose_text[start:end]
220+
221+
222+
def test_setup_local_deploy_hardens_fuseki_for_reliability(tmp_path: Path) -> None:
223+
# Guards that `abi deploy local` (and therefore `--regenerate`, which re-renders
224+
# this same template) carries the Fuseki reliability hardening into a
225+
# deployment's compose, so existing deployments can be patched by regenerating.
226+
setup_local_deploy(str(tmp_path), base_domain="localhost")
227+
228+
compose_text = (tmp_path / "docker-compose.yml").read_text(encoding="utf-8")
229+
fuseki = _service_block(compose_text, "fuseki", "yasgui")
230+
lines = [line.strip() for line in fuseki.splitlines()]
231+
232+
# Image intentionally unchanged for now (staying on the community image;
233+
# migrating off it is a separate backup + dump-and-reload job).
234+
assert [line for line in lines if line.startswith("image:")] == [
235+
"image: stain/jena-fuseki:latest"
236+
]
237+
238+
# Heap + memory caps so an OOM can't Docker-kill the JVM mid-write (the
239+
# unclean kill that corrupts TDB2).
240+
assert "mem_limit: 4g" in fuseki
241+
assert "JVM_ARGS=-Xmx2g" in fuseki
242+
243+
# Healthcheck probes the dataset, not just the web root, so a broken TDB2
244+
# dataset marks the container unhealthy instead of falsely reporting ready.
245+
assert "/ds/query?query=ASK" in fuseki
246+
assert "start_period: 20s" in fuseki
247+
248+
# pull_policy: always removed (the explanatory comment mentions it, so assert
249+
# on real directive lines rather than a naive substring check).
250+
assert not any(line.startswith("pull_policy") for line in lines)
251+
252+
# The TDB2 backup/compaction helper ships into the deployment's .deploy/.
253+
backup_script = tmp_path / ".deploy" / "docker" / "fuseki" / "backup.sh"
254+
assert backup_script.exists()
255+
assert "--compact" in backup_script.read_text(encoding="utf-8")

libs/naas-abi-cli/naas_abi_cli/cli/deploy/templates/local/docker-compose.yml

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,15 +206,28 @@ services:
206206
# Stores structured knowledge as RDF triples for semantic reasoning
207207
# TDB2 provides transactional storage with SPARQL query/update endpoints
208208
fuseki:
209+
# Staying on stain/jena-fuseki (the older community image) for now — this is
210+
# only about making the *current* store reliable. Apache does NOT publish a
211+
# prebuilt Fuseki image on Docker Hub (the official path is the build-your-own
212+
# jena-fuseki-docker kit), so moving off this is a real migration (build an
213+
# image + back up + dump-and-reload the TDB2 data), tracked separately.
214+
# `pull_policy: always` dropped on purpose: a boot now uses the locally cached
215+
# image instead of requiring a registry pull every start (one less thing that
216+
# can fail a restart). Re-add it if you'd rather always re-pull.
209217
image: stain/jena-fuseki:latest
210-
pull_policy: always
211218
restart: unless-stopped
212219
ports:
213220
- 3030:3030
214221
volumes:
215222
- fuseki_data:/fuseki
223+
# Cap Docker memory so the JVM can't balloon and get OOM-killed mid-write —
224+
# an unclean kill is what leaves TDB2's node table/journal corrupt. Keep
225+
# mem_limit comfortably above JVM_ARGS -Xmx: TDB2 memory-maps its files
226+
# off-heap, so it needs headroom on top of the Java heap. Tune to host/data.
227+
mem_limit: 4g
216228
environment:
217229
- ADMIN_PASSWORD=${FUSEKI_ADMIN_PASSWORD}
230+
- JVM_ARGS=-Xmx2g
218231
command: >-
219232
bash -c '
220233
mkdir -p /fuseki/configuration /fuseki/databases/ds &&
@@ -247,10 +260,21 @@ services:
247260
" > /fuseki/configuration/ds.ttl &&
248261
/jena-fuseki/fuseki-server'
249262
healthcheck:
250-
test: ["CMD-SHELL", "curl -fsS http://localhost:3030/ >/dev/null || exit 1"]
263+
# Probe the DATASET, not just the web root. `curl /` returns 200 even when
264+
# the `ds` dataset failed to attach or is corrupt, which lets dependents
265+
# start against a dead store and then 500 on the first query. `ASK { ?s ?p
266+
# ?o }` short-circuits at the first triple (cheap) but opens TDB2 storage,
267+
# so a broken dataset correctly marks the container unhealthy. Authenticated
268+
# because the server runs with ADMIN_PASSWORD; `$$` is a literal `$` in the
269+
# container shell (Compose would otherwise treat `$ADMIN_PASSWORD` as a
270+
# host-side variable).
271+
test: ["CMD-SHELL", "curl -fsS -u admin:$$ADMIN_PASSWORD 'http://localhost:3030/ds/query?query=ASK%20%7B%20%3Fs%20%3Fp%20%3Fo%20%7D' >/dev/null || exit 1"]
251272
interval: 10s
252-
timeout: 5s
273+
timeout: 10s
253274
retries: 10
275+
# Grace window for TDB2 to attach/recover before probes count; the
276+
# interval×retries budget below still covers slower recoveries.
277+
start_period: 20s
254278
networks:
255279
- abi-network
256280

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Fuseki / TDB2 backup + compaction helper (rendered into a deployment's
4+
# .deploy/docker/fuseki/backup.sh by `abi deploy local`).
5+
#
6+
# Why this exists
7+
# ---------------
8+
# TDB2 corruption after an unclean shutdown (OOM-kill, disk-full mid-write) is
9+
# the failure mode behind the intermittent/boot-time HTTP 500s on this store.
10+
# Two server-side operations make that recoverable and less likely:
11+
#
12+
# * backup -> writes a consistent N-Quads dump of the dataset to the Fuseki
13+
# data volume ($FUSEKI_BASE/backups/). If the live TDB2 files are
14+
# ever wedged, you reload from the most recent good dump instead
15+
# of losing the graph.
16+
# * compact -> rewrites the TDB2 database into a fresh, defragmented copy and
17+
# (with deleteOld=true) drops the old generation. Reclaims the
18+
# disk that append-only TDB2 growth eats, and a freshly written
19+
# copy is far less prone to the states that precede corruption.
20+
#
21+
# Both run through Fuseki's admin HTTP API — no container exec required.
22+
#
23+
# Usage (run from the deployment root, where the .env lives)
24+
# ----------------------------------------------------------
25+
# set -a; . ./.env; set +a
26+
# bash .deploy/docker/fuseki/backup.sh # backup only
27+
# bash .deploy/docker/fuseki/backup.sh --compact # backup + compact
28+
#
29+
# Environment
30+
# -----------
31+
# FUSEKI_ADMIN_PASSWORD (required) admin password (from the deployment .env)
32+
# FUSEKI_URL base URL, default http://localhost:3030
33+
# FUSEKI_DATASET dataset name, default ds
34+
# FUSEKI_ADMIN_USER admin user, default admin
35+
#
36+
# Scheduling
37+
# ----------
38+
# Run this on a schedule (nightly is sensible). Since the stack already runs
39+
# Dagster, a Dagster schedule that shells out here is the most native option; a
40+
# host cron entry works too:
41+
# 0 3 * * * cd /path/to/deployment && set -a && . ./.env && set +a && \
42+
# bash .deploy/docker/fuseki/backup.sh --compact
43+
#
44+
set -euo pipefail
45+
46+
FUSEKI_URL="${FUSEKI_URL:-http://localhost:3030}"
47+
FUSEKI_DATASET="${FUSEKI_DATASET:-ds}"
48+
FUSEKI_ADMIN_USER="${FUSEKI_ADMIN_USER:-admin}"
49+
DO_COMPACT=0
50+
51+
for arg in "$@"; do
52+
case "$arg" in
53+
--compact) DO_COMPACT=1 ;;
54+
-h|--help) sed -n '2,45p' "$0"; exit 0 ;;
55+
*) echo "Unknown argument: $arg" >&2; exit 2 ;;
56+
esac
57+
done
58+
59+
if [[ -z "${FUSEKI_ADMIN_PASSWORD:-}" ]]; then
60+
echo "ERROR: FUSEKI_ADMIN_PASSWORD is not set (source the deployment .env first)." >&2
61+
exit 1
62+
fi
63+
64+
AUTH=(-u "${FUSEKI_ADMIN_USER}:${FUSEKI_ADMIN_PASSWORD}")
65+
BASE="${FUSEKI_URL%/}"
66+
67+
# Trigger an async admin task and echo the task id it returns.
68+
_trigger() {
69+
local path="$1"
70+
curl -fsS -X POST "${AUTH[@]}" "${BASE}${path}" \
71+
| grep -o '"taskId"[^,}]*' | grep -o '[0-9]\+' | head -n1
72+
}
73+
74+
# Poll /$/tasks/<id> until the task reports it finished; fail on error.
75+
_wait_for_task() {
76+
local task_id="$1" label="$2"
77+
[[ -z "$task_id" ]] && { echo " (no task id returned for ${label}; assuming synchronous completion)"; return 0; }
78+
echo " ${label}: task ${task_id} running…"
79+
for _ in $(seq 1 120); do
80+
local body
81+
body="$(curl -fsS "${AUTH[@]}" "${BASE}/\$/tasks/${task_id}")" || true
82+
if echo "$body" | grep -q '"finished"'; then
83+
if echo "$body" | grep -qi '"success"[[:space:]]*:[[:space:]]*false'; then
84+
echo " ${label}: FAILED — ${body}" >&2
85+
return 1
86+
fi
87+
echo " ${label}: done."
88+
return 0
89+
fi
90+
sleep 5
91+
done
92+
echo " ${label}: timed out waiting for completion" >&2
93+
return 1
94+
}
95+
96+
echo "==> Backing up dataset '${FUSEKI_DATASET}' at ${BASE}"
97+
BACKUP_TASK="$(_trigger "/\$/backup/${FUSEKI_DATASET}")"
98+
_wait_for_task "$BACKUP_TASK" "backup"
99+
echo " Backup written to the Fuseki data volume under \$FUSEKI_BASE/backups/"
100+
101+
if [[ "$DO_COMPACT" -eq 1 ]]; then
102+
echo "==> Compacting dataset '${FUSEKI_DATASET}' (deleteOld=true)"
103+
COMPACT_TASK="$(_trigger "/\$/compact/${FUSEKI_DATASET}?deleteOld=true")"
104+
_wait_for_task "$COMPACT_TASK" "compact"
105+
fi
106+
107+
echo "==> Done."

libs/naas-abi-core/naas_abi_core/services/triple_store/adaptors/secondary/ApacheJenaTDB2.py

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,20 +180,91 @@ def __init__(
180180

181181
logger.info("ApacheJenaTDB2 adapter initialized: %s", self.jena_tdb2_url)
182182

183-
def _test_connection(self):
184-
response = self._session.get(
185-
self.query_endpoint,
186-
params={"query": "ASK { ?s ?p ?o }"},
187-
timeout=self.timeout,
188-
)
189-
response.raise_for_status()
183+
def _test_connection(self) -> None:
184+
"""Verify the dataset is queryable, tolerating a slow/starting server.
185+
186+
The probe query is ``ASK { ?s ?p ?o }`` — it short-circuits at the first
187+
triple (so it stays cheap even on a huge dataset) while still opening and
188+
reading TDB2 storage, which is what makes it a genuine *corruption*
189+
detector rather than a mere liveness ping.
190+
191+
A freshly (re)started Fuseki can take several seconds to attach a large
192+
TDB2 dataset, during which it may refuse the connection or answer 500/503.
193+
Those are transient, so we retry with the same exponential back-off used
194+
by the read/write paths instead of letting a single boot-time blip crash
195+
the whole engine import (which manifests as the Dagster code-server
196+
failing to load). On a *persistent* failure we raise a domain
197+
``RequestError`` carrying Fuseki's own response body (e.g. a TDB2 recovery
198+
stack trace), so the cause is diagnosable rather than a bare
199+
``HTTPError: 500 Server Error``.
200+
"""
201+
last_error: Optional[Exceptions.RequestError] = None
202+
for attempt in range(self._CONNECT_MAX_RETRIES + 1):
203+
try:
204+
response = self._session.get(
205+
self.query_endpoint,
206+
params={"query": "ASK { ?s ?p ?o }"},
207+
timeout=self.timeout,
208+
)
209+
except requests.RequestException as exc:
210+
# Connection refused/reset/read-timeout while Fuseki is still
211+
# coming up. Treat as transient and retry.
212+
last_error = Exceptions.RequestError(
213+
operation="connect",
214+
message=(
215+
f"Could not reach Fuseki at {self.query_endpoint}: {exc}"
216+
),
217+
endpoint=self.query_endpoint,
218+
attempts=attempt + 1,
219+
)
220+
else:
221+
if response.status_code not in self._RETRYABLE_STATUS_CODES:
222+
# Success (<400) or a non-retryable error (e.g. 401/404):
223+
# surface it immediately with the server's body.
224+
self._raise_for_status(
225+
response,
226+
operation="connect",
227+
endpoint=self.query_endpoint,
228+
attempts=attempt + 1,
229+
)
230+
return
231+
# Retryable 500/503: capture the body in case this is the last
232+
# attempt, then fall through to back-off.
233+
try:
234+
self._raise_for_status(
235+
response,
236+
operation="connect",
237+
endpoint=self.query_endpoint,
238+
attempts=attempt + 1,
239+
)
240+
except Exceptions.RequestError as exc:
241+
last_error = exc
242+
243+
if attempt < self._CONNECT_MAX_RETRIES:
244+
delay = self.retry_delay * (2 ** attempt) + random.uniform(0, 0.1)
245+
logger.warning(
246+
"Fuseki connectivity check failed (attempt %d/%d); "
247+
"retrying in %.2fs",
248+
attempt + 1,
249+
self._CONNECT_MAX_RETRIES + 1,
250+
delay,
251+
)
252+
time.sleep(delay)
253+
254+
assert last_error is not None # loop always sets it before exhausting
255+
raise last_error
190256

191257
# ------------------------------------------------------------------
192258
# Internal HTTP helpers with retry
193259
# ------------------------------------------------------------------
194260

195261
_RETRYABLE_STATUS_CODES = frozenset({500, 503})
196262

263+
# Boot-time connectivity probe retries. A restarting Fuseki can take a few
264+
# seconds to attach a large TDB2 dataset; we tolerate that many transient
265+
# failures before declaring the store unreachable.
266+
_CONNECT_MAX_RETRIES = 5
267+
197268
# Upper bound on how many characters of the server's error body we keep.
198269
# Fuseki error responses are usually a short message + Java stack trace;
199270
# this is plenty to diagnose OOM / disk-full / lock aborts without bloating

0 commit comments

Comments
 (0)