Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a91262d
fix(rdf): converge Fuseki state on weekly rebuilds and isolate API la…
harshach May 14, 2026
9b6e112
fix(rdf): address PR review — preserve relationships, scope DELETEs, …
harshach May 14, 2026
26f743b
test(rdf): expect per-source clear on batches whose relationships are…
harshach May 15, 2026
10fe181
fix(rdf): address remaining PR review nits
harshach May 15, 2026
1676feb
fix(rdf): surface cleanup failures, sync fallback predicates, time-bo…
harshach May 15, 2026
4567706
fix(rdf): qualify EntityRelationship in test to fix compile
harshach May 15, 2026
2fc1147
Merge branch 'main' into harshach/rdf-fuseki-duplicate-relations
harshach May 15, 2026
ef9bb30
fix(rdf): drop QueryExecution.setTimeout — removed in Jena 5 used by …
harshach May 15, 2026
7a1fae7
fix(rdf): align ontology-loaded check, predicate URIs, and CURIE fall…
harshach May 15, 2026
e2575d5
fix(rdf): schema default + migration force entities=[all] for safe fu…
harshach May 15, 2026
22d5825
fix(rdf): scope storeEntity DELETE to translator-managed predicates
harshach May 15, 2026
857c097
fix(rdf): scope reconciliation DELETE to relationship-hook predicates…
harshach May 15, 2026
63d9864
fix(rdf): scope bulk reconciliation to batch entities, not all relati…
harshach May 15, 2026
66884e2
fix(rdf): time-bound HTTP request bodies via CompletableFuture wrapper
harshach May 15, 2026
0c4345b
docs(rdf): document RdfUpdater async-ordering trade-off in submitAsync
harshach May 15, 2026
4242c15
fix(rdf): atomic clear+insert, broader fallback predicate set, close …
harshach May 15, 2026
03c5d4f
fix(rdf): make buildPredicateInList public so JenaFusekiStorage can u…
harshach May 15, 2026
9eeca99
fix(rdf): normalise sourcesToReconcile to empty-set to prevent NPE in…
harshach May 15, 2026
28fb585
test(rdf): update RdfIndexAppTest verifications for the new bulkAddRe…
harshach May 15, 2026
53a83b7
fix(rdf): four follow-up findings from Copilot review 4299978111
harshach May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1 +1,18 @@
-- Post data migration script for Task workflow cutover - OpenMetadata 2.0.1

-- RdfIndexApp: switch to weekly Saturday cron and recreate-on-each-run.
-- Previous defaults (daily, incremental) were producing unbounded triple growth
-- because relationship-removal paths weren't fully reconciled. With per-run
-- CLEAR ALL the dataset always converges to the current MySQL state; weekly
-- cadence keeps the per-run cost from saturating Fuseki.
UPDATE installed_apps
SET json = JSON_SET(
json,
'$.appConfiguration.recreateIndex', CAST('true' AS JSON),
'$.appSchedule.cronExpression', '0 0 * * 6'
)
WHERE name = 'RdfIndexApp';

UPDATE apps_marketplace
SET json = JSON_SET(json, '$.appConfiguration.recreateIndex', CAST('true' AS JSON))
WHERE name = 'RdfIndexApp';
Comment thread
harshach marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -1 +1,18 @@
-- Post data migration script for Task workflow cutover - OpenMetadata 2.0.1

-- RdfIndexApp: switch to weekly Saturday cron and recreate-on-each-run.
-- Previous defaults (daily, incremental) were producing unbounded triple growth
-- because relationship-removal paths weren't fully reconciled. With per-run
-- CLEAR ALL the dataset always converges to the current MySQL state; weekly
-- cadence keeps the per-run cost from saturating Fuseki.
UPDATE installed_apps
SET json = jsonb_set(
jsonb_set(json::jsonb, '{appConfiguration,recreateIndex}', 'true'),
'{appSchedule,cronExpression}',
'"0 0 * * 6"'
)
WHERE name = 'RdfIndexApp';

