Skip to content

Fixes #36437: Compact the RDF store on a schedule and pause writes during maintenance - #36471

Open
manerow wants to merge 4 commits into
mainfrom
fix/36437-rdf-storage-maintenance
Open

manerow wants to merge 4 commits into
mainfrom
fix/36437-rdf-storage-maintenance

Conversation

@manerow

@manerow manerow commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Describe your changes:

Fixes #36437

Problem

Fuseki's TDB2 store never reuses disk by itself. Every write copies the index pages it touches, and the old pages stay until the dataset is compacted. Rewriting one entity costs about 750 KB, so a store that only receives live writes grows by tens of gigabytes a night under ingestion, until the volume is full and Fuseki crashes on every restart.

Why it happened

Only the RDF reindex app compacted, weekly, and only after its CLEAR ALL. That clear ran under the 60-second request timeout. On a large store it timed out, the run failed before compacting, and nothing ever freed space again. Fuseki also blocks writers for a whole compaction, so live writes that arrived meanwhile timed out, used up their retries and were dead-lettered.

What this PR changes

  • Scheduled compaction. One server compacts the serving dataset every 24 hours (RDF_COMPACTION_INTERVAL_HOURS), never during a reindex. A failed compaction is logged as an error and retried 15 minutes later.
  • A write pause during maintenance. During a compaction or reindex clear, every server holds back writes to the serving dataset; they wait in SQL and apply afterwards. Waiting uses no retry, trips no circuit breaker and leaves the projection healthy.
  • A reindex that cannot time out on its clear. The clear gets a maintenance budget (RDF_MAINTENANCE_MAX_DURATION_MINUTES, 240) instead of the request timeout. Compaction always follows; if the clear failed, the run then fails.
  • Honest compaction results. Success means Fuseki reported success: true. A task Fuseki no longer reports counts as unknown, not success. Compactions are timed in ontology.rdf.compaction, with a last-success gauge for alerts.
  • A lock race: taking over an expired reindex lock deleted it by key, so two servers could both hold it.

The leases use the existing rdf_reindex_lock table, so there is no migration. Decision record: ADR:2026-10-28-rdf-storage-maintenance-pauses-serving-writes-and-compacts-on-a-schedule.

What operators will notice

Disk use stays near the live graph's size. Live writes wait out a compaction (about 20 seconds for a 1.2M-triple graph) instead of failing.

How it was tested

  • Unit tests for the compaction client against a stub Fuseki, the schedule across two servers, and the reindex reset order.
  • RdfStorageMaintenanceIT on MySQL and PostgreSQL: paused writes wait without using attempts and apply afterwards, only the holder writes during a pause, an expired pause stops holding writes, and a maintenance clear plus compaction shrink the dataset. The existing RDF delivery, blue/green, lock and readiness ITs pass.
  • Locally, on the production Fuseki image with a 34,000-entity catalog: scheduled compaction and the reindex reset both completed under ingestion load, with no dead-lettered writes.

Not in this PR

  • Fuseki refusing writes before its volume fills, and compacting early when it runs low. That needs Fuseki image changes and comes in a follow-up PR.
  • Writing only the triples that changed, to cut the cost per write.

Type of change:

  • Bug fix

Checklist:

  • I have read the CONTRIBUTING document.
  • My PR title is Fixes <issue-number>: <short explanation>
  • My PR is linked to a GitHub issue via Fixes #<issue-number> above.
  • I have commented on my code, particularly in hard-to-understand areas.
  • For JSON Schema changes: no migration is needed; the new configuration block has defaults.
  • I have added tests (unit / integration) and listed them above.
  • Decision check: docs/decisions/2026-10-28-rdf-storage-maintenance-pauses-serving-writes-and-compacts-on-a-schedule.md.

@manerow
manerow requested a review from a team as a code owner October 28, 2026 15:56
@manerow
manerow requested a review from pmbrull October 28, 2026 15:56
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

✅ PR checks passed

The linked issue has a description and all required Shipping project fields set. Thanks!

