-
Notifications
You must be signed in to change notification settings - Fork 2.3k
fix(rdf): converge Fuseki state on weekly rebuilds and isolate API latency #28117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
a91262d
9b6e112
26f743b
10fe181
1676feb
4567706
2fc1147
ef9bb30
7a1fae7
e2575d5
22d5825
857c097
63d9864
66884e2
0c4345b
4242c15
03c5d4f
9eeca99
28fb585
53a83b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
| 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') | ||
|
harshach marked this conversation as resolved.
Outdated
|
||
| WHERE name = 'RdfIndexApp'; | ||
| 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() {} | ||
|
|
@@ -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); | ||
|
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) { | ||
|
harshach marked this conversation as resolved.
|
||
| LOG.error("Failed to remove relationship in RDF", e); | ||
| } finally { | ||
| RequestLatencyContext.endRdfOperation(sample); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| public static boolean isEnabled() { | ||
|
|
@@ -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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred — see the new comment block in RdfUpdater.submitAsync ( The race exists in theory but practical impact is bounded:
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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferred — see the new comment block in RdfUpdater.submitAsync ( The race exists in theory but practical impact is bounded:
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); | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.