UPDATE apps_marketplace
SET json = jsonb_set(json::jsonb, '{appConfiguration,recreateIndex}', 'true')
Comment thread
harshach marked this conversation as resolved.
Outdated
WHERE name = 'RdfIndexApp';
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,21 @@ private void initializeJob(JobExecutionContext jobExecutionContext) {
rdfIndexStats.set(initializeTotalRecords(jobData.getEntities()));
jobData.setStats(rdfIndexStats.get());

// bulkAddGlossaryTermRelations has no per-batch DELETE side, so stale
// glossary-term relations would accumulate forever across reindex runs.
// When recreateIndex=true clearAll() already wipes everything, so we
// only need this targeted cleanup on incremental runs.
if (!Boolean.TRUE.equals(jobData.getRecreateIndex())
&& jobData.getEntities() != null
&& jobData.getEntities().contains(Entity.GLOSSARY_TERM)) {
LOG.info("Clearing existing glossary term relations before re-indexing");
try {
rdfRepository.clearAllGlossaryTermRelations();
} catch (Exception e) {
LOG.warn("Failed to clear glossary term relations; continuing with reindex", e);
Comment thread
harshach marked this conversation as resolved.
Outdated
}
Comment thread
harshach marked this conversation as resolved.
Outdated
Comment thread
harshach marked this conversation as resolved.
Outdated
}

if (Boolean.TRUE.equals(jobData.getUseDistributedIndexing())) {
sendUpdates(jobExecutionContext, true);
return;
Expand Down Expand Up @@ -242,6 +257,10 @@ private void clearRdfData() {
try {
rdfRepository.clearAll();
LOG.info("Cleared all RDF data");
// CLEAR ALL wipes the ontology and shapes graphs as well; reload them
// before indexing starts so SPARQL queries that depend on the ontology
// (inference, federated, etc.) work after the wipe.
rdfRepository.reloadOntologies();
} catch (Exception e) {
LOG.error("Failed to clear RDF data", e);
throw new RuntimeException("Failed to clear RDF data", e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ private void loadOntologies() {
}
}

// CLEAR ALL (called by clearAll()) wipes the ontology and shapes graphs too.
// Callers that wipe the dataset must invoke this afterwards so SPARQL queries
// that depend on the ontology don't break. Unlike loadOntologies() this skips
// the "already loaded" guard — areOntologiesLoaded() would return false right
// after a CLEAR, but we want to unconditionally reload.
public void reloadOntologies() {
if (!isEnabled()) {
return;
}
try {
new OntologyLoader(this).loadOntologies();
Comment thread
harshach marked this conversation as resolved.
Outdated
LOG.info("Reloaded OpenMetadata ontologies into RDF store");
} catch (Exception e) {
LOG.error("Failed to reload ontologies", e);
}
}

public static void initialize(RdfConfiguration config) {
if (INSTANCE != null) {
throw new IllegalStateException("RdfRepository already initialized");
Expand Down Expand Up @@ -127,24 +144,6 @@ public void createOrUpdate(EntityInterface entity) {
entity.getName(),
entity.getId());
Model rdfModel = translator.toRdf(entity);

// Preserve existing relationship triples before updating
// This prevents postCreate() from overwriting relationships added by storeRelationships()
Model existingModel = storageService.getEntity(entityType, entity.getId());
if (existingModel != null && !existingModel.isEmpty()) {
String entityUri =
config.getBaseUri().toString() + "entity/" + entityType + "/" + entity.getId();
// Extract and preserve relationship triples (where entity is subject and object is a URI)
Model relationshipTriples = extractRelationshipTriples(existingModel, entityUri);
if (!relationshipTriples.isEmpty()) {
rdfModel.add(relationshipTriples);
LOG.debug(
"Preserved {} relationship triples for entity {}",
relationshipTriples.size(),
entity.getId());
}
}

storageService.storeEntity(entityType, entity.getId(), rdfModel);
Comment thread
harshach marked this conversation as resolved.
LOG.debug("Created/Updated entity {} in RDF store", entity.getId());
} catch (Exception e) {
Expand All @@ -154,33 +153,10 @@ public void createOrUpdate(EntityInterface entity) {
entity.getEntityReference().getType(),
entity.getFullyQualifiedName(),
e);
// Rethrow so callers (e.g. RdfBatchProcessor) can count this as a failure instead of
// reporting a false success. Entity-hook callers (RdfUpdater) already wrap in try/catch.
throw new RuntimeException("Failed to create/update entity in RDF", e);
}
}

private Model extractRelationshipTriples(Model model, String entityUri) {
Model relationshipTriples = ModelFactory.createDefaultModel();
Resource entityResource = model.createResource(entityUri);

// Find all triples where entity is subject and object is a URI resource (relationships)
model
.listStatements(entityResource, null, (org.apache.jena.rdf.model.RDFNode) null)
.forEachRemaining(
stmt -> {
if (stmt.getObject().isURIResource()) {
String objectUri = stmt.getObject().asResource().getURI();
// Only preserve triples that link to other entities (not type/label predicates)
if (objectUri.contains("/entity/")) {
relationshipTriples.add(stmt);
}
}
});

return relationshipTriples;
}

public void delete(EntityReference entityReference) {
if (!isEnabled()) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
package org.openmetadata.service.rdf;

import io.micrometer.core.instrument.Timer;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.api.configuration.rdf.RdfConfiguration;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.EntityRelationship;
import org.openmetadata.service.monitoring.RequestLatencyContext;
import org.openmetadata.service.util.AsyncService;

@Slf4j
public class RdfUpdater {

private static final int MAX_PENDING_RDF_WRITES = 1000;
private static final AtomicInteger pendingWrites = new AtomicInteger(0);
private static final AtomicLong droppedWrites = new AtomicLong(0L);

private static RdfRepository rdfRepository;

private RdfUpdater() {}
Expand All @@ -26,55 +33,75 @@ public static void initialize(RdfConfiguration config) {
}

public static void updateEntity(EntityInterface entity) {
if (rdfRepository != null && rdfRepository.isEnabled()) {
Timer.Sample sample = RequestLatencyContext.startRdfOperation();
try {
rdfRepository.createOrUpdate(entity);
} catch (Exception e) {
LOG.error("Failed to update entity {} in RDF", entity.getId(), e);
} finally {
RequestLatencyContext.endRdfOperation(sample);
}
if (rdfRepository == null || !rdfRepository.isEnabled()) {
return;
}
submitAsync(
"updateEntity " + entity.getId(),
() -> {
Timer.Sample sample = RequestLatencyContext.startRdfOperation();
try {
rdfRepository.createOrUpdate(entity);
Comment thread
harshach marked this conversation as resolved.
} catch (Exception e) {
LOG.error("Failed to update entity {} in RDF", entity.getId(), e);
} finally {
RequestLatencyContext.endRdfOperation(sample);
}
});
}

public static void deleteEntity(EntityReference entityReference) {
if (rdfRepository != null && rdfRepository.isEnabled()) {
Timer.Sample sample = RequestLatencyContext.startRdfOperation();
try {
rdfRepository.delete(entityReference);
} catch (Exception e) {
LOG.error("Failed to delete entity {} in RDF", entityReference.getId(), e);
} finally {
RequestLatencyContext.endRdfOperation(sample);
}
if (rdfRepository == null || !rdfRepository.isEnabled()) {
return;
}
submitAsync(
"deleteEntity " + entityReference.getId(),
() -> {
Timer.Sample sample = RequestLatencyContext.startRdfOperation();
try {
rdfRepository.delete(entityReference);
} catch (Exception e) {
LOG.error("Failed to delete entity {} in RDF", entityReference.getId(), e);
} finally {
RequestLatencyContext.endRdfOperation(sample);
}
});
}

public static void addRelationship(EntityRelationship relationship) {
if (rdfRepository != null && rdfRepository.isEnabled()) {
Timer.Sample sample = RequestLatencyContext.startRdfOperation();
try {
rdfRepository.addRelationship(relationship);
} catch (Exception e) {
LOG.error("Failed to add relationship in RDF", e);
} finally {
RequestLatencyContext.endRdfOperation(sample);
}
if (rdfRepository == null || !rdfRepository.isEnabled()) {
return;
}
submitAsync(
"addRelationship",
() -> {
Timer.Sample sample = RequestLatencyContext.startRdfOperation();
try {
rdfRepository.addRelationship(relationship);
} catch (Exception e) {
LOG.error("Failed to add relationship in RDF", e);
} finally {
RequestLatencyContext.endRdfOperation(sample);
}
});
}

public static void removeRelationship(EntityRelationship relationship) {
if (rdfRepository != null && rdfRepository.isEnabled()) {
Timer.Sample sample = RequestLatencyContext.startRdfOperation();
try {
rdfRepository.removeRelationship(relationship);
} catch (Exception e) {
LOG.error("Failed to remove relationship in RDF", e);
} finally {
RequestLatencyContext.endRdfOperation(sample);
}
if (rdfRepository == null || !rdfRepository.isEnabled()) {
return;
}
submitAsync(
"removeRelationship",
() -> {
Timer.Sample sample = RequestLatencyContext.startRdfOperation();
try {
rdfRepository.removeRelationship(relationship);
} catch (Exception e) {
Comment thread
harshach marked this conversation as resolved.
LOG.error("Failed to remove relationship in RDF", e);
} finally {
RequestLatencyContext.endRdfOperation(sample);
}
});
}

public static boolean isEnabled() {
Expand All @@ -89,33 +116,79 @@ public static void disable() {

public static void addGlossaryTermRelation(
java.util.UUID fromTermId, java.util.UUID toTermId, String relationType) {
if (rdfRepository != null && rdfRepository.isEnabled()) {
try {
rdfRepository.addGlossaryTermRelation(fromTermId, toTermId, relationType);
} catch (Exception e) {
LOG.error(
"Failed to add glossary term relation {} -> {} ({}) to RDF",
fromTermId,
toTermId,
relationType,
e);
}
if (rdfRepository == null || !rdfRepository.isEnabled()) {
return;
}
submitAsync(
"addGlossaryTermRelation",
() -> {
try {
rdfRepository.addGlossaryTermRelation(fromTermId, toTermId, relationType);
} catch (Exception e) {
LOG.error(
"Failed to add glossary term relation {} -> {} ({}) to RDF",
fromTermId,
toTermId,
relationType,
e);
}
});
}

public static void removeGlossaryTermRelation(
java.util.UUID fromTermId, java.util.UUID toTermId, String relationType) {
if (rdfRepository != null && rdfRepository.isEnabled()) {
try {
rdfRepository.removeGlossaryTermRelation(fromTermId, toTermId, relationType);
} catch (Exception e) {
LOG.error(
"Failed to remove glossary term relation {} -> {} ({}) from RDF",
fromTermId,
toTermId,
relationType,
e);
if (rdfRepository == null || !rdfRepository.isEnabled()) {
return;
}
submitAsync(
"removeGlossaryTermRelation",
() -> {
try {
rdfRepository.removeGlossaryTermRelation(fromTermId, toTermId, relationType);
} catch (Exception e) {
LOG.error(
"Failed to remove glossary term relation {} -> {} ({}) from RDF",
fromTermId,
toTermId,
relationType,
e);
}
});
}

// Bounded fire-and-forget submission: a request thread that triggers an RDF
// write must NOT wait for Fuseki. We submit to AsyncService (virtual-thread
// pool) but gate first on a soft cap of in-flight writes so that, if Fuseki
// is unreachable and tasks pile up, we drop with a logged warning instead
// of spawning unbounded virtual threads. RDF is a derived index — missed
// writes are reconciled by the weekly RdfIndexApp run.
private static void submitAsync(String description, Runnable task) {
int newCount = pendingWrites.incrementAndGet();
if (newCount > MAX_PENDING_RDF_WRITES) {
pendingWrites.decrementAndGet();
long dropped = droppedWrites.incrementAndGet();
if (dropped == 1 || dropped % 100 == 0) {
LOG.warn(
"Dropping RDF {} due to backpressure (pending={}, total dropped={})",
description,
newCount - 1,
dropped);
}
return;
}
try {
AsyncService.getInstance()
.execute(
() -> {
try {
task.run();
} finally {
pendingWrites.decrementAndGet();
}
});
Comment on lines +197 to +205

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferred — see the new comment block in RdfUpdater.submitAsync (0c4345b).

The race exists in theory but practical impact is bounded:

  • EntityUpdater diff-applies per request, so add-then-remove of the same edge within one API call nets to no-op (no hooks fire).
  • Cross-request races resolve at the next weekly recreate-index (RdfIndexApp with recreateIndex=true rebuilds from MySQL, so any temporarily out-of-order RDF state is reconciled within a week).
  • The fix (per-entity ConcurrentHashMap<UUID, Semaphore> striped lock) costs memory and adds latency for the no-contention common case.

Leaving the threads open as the right hook for a future PR if an observed-in-production race emerges.

Comment on lines +197 to +205

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferred — see the new comment block in RdfUpdater.submitAsync (0c4345b).

The race exists in theory but practical impact is bounded:

  • EntityUpdater diff-applies per request, so add-then-remove of the same edge within one API call nets to no-op (no hooks fire).
  • Cross-request races resolve at the next weekly recreate-index (RdfIndexApp with recreateIndex=true rebuilds from MySQL, so any temporarily out-of-order RDF state is reconciled within a week).
  • The fix (per-entity ConcurrentHashMap<UUID, Semaphore> striped lock) costs memory and adds latency for the no-contention common case.

Leaving the threads open as the right hook for a future PR if an observed-in-production race emerges.

} catch (RuntimeException e) {
pendingWrites.decrementAndGet();
LOG.error("Failed to submit RDF {} to async executor", description, e);
}
}
}
Loading
Loading