Comment on lines +127 to +141
private CompactionResult compactHoldingSlot(final String slot, final String jobId) {
CompactionResult result = null;
try {
result = compactExclusively(jobId);
} catch (RdfReindexRunLock.HeldByAnotherRun reindexRunning) {
LOG.info("Skipping RDF compaction while a reindex runs; it compacts itself");
} finally {
if (result == null || !result.succeeded()) {
locks.releaseLock(slot, jobId);
}
}
return result;
}

private CompactionResult compactExclusively(final String jobId) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Bug: Pause released while a timed-out/untracked Fuseki compaction still blocks

FusekiCompaction returns TIMED_OUT when maxWait runs out and UNKNOWN when an IOException cuts the poll short. CompactionResult.Outcome.TIMED_OUT itself says writes "stay blocked until it ends". In both cases the Fuseki task is still running. Even so, RdfLiveWritePause.during releases the cluster-wide pause as soon as compact() returns (RdfStorageMaintenanceService.java:153, RdfIndexApp.java:417-418). The queued live writes then reach a Fuseki writer that is still blocked, time out, and use up delivery attempts. With enough of them they get dead-lettered. That is the failure the ADR says this PR prevents. compactHoldingSlot also releases the RDF_STORAGE_NEXT_COMPACTION slot for a non-success result, so 15 minutes later a second compaction can be started against a dataset that is still compacting. Fix: when the outcome is TIMED_OUT or UNKNOWN with a task id, keep the pause held, or re-poll /$/tasks/{id} until the task is gone. Also don't release the slot until Fuseki reports no running Compact task.

Keep the pause until the task is no longer running when the wait ran out, and don't free the slot early.:

return rdf.withServingWritesPaused(jobId, () -> {
  CompactionResult result = rdf.compact(maxDuration());
  if (result.outcome() == CompactionResult.Outcome.TIMED_OUT) {
    LOG.error("Compaction {} still running in Fuseki; keeping writes paused", result.taskId());
    result = rdf.compact(maxDuration()); // or a dedicated awaitTask(taskId)
  }
  return result;
});
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +63 to +76
try {
return new Run(serverBaseUrl, authenticate).awaitStarted(dataset, maxWait, requestedAt);
} catch (IOException exception) {
return CompactionResult.of(
CompactionResult.Outcome.UNKNOWN, null, describe(exception), requestedAt, clock);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return CompactionResult.of(
CompactionResult.Outcome.UNKNOWN,
null,
"Interrupted while compacting; the task may still be running",
requestedAt,
clock);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: Non-JSON Fuseki reply makes compaction throw instead of reporting an outcome

FusekiCompaction.compact catches only IOException and InterruptedException. field() calls JsonUtils.readTree, which throws an unchecked JsonParsingException when a 200 reply has a body that is not JSON (for example an HTML page from a proxy, or a truncated body). That exception escapes compact(). RdfStorageInterface.compactStorage() is documented as best-effort and "never thrown", but it now delegates to compact(). The old implementation swallowed these errors. As a result, prepareBuildDataset (an unwrapped indexingRepository.compactStorage() call) and resetServingDataset now fail the whole rebuild on such a reply. Fix: handle parse failures in field()/TaskState.parse and map them to UNKNOWN, or catch RuntimeException in compact() and return UNKNOWN.

Treat unparseable bodies as having no field.:

static String field(final String body, final String name) {
  if (body == null || body.isBlank()) {
    return null;
  }
  final JsonNode value;
  try {
    value = JsonUtils.readTree(body).get(name);
  } catch (JsonParsingException unparseable) {
    LOG.warn("Unparseable Fuseki task response: {}", body);
    return null;
  }
  return value == null || value.isNull() || value.asText().isBlank() ? null : value.asText();
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

@github-actions

Copy link
Copy Markdown
Contributor

✅ Generated Sources Auto-Updated

The generated TypeScript types (src/generated/) and dereferenced JSON
schemas (src/jsons/, public/jsons/) have been automatically updated
based on JSON schema changes in this PR.

@github-actions

Copy link
Copy Markdown
Contributor

Jest test Coverage

UI tests summary

Lines Statements Branches Functions
Coverage: 80%
80.02% (132079/165045) 66.59% (46761/70220) 69.63% (26509/38071)

@gitar-bot

gitar-bot Bot commented Oct 28, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 0 closed / 2 findings

🔴 High risk · Pausing live RDF writes around scheduled compaction and reindex clears changes stored-data handling; lease or reset failures could leave the RDF projection incomplete.

Adds scheduled RDF store compaction with a write pause during maintenance, but changes are requested before merge. The pause is released while a Fuseki compaction may still be running after a timeout or interrupted poll, so queued writes can time out and be dead-lettered, and the next compaction slot can open against a dataset still compacting. Also, a non-JSON Fuseki reply throws an unchecked exception out of compaction instead of reporting an outcome, which can fail the whole rebuild.

⚠️ Bug: Pause released while a timed-out/untracked Fuseki compaction still blocks

📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/maintenance/RdfStorageMaintenanceService.java:127-141 📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RdfLiveWritePause.java:66-79 📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:63-76 📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:125-136 📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/CompactionResult.java:27-30 📄 openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/rdf/RdfIndexApp.java:414-425

FusekiCompaction returns TIMED_OUT when maxWait runs out and UNKNOWN when an IOException cuts the poll short. CompactionResult.Outcome.TIMED_OUT itself says writes "stay blocked until it ends". In both cases the Fuseki task is still running. Even so, RdfLiveWritePause.during releases the cluster-wide pause as soon as compact() returns (RdfStorageMaintenanceService.java:153, RdfIndexApp.java:417-418). The queued live writes then reach a Fuseki writer that is still blocked, time out, and use up delivery attempts. With enough of them they get dead-lettered. That is the failure the ADR says this PR prevents. compactHoldingSlot also releases the RDF_STORAGE_NEXT_COMPACTION slot for a non-success result, so 15 minutes later a second compaction can be started against a dataset that is still compacting. Fix: when the outcome is TIMED_OUT or UNKNOWN with a task id, keep the pause held, or re-poll /$/tasks/{id} until the task is gone. Also don't release the slot until Fuseki reports no running Compact task.

Keep the pause until the task is no longer running when the wait ran out, and don't free the slot early.
return rdf.withServingWritesPaused(jobId, () -> {
  CompactionResult result = rdf.compact(maxDuration());
  if (result.outcome() == CompactionResult.Outcome.TIMED_OUT) {
    LOG.error("Compaction {} still running in Fuseki; keeping writes paused", result.taskId());
    result = rdf.compact(maxDuration()); // or a dedicated awaitTask(taskId)
  }
  return result;
});
💡 Edge Case: Non-JSON Fuseki reply makes compaction throw instead of reporting an outcome

📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:63-76 📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:164-168 📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:181-185 📄 openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/RdfStorageInterface.java:242-247

FusekiCompaction.compact catches only IOException and InterruptedException. field() calls JsonUtils.readTree, which throws an unchecked JsonParsingException when a 200 reply has a body that is not JSON (for example an HTML page from a proxy, or a truncated body). That exception escapes compact(). RdfStorageInterface.compactStorage() is documented as best-effort and "never thrown", but it now delegates to compact(). The old implementation swallowed these errors. As a result, prepareBuildDataset (an unwrapped indexingRepository.compactStorage() call) and resetServingDataset now fail the whole rebuild on such a reply. Fix: handle parse failures in field()/TaskState.parse and map them to UNKNOWN, or catch RuntimeException in compact() and return UNKNOWN.

Treat unparseable bodies as having no field.
static String field(final String body, final String name) {
  if (body == null || body.isBlank()) {
    return null;
  }
  final JsonNode value;
  try {
    value = JsonUtils.readTree(body).get(name);
  } catch (JsonParsingException unparseable) {
    LOG.warn("Unparseable Fuseki task response: {}", body);
    return null;
  }
  return value == null || value.isNull() || value.asText().isBlank() ? null : value.asText();
}
🤖 Prompt for agents
Code Review: Adds scheduled RDF store compaction with a write pause during maintenance, but changes are requested before merge. The pause is released while a Fuseki compaction may still be running after a timeout or interrupted poll, so queued writes can time out and be dead-lettered, and the next compaction slot can open against a dataset still compacting. Also, a non-JSON Fuseki reply throws an unchecked exception out of compaction instead of reporting an outcome, which can fail the whole rebuild.

1. ⚠️ Bug: Pause released while a timed-out/untracked Fuseki compaction still blocks
   Files: openmetadata-service/src/main/java/org/openmetadata/service/rdf/maintenance/RdfStorageMaintenanceService.java:127-141, openmetadata-service/src/main/java/org/openmetadata/service/rdf/rebuild/RdfLiveWritePause.java:66-79, openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:63-76, openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:125-136, openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/CompactionResult.java:27-30, openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/rdf/RdfIndexApp.java:414-425

   `FusekiCompaction` returns `TIMED_OUT` when `maxWait` runs out and `UNKNOWN` when an `IOException` cuts the poll short. `CompactionResult.Outcome.TIMED_OUT` itself says writes "stay blocked until it ends". In both cases the Fuseki task is still running. Even so, `RdfLiveWritePause.during` releases the cluster-wide pause as soon as `compact()` returns (RdfStorageMaintenanceService.java:153, RdfIndexApp.java:417-418). The queued live writes then reach a Fuseki writer that is still blocked, time out, and use up delivery attempts. With enough of them they get dead-lettered. That is the failure the ADR says this PR prevents. `compactHoldingSlot` also releases the `RDF_STORAGE_NEXT_COMPACTION` slot for a non-success result, so 15 minutes later a second compaction can be started against a dataset that is still compacting. Fix: when the outcome is `TIMED_OUT` or `UNKNOWN` with a task id, keep the pause held, or re-poll `/$/tasks/{id}` until the task is gone. Also don't release the slot until Fuseki reports no running Compact task.

   Fix (Keep the pause until the task is no longer running when the wait ran out, and don't free the slot early.):
   return rdf.withServingWritesPaused(jobId, () -> {
     CompactionResult result = rdf.compact(maxDuration());
     if (result.outcome() == CompactionResult.Outcome.TIMED_OUT) {
       LOG.error("Compaction {} still running in Fuseki; keeping writes paused", result.taskId());
       result = rdf.compact(maxDuration()); // or a dedicated awaitTask(taskId)
     }
     return result;
   });

2. 💡 Edge Case: Non-JSON Fuseki reply makes compaction throw instead of reporting an outcome
   Files: openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:63-76, openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:164-168, openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/FusekiCompaction.java:181-185, openmetadata-service/src/main/java/org/openmetadata/service/rdf/storage/RdfStorageInterface.java:242-247

   `FusekiCompaction.compact` catches only `IOException` and `InterruptedException`. `field()` calls `JsonUtils.readTree`, which throws an unchecked `JsonParsingException` when a 200 reply has a body that is not JSON (for example an HTML page from a proxy, or a truncated body). That exception escapes `compact()`. `RdfStorageInterface.compactStorage()` is documented as best-effort and "never thrown", but it now delegates to `compact()`. The old implementation swallowed these errors. As a result, `prepareBuildDataset` (an unwrapped `indexingRepository.compactStorage()` call) and `resetServingDataset` now fail the whole rebuild on such a reply. Fix: handle parse failures in `field()`/`TaskState.parse` and map them to `UNKNOWN`, or catch `RuntimeException` in `compact()` and return `UNKNOWN`.

   Fix (Treat unparseable bodies as having no field.):
   static String field(final String body, final String name) {
     if (body == null || body.isBlank()) {
       return null;
     }
     final JsonNode value;
     try {
       value = JsonUtils.readTree(body).get(name);
     } catch (JsonParsingException unparseable) {
       LOG.warn("Unparseable Fuseki task response: {}", body);
       return null;
     }
     return value == null || value.isNull() || value.asText().isBlank() ? null : value.asText();
   }

Verification

Rules No rules evaluated

Functional validation Not enabled

Set up

Auto-approval Not enabled

Set up

For more verification details, comment gitar display:verbose.

Options

Display: compact → Showing compact verification results.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source

@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend safe to test Add this label to run secure Github workflows on PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fuseki disk grows until full because the RDF store is only compacted by the reindex app

1 participant