chore: import upstream snapshot with attribution
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,620 @@
|
||||
# Company Context — File-Extracted Knowledge Pills
|
||||
|
||||
- **Date:** 2026-06-12
|
||||
- **Status:** Implemented (as-built spec for branch `pmbrull/companycontext-entity`)
|
||||
- **Author:** pmbrull (with Claude)
|
||||
- **Scope:** 72 files, ~+2818/−81. Backend (Java), schema (JSON), ingestion of
|
||||
file content into reusable memories, embedding/search, MCP tools, UI badge.
|
||||
|
||||
This document describes the feature **as shipped**, not the earlier
|
||||
pre-implementation design. Where the implementation diverged from that design,
|
||||
the as-built behavior is recorded here and flagged in
|
||||
[§13 Known limitations](#13-known-limitations--follow-ups).
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The Context Center ("Drive") lets users upload documents. Previously an upload
|
||||
was stored, its raw text extracted into `ContextFile.extractedText`, and that was
|
||||
the end. This feature turns that raw text into structured, retrievable
|
||||
**knowledge pills** — short question/answer facts about the company — that are
|
||||
embedded, indexed, linked back to the originating file, and reachable by any
|
||||
MCP-connected agent.
|
||||
|
||||
Pills are **not a new entity**. They are `ContextMemory` rows tagged
|
||||
`sourceType=FileExtraction` with a `sourceFile` back-reference, riding the
|
||||
entity's existing embedding + search plumbing.
|
||||
|
||||
End-to-end:
|
||||
|
||||
```
|
||||
upload ──▶ store blob (object storage / AssetService)
|
||||
──▶ extract text (status: Analyzing)
|
||||
──▶ LLM extracts knowledge pills (status: ExtractingContext)
|
||||
──▶ pills stored as ContextMemory rows, linked to the file
|
||||
──▶ pills auto-embedded + indexed on create
|
||||
──▶ pills retrievable via MCP + searchable by source filename
|
||||
file carries a processing status surfaced in the UI (status: Processed)
|
||||
```
|
||||
|
||||
## 2. Motivation
|
||||
|
||||
`ContextMemory` already existed — "Reusable context memory for Context Center
|
||||
and AI-assisted retrieval", a question/answer pill that is already
|
||||
vector-embedded (`contextMemory` is in `AvailableEntityTypes`, has
|
||||
`ContextMemoryBodyTextContributor` + `ContextMemoryIndex`, and its index carries
|
||||
parent aliases `["all", "dataAssetEmbeddings"]`). It needed only a new source
|
||||
type and a file back-reference to become the backing store for file-extracted
|
||||
knowledge, avoiding a second overlapping "knowledge" concept and a full new
|
||||
entity stack.
|
||||
|
||||
The real new infrastructure is a **generic LLM completion layer** — there was no
|
||||
chat/completion client before (only `EmbeddingClient` and a NoOp `NLQService`).
|
||||
It is built decoupled from Context Center so other features (e.g. MCP Chat) can
|
||||
reuse it.
|
||||
|
||||
## 3. Architecture & data flow
|
||||
|
||||
```
|
||||
POST /v1/contextCenter/drive/files (ContextFileResource:208)
|
||||
└─ store asset (AssetServiceFactory.getService().upload), persist ContextFile + content
|
||||
└─ extractionService.submit(fileId, contentId) (ContextFileResource:287)
|
||||
|
||||
ContextFileProcessingService.process(fileId, contentId) (drive/ContextFileProcessingService.java)
|
||||
guard: contentId == file.headContentId (else abandon — stale re-upload)
|
||||
1. markAnalyzing() → status Analyzing (file + content)
|
||||
2. extractText() [DEFAULT_EXECUTOR, CPU] → read blob via AssetService, text-extract
|
||||
├─ failure → status Failed (+ processingError on content), extractedText cleared
|
||||
└─ success → text status (Processed / Unsupported)
|
||||
3. if text Processed AND LLMClientHolder.isEnabled():
|
||||
status ExtractingContext (file) [content snapshot stays Processed]
|
||||
submitMemoryExtraction() [LLM_EXECUTOR, network]
|
||||
runMemoryExtraction():
|
||||
deleteExtractedMemories(file, hardDelete=true) ← wholesale replace
|
||||
ContextMemoryExtractor.extract(file, canonicalText)
|
||||
chunk → per-chunk LLM call → dedupe → create ContextMemory rows
|
||||
{sourceType:FileExtraction, sourceFile:<ref>, status:Active,
|
||||
visibility:Shared} └─ VectorEmbeddingHandler auto-embeds
|
||||
status Processed (file)
|
||||
├─ LLM failure → status Failed (+ processingError), extractedText RETAINED
|
||||
└─ LLM queue full → status Failed ("…queue is full. Please retry later.")
|
||||
else (LLM disabled): text Processed is terminal; LLM stage never enqueued
|
||||
```
|
||||
|
||||
Two **separate** thread pools by design (`ContextFileProcessingService`):
|
||||
|
||||
| Pool | Thread-name prefix | Work | Sizing |
|
||||
|------|--------------------|------|--------|
|
||||
| `DEFAULT_EXECUTOR` | `context-file-extraction-` | CPU-bound text extraction | `threads = max(2, cores/2)`, `ArrayBlockingQueue(max(64, threads*8))`, `AbortPolicy`, daemon |
|
||||
| `LLM_EXECUTOR` | `context-memory-extraction-` | network-bound LLM calls | identical sizing |
|
||||
|
||||
Mixing seconds-long LLM calls into the text pool would starve text extraction;
|
||||
the pools are kept separate (the class javadoc spells this out). They are
|
||||
currently sized identically — the only difference is the thread-name prefix.
|
||||
|
||||
## 4. Key design decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Entity | **Reuse `ContextMemory`** (new `sourceType=FileExtraction` + `sourceFile`); no new entity, no DDL migration |
|
||||
| LLM layer | New **generic** `LLMCompletionClient` abstraction + per-provider concretes + factory + process-wide holder, modeled on `EmbeddingClient` |
|
||||
| LLM→entity boundary | A narrow `KnowledgePill` DTO is the anti-corruption layer between untrusted model JSON and the entity (see [§9](#9-the-knowledgepill-dto-boundary)) |
|
||||
| File↔pill link | `Relationship.MENTIONED_IN` edge, `ContextFile → ContextMemory`; `memoryCount` surfaced on the file |
|
||||
| Reprocess | **Wholesale hard-delete + recreate** of a file's pills on every extraction run (no versioning, no checksum skip) |
|
||||
| Async | Reuse + rename the live `ContextFileExtractionService` → `ContextFileProcessingService`; LLM step on its own executor |
|
||||
| Status | Extend the single `ProcessingStatus` enum: insert `ExtractingContext` between `Analyzing` and `Processed` |
|
||||
| Embedding | Free — `ContextMemory` already auto-embeds on create; this PR only threads `sourceFile` through the index/body-text |
|
||||
| MCP | Two purpose-built tools `search_company_context` + `get_company_context` |
|
||||
| Storage guardrail | New `/system/validate` "Object Storage" step + startup warn — uploads are useless if object storage is NoOp/broken |
|
||||
|
||||
## 5. Generic LLM completion layer
|
||||
|
||||
Package `org.openmetadata.service.llm` (all new).
|
||||
|
||||
### 5.1 Config schema
|
||||
|
||||
`openmetadata-spec/.../json/schema/configuration/llmConfiguration.json` →
|
||||
`org.openmetadata.schema.configuration.LLMConfiguration`.
|
||||
|
||||
- `enabled` — boolean, default `false`.
|
||||
- `provider` — enum `["bedrock", "openai", "azureOpenAI", "google", "anthropic", "noop"]`, default `noop` (Java enum `LLMProvider`).
|
||||
- `maxConcurrentRequests` — integer, default `5`, minimum `1`.
|
||||
- Per-provider objects (each `additionalProperties:false`):
|
||||
|
||||
| Object | Java type | Auth (masked?) | Notable fields / defaults |
|
||||
|--------|-----------|----------------|---------------------------|
|
||||
| `bedrock` | `LLMBedrockConfig` | `awsConfig` → `awsBaseConfig.json` | `modelId` `anthropic.claude-3-5-sonnet-20240620-v1:0`, temp 0.0, maxTokens 4096, timeout 60s |
|
||||
| `openai` | `LLMOpenAIConfig` | `apiKey` **`mask:true`** | `modelId` `gpt-4o-mini`, `endpoint`, `deploymentName`, `apiVersion` `2024-02-01` (Azure), temp 0.0, maxTokens 4096, timeout 60s |
|
||||
| `google` | `LLMGoogleConfig` | `apiKey` **`mask:true`** | `modelId` `gemini-2.5-flash`, `endpoint`, temp 0.0, maxTokens 4096, timeout 60s |
|
||||
| `anthropic` | `LLMAnthropicConfig` | `apiKey` **`mask:true`** | `modelId` `claude-3-5-sonnet-20240620`, `baseUrl` `https://api.anthropic.com`, temp 0.0, maxTokens 4096, timeout 60s |
|
||||
|
||||
Masked API keys round-trip through the Secrets Manager. Bedrock delegates
|
||||
secrecy to `awsBaseConfig.json`.
|
||||
|
||||
`conf/openmetadata.yaml` carries an `llmConfiguration` block, fully env-var
|
||||
overridable (`LLM_ENABLED`, `LLM_PROVIDER`, `LLM_MAX_CONCURRENT_REQUESTS`, and
|
||||
per-provider `LLM_<PROVIDER>_*` keys — see [§12](#12-configuration-reference)).
|
||||
Its header comment states plainly that **uploaded document content is sent to the
|
||||
configured provider when enabled**.
|
||||
|
||||
### 5.2 Client hierarchy
|
||||
|
||||
- **`LLMCompletionClient`** (abstract, `@Slf4j`): owns a `Semaphore`
|
||||
(`maxConcurrentRequests` permits; ctor rejects `< 1`), the `complete()` /
|
||||
`completeStructured()` template methods, JSON-array parsing, and code-fence
|
||||
stripping. Abstract surface: `doComplete(systemPrompt, userPrompt)` and
|
||||
`getModelId()`. `DEFAULT_MAX_CONCURRENT_REQUESTS = 5`.
|
||||
- **Concretes** — `OpenAICompletionClient` (OpenAI + Azure OpenAI),
|
||||
`AnthropicCompletionClient`, `BedrockCompletionClient` (AWS SDK v2,
|
||||
`AutoCloseable`), `GoogleCompletionClient`, and `NoopCompletionClient`
|
||||
(returns `"[]"`, model id `"noop"`).
|
||||
- **`LLMCompletionClientFactory.create(LLMConfiguration)`** — switch on
|
||||
`provider` → concrete; null config/provider → Noop. Note: the factory keys off
|
||||
`provider` only; it does **not** check `enabled` (the holder does).
|
||||
- **`LLMClientHolder`** — process-wide holder of the single shared client.
|
||||
`initialize(config)`: `enabled = config != null && Boolean.TRUE.equals(getEnabled())`;
|
||||
`instance = enabled ? factory.create(config) : new NoopCompletionClient()`. So
|
||||
a real client is built **only** when `enabled == true`. `get()` never returns
|
||||
null; `isEnabled()` gates the pipeline; `setForTesting()` is the test seam.
|
||||
`volatile` fields + `synchronized` mutators.
|
||||
|
||||
### 5.3 `completeStructured` contract
|
||||
|
||||
```java
|
||||
<T> List<T> completeStructured(String systemPrompt, String userPrompt, Class<T> elementType)
|
||||
```
|
||||
|
||||
- Acquires a permit, calls `doComplete`, releases in `finally`.
|
||||
- Strips a leading/trailing ` ``` ` code fence (drops the language tag line).
|
||||
- Parses with a default `new ObjectMapper()` (so `FAIL_ON_UNKNOWN_PROPERTIES`
|
||||
is **on**) into `List<elementType>`. Parse failure → `LLMCompletionException`.
|
||||
- **One retry** on `LLMCompletionException` (logged `warn`), then propagates. The
|
||||
retry catches `LLMCompletionException` broadly, so transport errors that surface
|
||||
as `LLMCompletionException` also get one retry.
|
||||
|
||||
### 5.4 Providers
|
||||
|
||||
All providers use plain chat/messages JSON mode (**no tool/function calling**);
|
||||
non-200 → `LLMCompletionException("<provider> API returned status …")`.
|
||||
|
||||
- **OpenAI / Azure**: JDK `HttpClient`. Azure detected when `endpoint` **and**
|
||||
`deploymentName` are set → `.../openai/deployments/<dep>/chat/completions?api-version=<v>`
|
||||
with `api-key` header; else `Authorization: Bearer`. Reads `choices[0].message.content`.
|
||||
- **Anthropic**: JDK `HttpClient`, `<baseUrl>/v1/messages`, headers `x-api-key` +
|
||||
`anthropic-version: 2023-06-01`. Reads `content[0].text`.
|
||||
- **Bedrock**: AWS SDK v2 `BedrockRuntimeClient` (credentials via
|
||||
`AwsCredentialsUtil.buildCredentialsProvider`, region required). Body uses
|
||||
`anthropic_version: bedrock-2023-05-31`. Reads `content[0].text`. `close()`
|
||||
exists but the holder never calls it.
|
||||
- **Google Gemini**: JDK `HttpClient`,
|
||||
`.../models/<modelId>:generateContent?key=<apiKey>` (**API key in the URL
|
||||
query string**). Reads `candidates[0].content.parts[0].text`.
|
||||
|
||||
### 5.5 Startup wiring
|
||||
|
||||
`OpenMetadataApplicationConfig` gains
|
||||
`@JsonProperty("llmConfiguration") private LLMConfiguration llmConfiguration`.
|
||||
`OpenMetadataApplication` startup calls
|
||||
`LLMClientHolder.initialize(catalogConfig.getLlmConfiguration())` (new), then the
|
||||
pre-existing `LlmConfigHolder.initialize(...)` (publishes raw config for MCP
|
||||
Chat). Two holders, same config source.
|
||||
|
||||
## 6. Schema & persistence changes
|
||||
|
||||
No DDL / Flyway migration — `ContextMemory` and `ContextFile` use the generic
|
||||
JSON-column entity tables, and the file↔pill link lives in the generic
|
||||
`entity_relationship` table.
|
||||
|
||||
### 6.1 Schemas
|
||||
|
||||
- **`contextMemory.json`** — `sourceType` enum gains `FileExtraction`
|
||||
(`FILE_EXTRACTION`); new property `sourceFile` (`entityReference`, not required).
|
||||
- **`createContextMemory.json`** — new create-time `sourceFile` (`entityReference`).
|
||||
- **`contextFile.json`** — `ProcessingStatus` enum gains `ExtractingContext` (3rd,
|
||||
between `Analyzing` and `Processed`); new derived property `memoryCount`
|
||||
(integer, default 0).
|
||||
|
||||
### 6.2 Repositories
|
||||
|
||||
- **`ContextMemoryRepository`** — `FIELD_SOURCE_FILE = "sourceFile"` added to
|
||||
`PATCH_FIELDS`/`UPDATE_FIELDS`. `storeRelationships` adds an
|
||||
`addRelationship(sourceFile.id /*from*/, memory.id /*to*/, CONTEXT_FILE,
|
||||
CONTEXT_MEMORY, Relationship.MENTIONED_IN)` edge. `setFields` resolves it with
|
||||
`findFrom(memory.id, CONTEXT_MEMORY, MENTIONED_IN, CONTEXT_FILE)` (bulk path via
|
||||
`findFromBatch`). Patch handled via `updateFromRelationship(...)`.
|
||||
- **Collision-free by construction:** each relationship hierarchy uses a
|
||||
distinct `Relationship` — `rootMemory`→`CONTAINS`, `parentMemory`→`PARENT_OF`,
|
||||
`domains`→`HAS`, `sourceFile`→**`MENTIONED_IN`** — so the four resolvers query
|
||||
disjoint `(relation, fromType)` tuples.
|
||||
- **`ContextFileRepository`** — `memoryCount` computed in `setFields` as
|
||||
`findTo(file.id, CONTEXT_FILE, MENTIONED_IN, CONTEXT_MEMORY).size()`. New
|
||||
`postDelete` cascades: `deleteExtractedMemories(file, hardDelete)` deletes each
|
||||
linked pill, propagating the file delete's `hardDelete` flag (soft→soft,
|
||||
hard→hard). The same method is reused by the reprocess path with `hardDelete=true`.
|
||||
- **`ContextMemoryMapper`** — passthrough of `create.getSourceFile()`.
|
||||
|
||||
## 7. Processing pipeline & status
|
||||
|
||||
### 7.1 Status state machine (`ProcessingStatus`)
|
||||
|
||||
```
|
||||
Uploaded ─▶ Analyzing ─▶ (text result) ─▶ Processed ─▶ ExtractingContext ─▶ Processed
|
||||
│ ▲ (LLM enabled) (final)
|
||||
├─▶ Failed └─ if LLM disabled: terminal here
|
||||
└─▶ Unsupported (passthrough from text extractor)
|
||||
any stage failure ─▶ Failed
|
||||
```
|
||||
|
||||
- The file and its content snapshot carry status independently. When the file
|
||||
advances to `ExtractingContext`, the **content snapshot keeps `Processed`** (it
|
||||
received the raw text status).
|
||||
- `Unsupported` exists in the enum but the service never writes it — it is passed
|
||||
through verbatim from `ContextFileTextExtractor`.
|
||||
|
||||
### 7.2 Service (`ContextFileProcessingService`, was `ContextFileExtractionService`)
|
||||
|
||||
- Only call site: `ContextFileResource` upload path →
|
||||
`extractionService.submit(fileId, contentId)`.
|
||||
- `submit` wraps `executor.execute(() -> process(...))` and catches
|
||||
`RejectedExecutionException` (queue full → `Failed`, "Processing queue is full").
|
||||
- Every stage re-reads from the repo and re-checks `headContentId`; a newer
|
||||
upload silently abandons the stale content's writes (concurrent re-upload guard).
|
||||
- `canonicalText` feeds the LLM the **content snapshot's** extracted text
|
||||
(capped ~1M chars) in preference to the file's indexed text (capped ~200K).
|
||||
|
||||
### 7.3 Error handling
|
||||
|
||||
- `applyFailure` sets both file and content to `Failed`; `processingError` is
|
||||
written to the **content snapshot only**, not the file entity.
|
||||
- **Text-stage** failures clear `extractedText` (+ `pageCount`). **LLM-stage**
|
||||
failures **retain** `extractedText` (so the file stays indexed and is retriable
|
||||
by re-upload).
|
||||
- `extractText` catches `Throwable` but re-throws `VirtualMachineError`.
|
||||
- No automatic retry anywhere; "retry later" means re-POST the file.
|
||||
|
||||
## 8. Knowledge-pill extraction (`ContextMemoryExtractor`)
|
||||
|
||||
- `extract(file, text)`: `chunkText` → per-chunk `completeStructured(SYSTEM_PROMPT,
|
||||
chunk, KnowledgePill.class)` → `dedupe` → `memoryRepository.create(toMemory(pill, fileRef))`.
|
||||
- **Chunking:** `MAX_PROMPT_CHARS = 60_000`, `MAX_CHUNKS = 8`. Chunk ends snap
|
||||
back to the last `\n\n` / `\n` / ` ` boundary in the second half of the budget,
|
||||
else hard-cut at the char cap. **Text past 8 chunks (~480K chars) is dropped
|
||||
with a warning** — even though `canonicalText` may supply up to ~1M chars.
|
||||
- **Dedupe:** `LinkedHashMap` keyed by `question.trim().toLowerCase(ROOT)`,
|
||||
first-wins, insertion order preserved.
|
||||
- **`isValid`:** `question` and `answer` both non-blank (title/summary/memoryType
|
||||
optional).
|
||||
- **`toMemory`** sets the full server-owned envelope: `id` (random UUID), `name`
|
||||
(`<fileName>-<uuid>`), `fullyQualifiedName`, `title`, `question`, `answer`,
|
||||
`summary`, `memoryType` (via `parseType`), `status=ACTIVE`,
|
||||
`sourceType=FILE_EXTRACTION`, `sourceFile=<fileRef>`,
|
||||
`shareConfig.visibility=SHARED`, `updatedBy=admin`, `updatedAt=now`.
|
||||
- **`parseType`:** case-insensitive match against `ContextMemoryType`
|
||||
(`Preference | UseCase | Note | Runbook | Faq`); default `NOTE`.
|
||||
- **`SYSTEM_PROMPT`:** *"You extract reusable company knowledge from a document as
|
||||
a JSON array. Each element is an object with keys: title, question, answer,
|
||||
summary, memoryType (one of Faq, Note, Runbook, UseCase, Preference). Capture
|
||||
durable facts, definitions, policies, and how-to guidance. Return ONLY the JSON
|
||||
array, no prose."*
|
||||
|
||||
### Reprocess / idempotency
|
||||
|
||||
Every LLM run first calls `deleteExtractedMemories(file, hardDelete=true)`,
|
||||
**hard-deleting all of the file's existing pills**, then recreates from scratch
|
||||
with fresh random IDs/FQNs. There is **no supersede/versioning** and **no
|
||||
checksum short-circuit** — re-uploading identical bytes re-runs text extraction
|
||||
and the LLM and regenerates pills. (Rationale in code: machine-generated pills
|
||||
are replaced wholesale, so soft-deleted rows would only accumulate with no
|
||||
restore path.)
|
||||
|
||||
## 9. The `KnowledgePill` DTO boundary
|
||||
|
||||
`KnowledgePill` (`service/llm/KnowledgePill.java`) is a 5-field record
|
||||
(`title, question, answer, summary, memoryType`) used **only** as the LLM
|
||||
deserialization target — deliberately not the `ContextMemory` entity. It is the
|
||||
anti-corruption boundary between untrusted model JSON and the trusted entity
|
||||
model. Parsing model output straight into `ContextMemory` is avoided because:
|
||||
|
||||
1. **Strict unknown fields.** `completeStructured` uses a default `ObjectMapper`
|
||||
(`FAIL_ON_UNKNOWN_PROPERTIES` on) and `contextMemory.json` is
|
||||
`additionalProperties:false` — one stray model key would fail the whole array
|
||||
parse. `KnowledgePill` carries `@JsonIgnoreProperties(ignoreUnknown = true)`.
|
||||
2. **Enum leniency.** `ContextMemory.memoryType` is the `ContextMemoryType` enum
|
||||
(Jackson throws on a non-exact value); `KnowledgePill.memoryType` is a raw
|
||||
String, so casing/variant forms degrade to `NOTE` in `parseType`.
|
||||
3. **Server-owned identity.** `contextMemory.json` requires `id`/`name`, and the
|
||||
whole envelope (FQN, status, sourceType, sourceFile, shareConfig,
|
||||
updatedBy/At) is set server-side in `toMemory` — the model neither produces
|
||||
nor should influence these.
|
||||
4. **Minimal prompt contract.** The DTO is exactly the five fields the prompt
|
||||
asks for; targeting the entity would expose its nested schema to the model.
|
||||
|
||||
`toMemory` + `parseType` is the single mapping layer; validation and dedupe
|
||||
operate on the DTO before any entity is built.
|
||||
|
||||
## 10. Embedding & search
|
||||
|
||||
`contextMemory` was already vector-indexable and auto-embedded on create
|
||||
(`AvailableEntityTypes`, `VectorEmbeddingHandler`, parent aliases
|
||||
`["all", "dataAssetEmbeddings"]`) — **unchanged here**. This PR threads the new
|
||||
`sourceFile` reference through three layers so file-derived pills are searchable
|
||||
by their origin filename:
|
||||
|
||||
- `ContextMemoryIndex` — `doc.put("sourceFile", getEntityWithDisplayName(...))`.
|
||||
- ES/OS mapping — a new `sourceFile` object (id/type/name/displayName/fqn/deleted
|
||||
as keywords) added identically to all four
|
||||
`elasticsearch/{en,jp,ru,zh}/context_memory_search_index.json` files (the
|
||||
per-language copies are byte-identical; no language-specific analyzers).
|
||||
- `ContextMemoryBodyTextContributor` — appends `source file: <name>` to the
|
||||
embedding body text.
|
||||
|
||||
## 11. Object-storage validation (guardrail)
|
||||
|
||||
Uploaded file content lives in object storage (S3 / Azure / in-memory) via
|
||||
`AssetService`. If that backend is disabled or NoOp, uploads silently "succeed"
|
||||
but their bytes are discarded and extraction fails with **"Unable to read file
|
||||
content from object storage"** — producing zero pills. To surface this *before*
|
||||
users hit it:
|
||||
|
||||
- **`SystemRepository.getObjectStorageValidation(config)`** adds an **"Object
|
||||
Storage"** step to the existing `GET /api/v1/system/status` validation. It fails
|
||||
fast when object storage is disabled/missing or the factory holds a
|
||||
`NoOpAssetService`; otherwise it runs a **live write→read→delete round-trip
|
||||
probe** (tiny `text/plain` asset, 10s per-op timeout, asserts the bytes match,
|
||||
cleans up in `finally`).
|
||||
- **`AssetServiceFactory.init`** logs a `warn` when storage is disabled,
|
||||
naming Context Center Drive and the exact failure string.
|
||||
- Test (`SystemRepositoryObjectStorageValidationTest`) exercises the real
|
||||
`AssetServiceFactory` + `InMemoryAssetService`: disabled, missing config,
|
||||
NoOp-after-config-drift, and a passing live in-memory round-trip.
|
||||
|
||||
This reuses the existing `/system/status` framework (no new endpoint) but exists
|
||||
solely for the Drive → object-storage → extraction pipeline.
|
||||
|
||||
## 12. MCP tools
|
||||
|
||||
Two tools in `openmetadata-mcp/.../tools/`, registered in `tools.json` and
|
||||
dispatched by new `DefaultToolContext` switch cases (via the 3-arg, no-limits
|
||||
`execute` — neither tool records usage or enforces limits).
|
||||
|
||||
### `search_company_context`
|
||||
- **Input:** `query` (string, required); `size` (integer, default 10, clamped 1..50).
|
||||
- **Query:** `OpenSearchVectorService.search(query, filters, size, from=0, k=100,
|
||||
threshold=0.0)` with filters pinned to `entityType=contextMemory`,
|
||||
`sourceType=FileExtraction`, **and `visibility=Shared`**.
|
||||
- **Output:** `{query, results:[{fullyQualifiedName, name, title, question,
|
||||
answer, summary, sourceFile, similarityScore}], returnedCount}`.
|
||||
- **Auth:** global `authorize(CONTEXT_MEMORY, VIEW_ALL)`. Requires vector
|
||||
embedding enabled, else returns an error payload.
|
||||
|
||||
### `get_company_context`
|
||||
- **Input:** `fqn` (string, required — the `fullyQualifiedName` from a search hit).
|
||||
- **Lookup:** `Entity.getEntityByName(CONTEXT_MEMORY, fqn,
|
||||
"sourceFile,owners,tags,domains", null)`.
|
||||
- **Exposability gate:** returns the pill only if `sourceType=FILE_EXTRACTION`
|
||||
**and** `shareConfig.visibility=SHARED`, else an error.
|
||||
- **Output:** `{fullyQualifiedName, name, title, question, answer, summary,
|
||||
memoryType, sourceFile (as FQN)}`.
|
||||
- **Auth:** same global `VIEW_ALL`.
|
||||
|
||||
Both gate on a single global `VIEW_ALL` plus the `Shared`-visibility scope; there
|
||||
is no per-pill owner re-check beyond that.
|
||||
|
||||
## 13. UI
|
||||
|
||||
- **`DocumentStatusBadge`** (new) renders a `ui-core-components` `Badge` (size
|
||||
`sm`, no icon) from a `ProcessingStatus`, returning `null` when status is
|
||||
absent. Mapping:
|
||||
|
||||
| Status | Color | Label key |
|
||||
|--------|-------|-----------|
|
||||
| `Uploaded` | gray | `label.uploaded` |
|
||||
| `Analyzing` | blue | `label.analyzing` |
|
||||
| `ExtractingContext` | indigo | `label.extracting-context` |
|
||||
| `Processed` | success | `label.processed` |
|
||||
| `Failed` | error | `label.failed` |
|
||||
| `Unsupported` | warning | `label.unsupported` |
|
||||
|
||||
- **`DocumentsView`** renders the badge inline next to each document's filename,
|
||||
driven by `file.processingStatus`.
|
||||
- **i18n:** 4 new `en-us` keys (`label.analyzing`, `label.extracting-context`,
|
||||
`label.unsupported`, `label.uploaded`); `label.processed`/`label.failed`
|
||||
pre-existed. Synced across all locales.
|
||||
|
||||
## 14. Security & privacy
|
||||
|
||||
- LLM provider API keys are `mask:true` config through the Secrets Manager —
|
||||
never logged, never returned in API responses.
|
||||
- **File content is sent to the configured LLM provider** during extraction. This
|
||||
is an explicit, admin-enabled action (`llmConfiguration.enabled`), and the
|
||||
provider is the admin's configured choice — documented in the yaml header.
|
||||
- Generated pills default to `visibility=Shared` (org-readable). MCP retrieval
|
||||
enforces the `Shared` scope and a `VIEW_ALL` authorize on every call.
|
||||
- No new unauthenticated surface; MCP tools ride existing OAuth/JWT auth.
|
||||
|
||||
## 15. Testing
|
||||
|
||||
**Java unit (`openmetadata-service`)**
|
||||
- `drive/ContextFileProcessingServiceTest` — status machine: analyzing→processed,
|
||||
LLM-enabled extraction, LLM-rejection/failure keeps text, storage-unavailable &
|
||||
null-stream failures, executor rejection, head-content skip, VME rethrow.
|
||||
- `drive/ContextMemoryExtractorTest` — one-memory-per-pill, dedupe + skip-invalid,
|
||||
skip-when-no-text, multi-chunk dedupe, chunk cap.
|
||||
- `jdbi3/SystemRepositoryObjectStorageValidationTest` — the four storage-validation
|
||||
scenarios.
|
||||
- `llm/LLMClientHolderTest`, `llm/LLMCompletionClientFactoryTest`,
|
||||
`llm/LLMCompletionClientTest` — holder stability + disabled-builds-no-client,
|
||||
factory dispatch, structured parsing / code-fence / concurrency guard.
|
||||
|
||||
**MCP (`openmetadata-mcp`)**
|
||||
- `tools/GetCompanyContextToolTest` — missing/blank fqn, auth-denied, shared-pill
|
||||
projection, non-file & private rejection.
|
||||
- `tools/SearchCompanyContextToolTest` — missing/blank query, auth-denied (input
|
||||
guards; does not exercise the vector path).
|
||||
|
||||
**Integration (`openmetadata-integration-tests`)**
|
||||
- `it/drive/CompanyContextPipelineIT` — end-to-end: upload `.txt`, poll
|
||||
(Awaitility, ≤45s) to `Processed`, `assumeTrue(memoryCount > 0)` to skip
|
||||
gracefully when no LLM provider is configured; otherwise asserts
|
||||
`memoryCount == pills.size()` and per-pill `sourceType=FILE_EXTRACTION`, non-null
|
||||
`sourceFile` matching the file id, non-blank question/answer.
|
||||
|
||||
**UI** — `DocumentStatusBadge.test.tsx` (renders nothing without status; `it.each`
|
||||
over the six status→label→color rows). No new Playwright spec.
|
||||
|
||||
## 16. Configuration reference
|
||||
|
||||
```yaml
|
||||
llmConfiguration:
|
||||
enabled: ${LLM_ENABLED:-false}
|
||||
provider: ${LLM_PROVIDER:-noop} # noop | openai | azureOpenAI | bedrock | google | anthropic
|
||||
maxConcurrentRequests: ${LLM_MAX_CONCURRENT_REQUESTS:-5}
|
||||
openai:
|
||||
apiKey: ${LLM_OPENAI_API_KEY:-""}
|
||||
modelId: ${LLM_OPENAI_MODEL_ID:-"gpt-4o-mini"}
|
||||
endpoint: ${LLM_OPENAI_ENDPOINT:-""}
|
||||
deploymentName:${LLM_OPENAI_DEPLOYMENT:-""}
|
||||
apiVersion: ${LLM_OPENAI_API_VERSION:-"2024-02-01"}
|
||||
maxTokens: ${LLM_OPENAI_MAX_TOKENS:-4096}
|
||||
bedrock:
|
||||
awsConfig:
|
||||
awsRegion: ${AWS_DEFAULT_REGION:-""}
|
||||
# IAM or static keys via AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN
|
||||
modelId: ${LLM_BEDROCK_MODEL_ID:-"eu.anthropic.claude-haiku-4-5-20251001-v1:0"}
|
||||
maxTokens: ${LLM_BEDROCK_MAX_TOKENS:-4096}
|
||||
google:
|
||||
apiKey: ${LLM_GOOGLE_API_KEY:-""}
|
||||
modelId: ${LLM_GOOGLE_MODEL_ID:-"gemini-2.5-flash"}
|
||||
maxTokens: ${LLM_GOOGLE_MAX_TOKENS:-4096}
|
||||
anthropic:
|
||||
apiKey: ${LLM_ANTHROPIC_API_KEY:-""}
|
||||
modelId: ${LLM_ANTHROPIC_MODEL_ID:-"claude-3-5-sonnet-20240620"}
|
||||
baseUrl: ${LLM_ANTHROPIC_BASE_URL:-"https://api.anthropic.com"}
|
||||
maxTokens: ${LLM_ANTHROPIC_MAX_TOKENS:-4096}
|
||||
```
|
||||
|
||||
## 17. Known limitations & follow-ups
|
||||
|
||||
1. **Chunk tail dropped.** Documents over ~480K chars (8 × 60K) lose their tail to
|
||||
the LLM, even though `canonicalText` supplies up to ~1M chars. Consider raising
|
||||
`MAX_CHUNKS` or summarizing the remainder.
|
||||
2. **`memoryCount` not in bulk path.** It is populated only in single-entity
|
||||
`setFields`, not `setFieldsInBulk` — list endpoints requesting `memoryCount`
|
||||
may return it unset.
|
||||
3. **`processingError` is content-only.** The file entity flips to `Failed` with
|
||||
no error string; clients must read the content snapshot for the reason.
|
||||
4. **No checksum short-circuit.** Identical re-uploads pay the full text + LLM
|
||||
cost; the captured checksum is stored but never compared.
|
||||
5. **Wholesale reprocess.** Pills are hard-deleted and regenerated with new
|
||||
UUIDs/FQNs each run — no continuity/versioning for an unchanged pill.
|
||||
6. **Bedrock client never closed.** `BedrockCompletionClient` is `AutoCloseable`
|
||||
but `LLMClientHolder` never calls `close()`; a re-`initialize` would leak the
|
||||
prior SDK client. Low impact (single startup init).
|
||||
7. **Factory ignores `enabled`.** Only the holder gates on it; calling the factory
|
||||
directly with `enabled=false` + a real provider would still build a live client.
|
||||
8. **MCP authorization is coarse.** A single global `VIEW_ALL` + ES
|
||||
`visibility=Shared` filter, with no per-pill owner re-check.
|
||||
9. **`Unsupported` status** is in the enum but never written by the service
|
||||
(passthrough from the text extractor only).
|
||||
10. **Per-language ES index files are byte-identical** — the jp/ru/zh
|
||||
`sourceFile` mappings carry no CJK/Russian-specific analyzers.
|
||||
|
||||
## 18. File touch-list
|
||||
|
||||
**New — backend**
|
||||
- `openmetadata-spec/.../json/schema/configuration/llmConfiguration.json`
|
||||
- `openmetadata-service/.../service/llm/` — `LLMCompletionClient`,
|
||||
`LLMCompletionClientFactory`, `LLMClientHolder`, `LLMCompletionException`,
|
||||
`KnowledgePill`, `NoopCompletionClient`, `OpenAICompletionClient`,
|
||||
`AnthropicCompletionClient`, `BedrockCompletionClient`, `GoogleCompletionClient`
|
||||
- `openmetadata-service/.../service/drive/ContextMemoryExtractor.java`
|
||||
- `openmetadata-mcp/.../tools/SearchCompanyContextTool.java`, `GetCompanyContextTool.java`
|
||||
|
||||
**New — frontend**
|
||||
- `.../components/.../ContextCenter/DocumentStatusBadge/` (component, interface, test)
|
||||
|
||||
**Modified — schema**
|
||||
- `entity/context/contextMemory.json`, `api/context/createContextMemory.json`,
|
||||
`entity/data/contextFile.json` (+ generated TS mirrors)
|
||||
|
||||
**Modified — backend**
|
||||
- `OpenMetadataApplication.java`, `OpenMetadataApplicationConfig.java`,
|
||||
`conf/openmetadata.yaml`
|
||||
- `service/drive/ContextFileExtractionService.java` → **renamed**
|
||||
`ContextFileProcessingService.java`
|
||||
- `resources/drive/ContextFileResource.java`
|
||||
- `jdbi3/ContextMemoryRepository.java`, `jdbi3/ContextFileRepository.java`,
|
||||
`jdbi3/SystemRepository.java`, `attachments/AssetServiceFactory.java`
|
||||
- `resources/context/ContextMemoryMapper.java`
|
||||
- `search/indexes/ContextMemoryIndex.java`,
|
||||
`search/vector/ContextMemoryBodyTextContributor.java`,
|
||||
`elasticsearch/{en,jp,ru,zh}/context_memory_search_index.json`
|
||||
- `openmetadata-mcp/.../tools/DefaultToolContext.java`,
|
||||
`openmetadata-mcp/.../resources/json/data/mcp/tools.json`
|
||||
|
||||
**Modified — frontend**
|
||||
- `.../DocumentsView/DocumentsView.component.tsx`, locale files (17), generated TS
|
||||
|
||||
**Tests** — across `openmetadata-service`, `openmetadata-mcp`,
|
||||
`openmetadata-integration-tests`, and UI (see [§15](#15-testing)).
|
||||
|
||||
**Codegen after schema edits:** `make generate`, `mvn spotless:apply`, UI
|
||||
checkstyle for generated TS, `npx tsc --noEmit`.
|
||||
|
||||
---
|
||||
|
||||
## 19. Follow-on: AISettings + Memory Agent (designed)
|
||||
|
||||
**Status:** Designed, not yet built. Full design:
|
||||
`docs/superpowers/specs/2026-06-18-ai-settings-memory-agent-design.md`. Built on
|
||||
branch `pmbrull/ottawa`. Two capabilities layered on top of the pill pipeline above.
|
||||
|
||||
### 19.1 AISettings (config plane)
|
||||
|
||||
A SearchSettings-style settings entity (`configuration/aiSettings.json`,
|
||||
`SettingsType.AI_SETTINGS`, seeded + merged via `SettingsCache` / a new
|
||||
`AISettingsHandler`, read/written through the generic `SystemResource`
|
||||
GET/PUT/reset, UI page under Preferences → AI). Shape:
|
||||
|
||||
- master `enabled` kill-switch (gates extraction **and** the agent)
|
||||
- `memoryExtraction.{fromFiles, fromPages}`
|
||||
- `memoryAgent.{enabled, deriveGlossaryTerms, deriveMetrics, deletionPolicy}`
|
||||
(`deletionPolicy` ∈ `cascade | orphan | deprecate`, default `cascade`)
|
||||
- `prompts.{memoryExtraction, memoryAgent}.systemPrompt`
|
||||
|
||||
**Deltas to this doc:**
|
||||
- The hardcoded `ContextMemoryExtractor.SYSTEM_PROMPT` ([§8](#8-knowledge-pill-extraction-contextmemoryextractor))
|
||||
moves to `AISettings.prompts.memoryExtraction.systemPrompt` — seed JSON carries
|
||||
the default, the code constant is kept only as the cache-miss fallback, and the
|
||||
extractor reads it at run time. This addresses the un-tunable-prompt gap.
|
||||
- File extraction ([§7.2](#72-service-contextfileprocessingservice-was-contextfileextractionservice)
|
||||
`submit`) and page extraction now gate **additionally** on AISettings
|
||||
(`enabled && memoryExtraction.fromFiles` / `fromPages`), not only on
|
||||
`LLMClientHolder.isEnabled()`.
|
||||
|
||||
### 19.2 Memory Agent (memory → ontology)
|
||||
|
||||
On `ContextMemory` create/update, an async, throttled agent reconciles the memory
|
||||
against the **existing** instance ontology and derives Glossary Terms / Metrics —
|
||||
reusing what already covers the memory, creating only what is missing.
|
||||
|
||||
- New **`DERIVED_FROM`** relationship (appended last in `entityRelationship.json`;
|
||||
`derivedEntity → sourceMemory`).
|
||||
- `MemoryExtractor` (pure `derive`) + `MemoryReconciler` (owns writes) +
|
||||
`MemoryProcessingEngine` (orchestrator) in the `drive` package — siblings to
|
||||
this pill pipeline, same hash-gate / throttle / reconcile idioms — triggered
|
||||
from new `ContextMemoryRepository.postCreate` / `postUpdate` hooks.
|
||||
- Per memory, two **independent** axes (Term, Metric), each:
|
||||
REUSE (`RELATED_TO` edge to an existing entity, not owned) /
|
||||
CREATE (`provider=automation`, `DERIVED_FROM` edge, agent-owned; mints a
|
||||
glossary when none fits) / SKIP. Grounding is top-K search over the existing
|
||||
term/metric indexes (no full-ontology load).
|
||||
- **Ownership lifecycle:** adopt-on-touch flips `automation→user` on a human PATCH
|
||||
(mirrors the pills Manual-guard); `deletionPolicy` governs memory-delete cleanup
|
||||
of owned entities (`cascade` hard-deletes, `orphan` releases, `deprecate` flags).
|
||||
Agent writes as a dedicated `memory-bot`.
|
||||
- **Schema:** new `ContextMemory.memoryStats` (hash-gate + derived/reused
|
||||
counts); new `provider` field on `Metric` ([§6.1](#61-schemas)) for uniform
|
||||
ownership across Terms/Metrics/Glossaries.
|
||||
- Renders the full **File → Memory → Term/Metric** provenance chain in the UI.
|
||||
@@ -0,0 +1,275 @@
|
||||
# CSV Import/Export Enhancement for Glossary Term Relations
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Currently, the glossary CSV import/export only captures related term FQNs without the relation type:
|
||||
- **Export**: Only exports FQNs like `Glossary.Term1;Glossary.Term2`
|
||||
- **Import**: Hardcodes all relations to `"relatedTo"`
|
||||
|
||||
This causes data loss when:
|
||||
1. A term has `synonym`, `broader`, `narrower`, or custom relation types
|
||||
2. CSV is exported and re-imported - all relation types become `"relatedTo"`
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### New CSV Format
|
||||
|
||||
**Format**: `relationType:termFQN` pairs separated by semicolons
|
||||
|
||||
**Examples**:
|
||||
```csv
|
||||
# New format with relation types
|
||||
relatedTerms
|
||||
synonym:Finance.Revenue;broader:Finance.Income;narrower:Finance.Net Revenue
|
||||
|
||||
# Backward compatible - no prefix defaults to "relatedTo"
|
||||
relatedTerms
|
||||
Finance.Revenue;Finance.Income
|
||||
|
||||
# Mixed format (new and legacy)
|
||||
relatedTerms
|
||||
synonym:Finance.Revenue;Finance.Income;broader:Finance.Gross Income
|
||||
```
|
||||
|
||||
### Parsing Rules
|
||||
|
||||
1. If a value contains `:` and the part before `:` is a valid relation type → use that relation type
|
||||
2. If no `:` or the prefix is not a valid relation type → default to `"relatedTo"`
|
||||
3. Valid relation types are determined by checking `glossaryTermRelationSettings` or using defaults
|
||||
|
||||
### Default Relation Types
|
||||
|
||||
| Relation Type | Description |
|
||||
|---------------|-------------|
|
||||
| `relatedTo` | Generic related term (default) |
|
||||
| `synonym` | Equivalent term |
|
||||
| `broader` | More general term |
|
||||
| `narrower` | More specific term |
|
||||
| `antonym` | Opposite meaning |
|
||||
| `partOf` | Component of |
|
||||
| `hasPart` | Contains |
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Backend Changes
|
||||
|
||||
#### 1.1 CsvUtil.java - Export Enhancement
|
||||
|
||||
**File**: `openmetadata-service/src/main/java/org/openmetadata/csv/CsvUtil.java`
|
||||
|
||||
**Current** (line 253-263):
|
||||
```java
|
||||
public static List<String> addTermRelations(
|
||||
List<String> csvRecord, List<TermRelation> termRelations) {
|
||||
csvRecord.add(
|
||||
nullOrEmpty(termRelations)
|
||||
? null
|
||||
: termRelations.stream()
|
||||
.map(tr -> tr.getTerm().getFullyQualifiedName())
|
||||
.sorted()
|
||||
.collect(Collectors.joining(FIELD_SEPARATOR)));
|
||||
return csvRecord;
|
||||
}
|
||||
```
|
||||
|
||||
**New**:
|
||||
```java
|
||||
public static List<String> addTermRelations(
|
||||
List<String> csvRecord, List<TermRelation> termRelations) {
|
||||
csvRecord.add(
|
||||
nullOrEmpty(termRelations)
|
||||
? null
|
||||
: termRelations.stream()
|
||||
.map(tr -> {
|
||||
String relationType = tr.getRelationType();
|
||||
String fqn = tr.getTerm().getFullyQualifiedName();
|
||||
// Only include relation type prefix if not the default "relatedTo"
|
||||
if (relationType != null && !relationType.equals("relatedTo")) {
|
||||
return relationType + ":" + fqn;
|
||||
}
|
||||
return fqn;
|
||||
})
|
||||
.sorted()
|
||||
.collect(Collectors.joining(FIELD_SEPARATOR)));
|
||||
return csvRecord;
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 GlossaryRepository.java - Import Enhancement
|
||||
|
||||
**File**: `openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryRepository.java`
|
||||
|
||||
**Current** (line 315-327):
|
||||
```java
|
||||
private List<TermRelation> getTermRelationsFromCsv(
|
||||
CSVPrinter printer, CSVRecord csvRecord, int fieldNumber) throws IOException {
|
||||
List<EntityReference> entityRefs =
|
||||
getEntityReferences(printer, csvRecord, fieldNumber, GLOSSARY_TERM);
|
||||
if (entityRefs == null) {
|
||||
return null;
|
||||
}
|
||||
List<TermRelation> termRelations = new ArrayList<>();
|
||||
for (EntityReference ref : entityRefs) {
|
||||
termRelations.add(new TermRelation().withTerm(ref).withRelationType("relatedTo"));
|
||||
}
|
||||
return termRelations;
|
||||
}
|
||||
```
|
||||
|
||||
**New**:
|
||||
```java
|
||||
private static final Set<String> VALID_RELATION_TYPES = Set.of(
|
||||
"relatedTo", "synonym", "broader", "narrower", "antonym", "partOf", "hasPart"
|
||||
);
|
||||
|
||||
private List<TermRelation> getTermRelationsFromCsv(
|
||||
CSVPrinter printer, CSVRecord csvRecord, int fieldNumber) throws IOException {
|
||||
String fieldValue = csvRecord.get(fieldNumber);
|
||||
if (nullOrEmpty(fieldValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<TermRelation> termRelations = new ArrayList<>();
|
||||
String[] entries = fieldValue.split(FIELD_SEPARATOR);
|
||||
|
||||
for (String entry : entries) {
|
||||
String relationType = "relatedTo"; // Default
|
||||
String termFqn = entry.trim();
|
||||
|
||||
// Check for relationType:fqn format
|
||||
int colonIndex = entry.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
String prefix = entry.substring(0, colonIndex).trim();
|
||||
String suffix = entry.substring(colonIndex + 1).trim();
|
||||
|
||||
// Validate if prefix is a known relation type
|
||||
if (VALID_RELATION_TYPES.contains(prefix) || isCustomRelationType(prefix)) {
|
||||
relationType = prefix;
|
||||
termFqn = suffix;
|
||||
}
|
||||
// If prefix is not a valid relation type, treat entire string as FQN
|
||||
// (handles FQNs that contain colons like "Database:Schema.Table")
|
||||
}
|
||||
|
||||
EntityReference termRef = getEntityReference(printer, csvRecord, GLOSSARY_TERM, termFqn);
|
||||
if (termRef != null) {
|
||||
termRelations.add(new TermRelation().withTerm(termRef).withRelationType(relationType));
|
||||
}
|
||||
}
|
||||
|
||||
return termRelations.isEmpty() ? null : termRelations;
|
||||
}
|
||||
|
||||
private boolean isCustomRelationType(String relationType) {
|
||||
// Check against glossaryTermRelationSettings for custom relation types
|
||||
try {
|
||||
// Fetch from settings cache or use default list
|
||||
return false; // Implement based on settings lookup
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 Documentation Update
|
||||
|
||||
**File**: `openmetadata-service/src/main/resources/json/data/glossary/glossaryCsvDocumentation.json`
|
||||
|
||||
Update the `relatedTerms` field documentation:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "relatedTerms",
|
||||
"required": false,
|
||||
"description": "Related glossary terms with optional relation types. Format: 'relationType:FQN' or just 'FQN'. Multiple values separated by ';'. Valid relation types: relatedTo (default), synonym, broader, narrower, antonym, partOf, hasPart. Example: 'synonym:Glossary.Term1;broader:Glossary.Term2;Glossary.Term3'",
|
||||
"examples": [
|
||||
"Glossary.Term1;Glossary.Term2",
|
||||
"synonym:Glossary.Term1;broader:Glossary.Term2",
|
||||
"synonym:Glossary.Revenue;Glossary.Income;narrower:Glossary.Net Revenue"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Testing
|
||||
|
||||
#### 2.1 Unit Tests
|
||||
|
||||
**File**: `openmetadata-service/src/test/java/org/openmetadata/csv/CsvUtilTest.java`
|
||||
|
||||
```java
|
||||
@Test
|
||||
void testAddTermRelationsWithRelationType() {
|
||||
// Test that relation types are included in export
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAddTermRelationsDefaultRelationType() {
|
||||
// Test that "relatedTo" terms don't include prefix
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Integration Tests
|
||||
|
||||
**File**: `openmetadata-service/src/test/java/org/openmetadata/service/resources/glossary/GlossaryTermResourceTest.java`
|
||||
|
||||
```java
|
||||
@Test
|
||||
void testGlossaryTermCsvImportWithRelationTypes() {
|
||||
// Test importing CSV with relation type prefixes
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGlossaryTermCsvExportWithRelationTypes() {
|
||||
// Test exporting terms with various relation types
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGlossaryTermCsvBackwardCompatibility() {
|
||||
// Test importing old format CSV (no relation types)
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGlossaryTermCsvRoundTripWithRelationTypes() {
|
||||
// Test that export -> import preserves relation types
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Edge Cases
|
||||
|
||||
1. **FQN contains colon**: Handle cases like `Database:Schema.Term` by validating the prefix against known relation types
|
||||
2. **Invalid relation type**: If prefix is not a valid relation type, treat entire string as FQN with default `relatedTo`
|
||||
3. **Empty relation type**: `":Glossary.Term"` should default to `relatedTo`
|
||||
4. **Custom relation types**: Check against `glossaryTermRelationSettings` for user-defined relation types
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
| CSV Format | Import Behavior |
|
||||
|------------|----------------|
|
||||
| `Glossary.Term1;Glossary.Term2` | All relations → `relatedTo` |
|
||||
| `synonym:Glossary.Term1;Glossary.Term2` | First → `synonym`, Second → `relatedTo` |
|
||||
| `synonym:Glossary.Term1;broader:Glossary.Term2` | Preserves both relation types |
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `CsvUtil.java` | Update `addTermRelations()` to include relation type prefix |
|
||||
| `GlossaryRepository.java` | Update `getTermRelationsFromCsv()` to parse relation types |
|
||||
| `glossaryCsvDocumentation.json` | Update field documentation and examples |
|
||||
| `GlossaryTermResourceTest.java` | Add tests for new format |
|
||||
| `CsvUtilTest.java` | Add unit tests for parsing |
|
||||
|
||||
### Migration Notes
|
||||
|
||||
- **No database migration needed**: The database already stores relation types correctly
|
||||
- **Existing CSVs**: Will continue to work (all imported as `relatedTo`)
|
||||
- **New exports**: Will include relation type prefixes for non-default relations
|
||||
|
||||
## Summary
|
||||
|
||||
This enhancement:
|
||||
1. ✅ Preserves relation types during CSV export/import
|
||||
2. ✅ Maintains backward compatibility with existing CSVs
|
||||
3. ✅ Defaults to `relatedTo` when no relation type specified
|
||||
4. ✅ Follows existing OpenMetadata CSV patterns (`type:value`)
|
||||
5. ✅ Supports custom relation types via settings
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
# Shipping Collate / OpenMetadata releases through CloudFront
|
||||
|
||||
Each customer gets a Collate deployment at their own host —
|
||||
`acme.getcolate.io`, `widgets.getcolate.io`, `globex.getcolate.io` — and each customer can
|
||||
be on a different release. This is the AWS-only design for serving the UI bundle from
|
||||
CloudFront in that model, and the coordination story when a request lands at one of those
|
||||
hosts.
|
||||
|
||||
## What we want
|
||||
|
||||
- **One CloudFront distribution** for every customer (not one per customer).
|
||||
- **One S3 bucket** for every release. Releases are immutable; promotion is a separate
|
||||
step from upload.
|
||||
- **Per-customer version pinning** that updates atomically — no DNS change, no CloudFront
|
||||
redeploy.
|
||||
- **Customer's own ALB** continues to serve `/api/*`; CloudFront only handles the UI bundle.
|
||||
|
||||
## What we explicitly do NOT want
|
||||
|
||||
- A new external data store to maintain (DynamoDB, an extra RDS, a separate Redis). The
|
||||
customer-version mapping is small (a few hundred entries, two tiny strings each) and
|
||||
changes rarely (a few writes per week, even at peak). Standing up a data store for that
|
||||
buys nothing and adds backup, monitoring, IAM, and cost surface.
|
||||
- Per-customer CloudFront distributions. They give clean isolation but at N customers we
|
||||
have N distributions to manage, N caches that share no edge state across customers, and
|
||||
hit the AWS 200-distributions-per-account cap by default. The savings from edge cache
|
||||
sharing (a thousand customers on v1.12.0 hit the same cached chunk) are the entire
|
||||
reason the shared model is worth using.
|
||||
- A lookup that requires Lambda@Edge. The cold start and per-request cost is real
|
||||
($1+/M, plus 30-60 ms when cold) and we don't need the SDK access Lambda@Edge gives.
|
||||
|
||||
## The architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
acme.getcolate.io ───┐ │ CloudFront distribution │
|
||||
widgets.getcolate.io ───┼─────►│ d1234abc.cloudfront.net │
|
||||
globex.getcolate.io ───┘ │ │
|
||||
│ ┌─ behavior: /* ──────────────────┐ │
|
||||
│ │ origin: S3 │ │ ┌─────────────────────────────┐
|
||||
│ │ viewer-request: host_router.js │─┼────►│ S3: collate-cdn │
|
||||
│ │ rewrites /foo → │ │ │ release/v1.11.2/index.html │
|
||||
│ │ /release/<version>/foo │ │ │ release/v1.12.0/index.html │
|
||||
│ └──────────────────────────────────┘ │ │ release/v1.13.0-beta/... │
|
||||
│ │ └─────────────────────────────┘
|
||||
│ ┌─ behavior: /api/* ──────────────┐ │
|
||||
│ │ bypass: same host's per- │ │ ┌─────────────────────────────┐
|
||||
│ │ customer ALB (Option A below) │─┼────►│ Each customer's own ALB │
|
||||
│ └──────────────────────────────────┘ │ └─────────────────────────────┘
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The CloudFront Function holds the customer→version routing table **as JavaScript object
|
||||
literal**. Source of truth is the Function's source code in our git repo. Promotion is
|
||||
a Function code update.
|
||||
|
||||
## The Function (no external lookup)
|
||||
|
||||
```js
|
||||
// host_router.js — CloudFront Function v2.0 (no Lambda@Edge, no KVS, no DynamoDB)
|
||||
//
|
||||
// Source of truth for which release each customer is pinned to. Edit, commit, deploy.
|
||||
// CI propagates a change to every edge POP in ~60 s.
|
||||
|
||||
const CUSTOMER_VERSIONS = {
|
||||
acme: 'v1.12.0',
|
||||
widgets: 'v1.11.2',
|
||||
globex: 'v1.13.0-beta',
|
||||
// … N customers
|
||||
};
|
||||
|
||||
// Hosts that don't match a customer slug (apex, www., staging) fall back to the latest
|
||||
// stable release. Bump this in lockstep with every GA release so new customers that
|
||||
// haven't been added to CUSTOMER_VERSIONS yet still get a current build.
|
||||
const DEFAULT_VERSION = 'v1.12.0';
|
||||
|
||||
function handler(event) {
|
||||
const request = event.request;
|
||||
|
||||
// /api/* lives on a separate behavior with the customer's own ALB as origin.
|
||||
// The Function should never see these requests under the current behavior config,
|
||||
// but guard anyway.
|
||||
if (request.uri.startsWith('/api/')) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const host = (request.headers.host && request.headers.host.value) || '';
|
||||
// Convention: customer slug is the first label of the host.
|
||||
// acme.getcolate.io -> 'acme'
|
||||
const slug = host.split('.')[0];
|
||||
const version = CUSTOMER_VERSIONS[slug] || DEFAULT_VERSION;
|
||||
|
||||
// /assets/foo.js -> /release/v1.12.0/assets/foo.js
|
||||
request.uri = '/release/' + version + request.uri;
|
||||
return request;
|
||||
}
|
||||
```
|
||||
|
||||
Function v2.0 has a 10 KB code limit. At ~30 bytes per entry that's ~300 customers
|
||||
comfortably; well beyond that the design needs revisiting — but if you ever reach 300+
|
||||
customers on this product, the operational economics of standing up KVS or DynamoDB
|
||||
will have shifted significantly anyway.
|
||||
|
||||
## Promotion flow
|
||||
|
||||
1. Edit `CUSTOMER_VERSIONS` in the Function source.
|
||||
2. Commit, push, open PR. The PR diff IS the promotion record — reviewable, auditable,
|
||||
git-blame'd.
|
||||
3. CI runs on merge: pushes the new Function code via `aws cloudfront update-function`
|
||||
and `publish-function`.
|
||||
4. ~60 s of edge propagation. Every POP picks up the new code.
|
||||
|
||||
A typical promotion PR looks like one line changed:
|
||||
|
||||
```diff
|
||||
const CUSTOMER_VERSIONS = {
|
||||
- acme: 'v1.12.0',
|
||||
+ acme: 'v1.12.1',
|
||||
widgets: 'v1.11.2',
|
||||
globex: 'v1.13.0-beta',
|
||||
};
|
||||
```
|
||||
|
||||
That's the entire surface area of a promotion. No DynamoDB write. No KVS API call. No
|
||||
extra IAM role. No backup story. Just a code change reviewed like any other.
|
||||
|
||||
Rollback is symmetric: revert the commit. Canary is "promote one slug first, watch error
|
||||
metrics, then PR the next batch." Roll-forward on a regression is the same revert.
|
||||
|
||||
### Release upload (independent of promotion)
|
||||
|
||||
The bundle bytes go to S3 separately, on every release tag, regardless of which customer
|
||||
ends up using them:
|
||||
|
||||
```bash
|
||||
VERSION="v1.12.0"
|
||||
aws s3 sync openmetadata-ui/src/main/resources/ui/dist/assets/ \
|
||||
s3://collate-cdn/release/${VERSION}/assets/ \
|
||||
--cache-control "public, max-age=31536000, immutable"
|
||||
|
||||
aws s3 cp openmetadata-ui/src/main/resources/ui/dist/index.html \
|
||||
s3://collate-cdn/release/${VERSION}/index.html \
|
||||
--cache-control "no-cache, must-revalidate" \
|
||||
--content-type "text/html; charset=utf-8"
|
||||
```
|
||||
|
||||
After this, the release exists in S3 but no customer is using it. Promotion (the PR
|
||||
above) is what flips customers to it. The decoupling matters: you can sit on a release
|
||||
in S3 for a week, watching it on staging, before promoting any customer to it.
|
||||
|
||||
## Why the Function code is a fine routing table
|
||||
|
||||
Honest comparison of the three approaches:
|
||||
|
||||
| | Function-embedded (this design) | CloudFront KeyValueStore | DynamoDB + Lambda@Edge |
|
||||
|---|---|---|---|
|
||||
| New AWS service to monitor / back up | none | KVS | DynamoDB + Lambda |
|
||||
| Read latency at edge | ~0 (in-function) | ~1 ms | ~10 ms (warm Lambda) |
|
||||
| Cold start | none | none | 30-60 ms |
|
||||
| Per-request cost | $0.10/M Function | $0.10/M Function + $0.04/M KVS | $0.10/M + $1+/M Lambda + DynamoDB reads |
|
||||
| Promotion surface | git PR | API call (`put-key`) | API call (`update-item`) |
|
||||
| Audit trail | git history | CloudWatch + KVS audit logs | CloudWatch + DDB streams |
|
||||
| Capacity ceiling | ~300 customers (10 KB code limit) | millions | millions |
|
||||
| Concurrent promotion safety | git merge serializes | `IfMatch` ETag | conditional writes |
|
||||
| Operational ownership | "this is in the repo" | "who paged on this last quarter?" | "who paged on this last quarter?" |
|
||||
|
||||
For a product that ships per-customer clusters and reaches dozens-to-low-hundreds of
|
||||
customers, "the routing table is a file in the repo" wins on every operational axis that
|
||||
matters. It only loses on capacity ceiling, and the day that becomes a problem we already
|
||||
have a clear migration target (KVS) without changing anything else in the design.
|
||||
|
||||
## API routing — two options, pick one
|
||||
|
||||
The Function above only handles UI bundle requests. `/api/*` still has to reach the
|
||||
customer's own ALB.
|
||||
|
||||
### Option A — Separate API host (recommended)
|
||||
|
||||
```
|
||||
acme.getcolate.io → CNAME → CloudFront distribution (this design)
|
||||
api-acme.getcolate.io → CNAME → acme's ALB
|
||||
```
|
||||
|
||||
SPA's API base URL is derived from the page host at runtime: `https://api-{slug}.getcolate.io/api`.
|
||||
|
||||
Pros: CloudFront does one thing well (static delivery). No Lambda@Edge anywhere. Failure
|
||||
modes are easy to reason about. Cons: SPA has a cookie/CORS story that knows about two
|
||||
hosts; we already handle this for various integrations.
|
||||
|
||||
### Option B — Same host, Lambda@Edge for `/api/*`
|
||||
|
||||
CloudFront's `/api/*` behavior runs a Lambda@Edge on origin-request that reads the host
|
||||
header and rewrites the origin to the right ALB.
|
||||
|
||||
Pros: single host per customer. Cons: now we DO have Lambda@Edge (which we explicitly
|
||||
chose to avoid for routing), and the operational cost is per-customer-API-request, not
|
||||
just per-promotion. We strongly prefer Option A.
|
||||
|
||||
## S3 bucket layout
|
||||
|
||||
```
|
||||
collate-cdn/
|
||||
└── release/
|
||||
├── v1.11.5/
|
||||
│ ├── index.html no-cache, must-revalidate
|
||||
│ ├── assets/index-Z3O_FBkA.js immutable
|
||||
│ ├── assets/index-Z3O_FBkA.js.br immutable
|
||||
│ ├── assets/index-Z3O_FBkA.js.gz immutable
|
||||
│ ├── assets/vendor-antd-BgrjOjhB.js immutable
|
||||
│ └── ...
|
||||
├── v1.12.0/ ← acme + widgets currently here
|
||||
│ └── ...
|
||||
└── v1.13.0-beta/ ← globex currently here (canary)
|
||||
└── ...
|
||||
```
|
||||
|
||||
Releases are immutable once uploaded. The promotion step never modifies S3 contents —
|
||||
only the Function code that maps `slug → /release/<v>/`.
|
||||
|
||||
Disk cost is small: a typical OM bundle is ~12 MB on disk after content-hash dedup,
|
||||
Brotli+gzip siblings add ~25%, call it 15 MB per release. 100 releases × 15 MB =
|
||||
1.5 GB. S3 standard rates put that at a few cents per month — keep many releases live
|
||||
for instant rollback and don't bother with aggressive lifecycle pruning.
|
||||
|
||||
## CloudFront cache behaviors
|
||||
|
||||
| Path pattern (after Function rewrite) | Edge TTL | Notes |
|
||||
|---|---|---|
|
||||
| `/release/<v>/assets/*` | 1 year | Content-addressed; bytes can't change |
|
||||
| `/release/<v>/index.html`, `/release/<v>/` | 30 s | Concurrent users in one region share one origin hit; ETag layer takes over after 30 s |
|
||||
| `/api/*` | bypass | Separate behavior to customer ALB (Option A: not via CloudFront at all) |
|
||||
|
||||
30 s on the shell is the sweet spot: long enough to dedupe a thousand concurrent reloads
|
||||
to one origin fetch, short enough that a promotion lands at all customers within ~90 s
|
||||
end-to-end (60 s Function propagation + 30 s residual edge cache).
|
||||
|
||||
## Per-customer branding (without per-customer bundles)
|
||||
|
||||
If a customer needs a different logo or accent colour, the right move is to keep one
|
||||
universal bundle and overlay branding assets at request time:
|
||||
|
||||
- Universal default: `/release/v1.12.0/images/logo.png` in S3.
|
||||
- Per-customer override (optional, only when needed): the Function checks for
|
||||
`s3://collate-cdn/customer-overrides/<slug>/logo.png` first and rewrites if it exists.
|
||||
|
||||
Branding stays out of the build artifact, which means one bundle still serves every
|
||||
customer and the cache-sharing argument holds.
|
||||
|
||||
## Verification after promotion
|
||||
|
||||
Two synthetic checks worth running automatically after a promotion PR merges:
|
||||
|
||||
```bash
|
||||
SLUG=acme
|
||||
EXPECTED_VERSION=v1.12.0
|
||||
|
||||
# 1. CloudFront serves the right release for this slug
|
||||
RESPONSE=$(curl -s "https://${SLUG}.getcolate.io/?nocache=$(uuidgen)")
|
||||
echo "$RESPONSE" | grep -oE 'index-[A-Za-z0-9_-]+\.js' | sort -u
|
||||
# Should match the hash from the v1.12.0 build manifest
|
||||
|
||||
# 2. The HTML shell is being served fresh from the right S3 prefix
|
||||
curl -sI "https://${SLUG}.getcolate.io/" \
|
||||
| grep -i 'x-amz-cf-pop\|via\|x-cache'
|
||||
# Should show an edge POP near the test runner, and either "Miss from cloudfront"
|
||||
# (first request after promotion) or "Hit from cloudfront" (within the 30 s edge TTL)
|
||||
```
|
||||
|
||||
CI runs this on every promotion PR after the Function deploys, and fails loud if the
|
||||
served bundle doesn't match the version we just pinned.
|
||||
|
||||
## What's not in this design
|
||||
|
||||
- **Per-customer API origin selection inside CloudFront**. Option A keeps `/api/*` off
|
||||
the CloudFront path entirely. If a customer ever needs single-host behavior, that's
|
||||
the moment to revisit Option B and accept Lambda@Edge.
|
||||
- **Multi-region S3 origin failover**. Single bucket in one region; CloudFront's edge
|
||||
caching handles regional reach. If you want CRR + origin groups, add them; the cost
|
||||
is straightforward but rarely justified for a UI bundle.
|
||||
- **WAF / Shield Advanced**. Add separately if your security posture requires them.
|
||||
|
||||
## What this design is good for and what would push it elsewhere
|
||||
|
||||
- **Good for**: dozens to low-hundreds of customers, infrequent promotion (a few per
|
||||
week), engineering ownership over the routing table.
|
||||
- **Push toward KVS** when: customer count grows past a few hundred (function size
|
||||
pressure) OR promotions happen via a non-engineering UI (a customer-success dashboard
|
||||
that flips slugs without a git PR).
|
||||
- **Push toward Lambda@Edge** when: routing decisions stop being a slug→version map and
|
||||
start needing per-request information not available in the host header (e.g. A/B
|
||||
testing by user ID, geo-routing, header-derived feature flags).
|
||||
|
||||
When those days come, the migration path from this design is small — the Function code
|
||||
becomes a `kvs.get(slug)` instead of a hash lookup, and the rest of the architecture
|
||||
(S3 layout, distribution behaviors, ALB routing) is identical.
|
||||
@@ -0,0 +1,384 @@
|
||||
# Search Indexing Stats Redesign
|
||||
|
||||
## Overview
|
||||
|
||||
Redesign the SearchIndexingApp stats tracking to simplify the current complex implementation and add support for vector embedding statistics.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Simplify stats building** - Replace multi-source stats with single pipeline model
|
||||
2. **Add vector embedding stats** - Track vector indexing separately without affecting overall job status
|
||||
3. **Per-entity index promotion** - Promote staged indexes immediately per entity type
|
||||
4. **Alias management from indexMapping.json** - Use configuration instead of reading from old index
|
||||
5. **Payload-aware vector bulk processor** - Respect payload size limits for vector chunks
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Simplified Stats Architecture
|
||||
|
||||
Replace the current multi-source stats with a **single pipeline model**:
|
||||
|
||||
```
|
||||
Read Stage → Process Stage → Sink Stage ──→ Vector Stage
|
||||
↓ ↓ ↓ ↓
|
||||
ReaderStats ProcessStats SinkStats VectorStats
|
||||
↓ ↓ ↓ ↓
|
||||
└──────────────┴──────────────┴──────────────┘
|
||||
↓
|
||||
search_index_server_stats (single source of truth)
|
||||
```
|
||||
|
||||
#### Stats Structure (per entity type, per server)
|
||||
|
||||
```java
|
||||
public class PipelineStats {
|
||||
// Reader: Database read operations
|
||||
int readerSuccess; // Entities read successfully
|
||||
int readerFailed; // Critical read errors (DB issues)
|
||||
int readerWarnings; // Non-critical (stale references, still processed)
|
||||
|
||||
// Process: Entity → SearchDoc conversion
|
||||
int processSuccess; // Docs built successfully
|
||||
int processFailed; // Build failures (EntityNotFoundException, schema errors)
|
||||
int processWarnings; // Non-critical processing issues
|
||||
|
||||
// Sink: Elasticsearch/OpenSearch write
|
||||
int sinkSuccess; // Docs indexed successfully
|
||||
int sinkFailed; // Index failures (rejected, mapping errors)
|
||||
int sinkWarnings; // Partial success (some fields skipped)
|
||||
|
||||
// Vector: Vector embedding indexing (Collate-specific)
|
||||
int vectorSuccess; // Embeddings indexed successfully
|
||||
int vectorFailed; // Embedding failures (API errors, chunk issues)
|
||||
int vectorWarnings; // Non-critical (fingerprint match, skipped regeneration)
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Points
|
||||
- **No reconciliation needed** - each stage reports its own accurate counts
|
||||
- **Vector stats are independent** - don't affect overall job success/failure
|
||||
- **Single DB table** as source of truth, updated incrementally
|
||||
|
||||
### 2. Database Schema Updates
|
||||
|
||||
#### Update `search_index_server_stats` Table
|
||||
|
||||
```sql
|
||||
ALTER TABLE search_index_server_stats ADD COLUMN (
|
||||
-- Process stage (new)
|
||||
processSuccess INT DEFAULT 0,
|
||||
processFailed INT DEFAULT 0,
|
||||
processWarnings INT DEFAULT 0,
|
||||
|
||||
-- Vector stage (new - Collate specific)
|
||||
vectorSuccess INT DEFAULT 0,
|
||||
vectorFailed INT DEFAULT 0,
|
||||
vectorWarnings INT DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
#### Update `search_index_failures` Table
|
||||
|
||||
```sql
|
||||
ALTER TABLE search_index_failures
|
||||
MODIFY COLUMN failureStage ENUM(
|
||||
'READER', -- DB read failure
|
||||
'READER_EXCEPTION', -- Non-critical read issue
|
||||
'PROCESS', -- Entity → Doc conversion failure
|
||||
'SINK', -- ES/OpenSearch write failure
|
||||
'VECTOR_SINK' -- Vector embedding failure
|
||||
);
|
||||
```
|
||||
|
||||
#### Migration Strategy
|
||||
- Add new columns with defaults (non-breaking)
|
||||
- New code writes to new columns
|
||||
- Old `entityBuildFailures` mapped to `processFailed` during aggregation (temporary)
|
||||
- Clean removal in future release
|
||||
|
||||
### 3. Simplified Stats Tracking Code
|
||||
|
||||
#### New `StageStatsTracker` Class
|
||||
|
||||
```java
|
||||
public class StageStatsTracker {
|
||||
private final String jobId;
|
||||
private final String serverId;
|
||||
private final String entityType;
|
||||
|
||||
// Atomic counters per stage
|
||||
private final StageCounter reader = new StageCounter();
|
||||
private final StageCounter process = new StageCounter();
|
||||
private final StageCounter sink = new StageCounter();
|
||||
private final StageCounter vector = new StageCounter();
|
||||
|
||||
// Record success/failure/warning for each stage
|
||||
public void recordReader(Result result) { reader.record(result); }
|
||||
public void recordProcess(Result result) { process.record(result); }
|
||||
public void recordSink(Result result) { sink.record(result); }
|
||||
public void recordVector(Result result) { vector.record(result); }
|
||||
|
||||
// Flush to DB periodically (every N operations or time interval)
|
||||
public void flush() {
|
||||
searchIndexStatsRepository.upsert(jobId, serverId, entityType,
|
||||
reader, process, sink, vector);
|
||||
}
|
||||
}
|
||||
|
||||
public class StageCounter {
|
||||
private final AtomicInteger success = new AtomicInteger();
|
||||
private final AtomicInteger failed = new AtomicInteger();
|
||||
private final AtomicInteger warnings = new AtomicInteger();
|
||||
|
||||
public void record(Result result) {
|
||||
switch (result) {
|
||||
case SUCCESS -> success.incrementAndGet();
|
||||
case FAILED -> failed.incrementAndGet();
|
||||
case WARNING -> warnings.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Usage in Pipeline
|
||||
|
||||
```java
|
||||
// In SearchIndexExecutor
|
||||
for (Entity entity : batch) {
|
||||
// Read stage
|
||||
try {
|
||||
entity = readEntity(id);
|
||||
tracker.recordReader(SUCCESS);
|
||||
} catch (EntityNotFoundException e) {
|
||||
tracker.recordReader(WARNING); // Non-critical, continue
|
||||
continue;
|
||||
} catch (Exception e) {
|
||||
tracker.recordReader(FAILED); // Critical
|
||||
recordFailure(entity, READER, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process stage
|
||||
try {
|
||||
doc = entity.buildSearchIndex();
|
||||
tracker.recordProcess(SUCCESS);
|
||||
} catch (Exception e) {
|
||||
tracker.recordProcess(FAILED);
|
||||
recordFailure(entity, PROCESS, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sink stage - handled by BulkSink callback
|
||||
bulkSink.add(doc, entity, tracker);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Immediate Per-Entity Index Promotion
|
||||
|
||||
#### Current Flow (Wait for All)
|
||||
```
|
||||
Reindex table → Reindex dashboard → Reindex pipeline → ... → Promote ALL at once
|
||||
```
|
||||
|
||||
#### New Flow (Promote Immediately)
|
||||
```
|
||||
Reindex table → Promote table immediately
|
||||
Reindex dashboard → Promote dashboard immediately
|
||||
Reindex pipeline → Promote pipeline immediately
|
||||
```
|
||||
|
||||
#### Code Changes in `DefaultRecreateHandler`
|
||||
|
||||
```java
|
||||
public class DefaultRecreateHandler implements RecreateHandler {
|
||||
|
||||
// Called after EACH entity type completes (not at the end)
|
||||
public void promoteEntityIndex(String entityType, boolean success) {
|
||||
ReindexContext context = getContext();
|
||||
String stagedIndex = context.getStagedIndex(entityType);
|
||||
String canonicalIndex = context.getCanonicalIndex(entityType);
|
||||
|
||||
if (!success) {
|
||||
// Delete failed staged index, keep old index active
|
||||
deleteIndex(stagedIndex);
|
||||
LOG.warn("Reindex failed for {}, keeping old index", entityType);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get aliases from indexMapping.json (not from old index)
|
||||
Set<String> aliases = getAliasesFromMapping(entityType);
|
||||
|
||||
// Delete old indices with this prefix (except staged)
|
||||
deleteOldIndices(canonicalIndex, stagedIndex);
|
||||
|
||||
// Promote: attach all aliases to staged index
|
||||
attachAliases(stagedIndex, aliases);
|
||||
|
||||
LOG.info("Promoted {} -> {}", entityType, stagedIndex);
|
||||
}
|
||||
|
||||
// Read aliases from indexMapping.json
|
||||
private Set<String> getAliasesFromMapping(String entityType) {
|
||||
IndexMapping mapping = indexMappings.get(entityType);
|
||||
Set<String> aliases = new HashSet<>();
|
||||
|
||||
// Add parent aliases (e.g., "all", "dataAsset")
|
||||
aliases.addAll(mapping.getParentAliases());
|
||||
|
||||
// Add short alias (e.g., "table")
|
||||
aliases.add(mapping.getAlias());
|
||||
|
||||
// Add canonical index name as alias (e.g., "table_search_index")
|
||||
aliases.add(mapping.getIndexName());
|
||||
|
||||
return aliases;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Vector Bulk Processor with Payload Size Handling
|
||||
|
||||
```java
|
||||
public class VectorBulkProcessor {
|
||||
private final List<BulkOperation> buffer = new ArrayList<>();
|
||||
private final AtomicLong currentPayloadBytes = new AtomicLong(0);
|
||||
|
||||
private final int maxBulkActions; // e.g., 500 chunks
|
||||
private final long maxPayloadSizeBytes; // e.g., 50MB (conservative for vectors)
|
||||
|
||||
public void addChunk(VectorChunk chunk, StageStatsTracker tracker) {
|
||||
long chunkSize = estimateChunkSize(chunk);
|
||||
|
||||
// Flush if adding this chunk would exceed limits
|
||||
if (shouldFlush(chunkSize)) {
|
||||
flush();
|
||||
}
|
||||
|
||||
buffer.add(toBulkOperation(chunk));
|
||||
currentPayloadBytes.addAndGet(chunkSize);
|
||||
}
|
||||
|
||||
private boolean shouldFlush(long incomingSize) {
|
||||
return buffer.size() >= maxBulkActions
|
||||
|| (currentPayloadBytes.get() + incomingSize) > maxPayloadSizeBytes;
|
||||
}
|
||||
|
||||
private long estimateChunkSize(VectorChunk chunk) {
|
||||
// Vector: dimensions × 4 bytes (float32)
|
||||
long vectorSize = chunk.getEmbedding().length * 4L;
|
||||
// Metadata: estimate JSON overhead
|
||||
long metadataSize = chunk.getMetadataJson().length();
|
||||
// Buffer for ES overhead
|
||||
return (long) ((vectorSize + metadataSize) * 1.2);
|
||||
}
|
||||
|
||||
public void flush() {
|
||||
if (buffer.isEmpty()) return;
|
||||
|
||||
try {
|
||||
BulkResponse response = client.bulk(buffer);
|
||||
processResponse(response); // Update stats via tracker
|
||||
} finally {
|
||||
buffer.clear();
|
||||
currentPayloadBytes.set(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Unified Failure Recording
|
||||
|
||||
```java
|
||||
public class IndexingFailureRecorder {
|
||||
private final List<SearchIndexFailure> buffer = new ArrayList<>();
|
||||
private static final int BATCH_SIZE = 100;
|
||||
|
||||
public enum FailureStage {
|
||||
READER, // DB read failure
|
||||
READER_EXCEPTION, // Non-critical read issue
|
||||
PROCESS, // Entity → Doc conversion failure
|
||||
SINK, // ES/OpenSearch write failure
|
||||
VECTOR_SINK // Vector embedding failure
|
||||
}
|
||||
|
||||
public void recordFailure(
|
||||
String jobId,
|
||||
String entityType,
|
||||
String entityId,
|
||||
String entityFqn,
|
||||
FailureStage stage,
|
||||
Exception error) {
|
||||
|
||||
SearchIndexFailure failure = SearchIndexFailure.builder()
|
||||
.jobId(jobId)
|
||||
.serverId(getServerId())
|
||||
.entityType(entityType)
|
||||
.entityId(entityId)
|
||||
.entityFqn(entityFqn)
|
||||
.failureStage(stage)
|
||||
.errorMessage(truncate(error.getMessage(), 65000))
|
||||
.stackTrace(truncate(getStackTrace(error), 65000))
|
||||
.timestamp(System.currentTimeMillis())
|
||||
.build();
|
||||
|
||||
synchronized (buffer) {
|
||||
buffer.add(failure);
|
||||
if (buffer.size() >= BATCH_SIZE) {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void flush() {
|
||||
synchronized (buffer) {
|
||||
if (!buffer.isEmpty()) {
|
||||
repository.batchInsert(buffer);
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**OpenMetadata Submodule:**
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `SearchIndexApp.java` | Simplify stats aggregation, remove reconciliation |
|
||||
| `SearchIndexExecutor.java` | Use `StageStatsTracker`, clean pipeline flow |
|
||||
| `DefaultRecreateHandler.java` | Per-entity promotion, alias from indexMapping.json |
|
||||
| `OpenSearchBulkSink.java` | Integrate with `StageStatsTracker` |
|
||||
| `StatsReconciler.java` | Remove or deprecate |
|
||||
| DB migration | Add process/vector columns to stats table, update failure stage enum |
|
||||
|
||||
**Collate:**
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `SearchRepositoryExt.java` | Initialize vector stats tracking |
|
||||
| `OpenSearchBulkSinkExt.java` | Add payload-aware vector bulk processor |
|
||||
| `ElasticSearchBulkSinkExt.java` | Same as above for ES |
|
||||
| `RecreateWithEmbeddings.java` | Per-entity promotion for vector index |
|
||||
|
||||
### New Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `StageStatsTracker.java` | Clean stats tracking per stage |
|
||||
| `StageCounter.java` | Atomic counter for success/failed/warnings |
|
||||
| `VectorBulkProcessor.java` | Payload-aware bulk processor for vectors |
|
||||
| `PipelineStats.java` | Stats data model |
|
||||
|
||||
### Implementation Order
|
||||
|
||||
1. DB migrations (add columns, backward compatible)
|
||||
2. `StageStatsTracker` and `StageCounter` (new code, no breaking changes)
|
||||
3. Update `SearchIndexExecutor` to use new tracker
|
||||
4. Update `DefaultRecreateHandler` for per-entity promotion
|
||||
5. Add `VectorBulkProcessor` in Collate
|
||||
6. Update `OpenSearchBulkSinkExt` for vector stats
|
||||
7. Remove old reconciliation code
|
||||
@@ -0,0 +1,358 @@
|
||||
# Bulk Recursive Deletion Redesign (Service-Level, At Scale)
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-06-22
|
||||
**Supersedes / replaces:** the FQN-prefix approach on branch `mohit/35dc-improve-deletion`
|
||||
**Related code:** `EntityRepository`, `PrefixDeletionService`, `CollectionDAO.RelationshipDAO`, `HierarchicalLockManager`, `DeletionLockDAO`, `SearchRepository`
|
||||
|
||||
## Overview
|
||||
|
||||
Hard-deleting a service (e.g. a `databaseService` with 100k–1M descendant tables/columns) currently takes **2–6 hours** and frequently leaves **orphaned `entity_relationship` rows** behind. This document specifies a deletion subsystem that is **fast** (set-based, not per-entity), **orphan-free by construction** (deletes by entity **id-set**, immune to NULL hashes and renames), **atomic and resumable** (chunked transactions with a durable job/tombstone), and **safe under concurrent ingestion** (creates under a deleting subtree are rejected).
|
||||
|
||||
The design deliberately reuses primitives that **already exist** in the codebase rather than inventing new SQL.
|
||||
|
||||
## Implementation status against current `main` (2026-06-22)
|
||||
|
||||
This doc was first written against an April snapshot + the `mohit/35dc-improve-deletion`
|
||||
prefix-deletion branch. **Latest `main` has since converged on most of this design's core**, which
|
||||
materially narrows the remaining work. Verified against `EntityRepository` on `main`:
|
||||
|
||||
- ✅ **Per-level, per-type batched deletion** (`bulkHardDeleteSubtree` / `bulkSoftDeleteSubtree` /
|
||||
`bulkRestoreSubtree`, dispatched from `deleteChildren`). Replaces the old per-entity
|
||||
`cleanup()`-per-descendant transaction loop — the comments cite ~120k round-trips collapsed for a
|
||||
12k-table DB.
|
||||
- ✅ **Relationships deleted by entity id-set** via `RelationshipDAO.batchDeleteRelationships(ids,
|
||||
type)` (`DELETE … WHERE fromId IN(…) … OR toId IN(…)`), i.e. the NULL-immune key this design
|
||||
argued for — **not** fqnHash prefixes. The prefix-branch approach is obsolete.
|
||||
- ✅ Entity-row deletes chunked at `MAX_IN_LIST_CHUNK_SIZE = 30_000` (`EntityDAO.deleteByIds`).
|
||||
- ✅ Both `tag_usage` sides cleaned (`deleteTagLabelsByTargetPrefix` + `deleteTagLabelsByFqn`),
|
||||
cache invalidation + NotFoundCache markers for every deleted descendant, per-entity `postDelete`
|
||||
+ `deleteFromSearch`.
|
||||
|
||||
**Remaining gaps (what this design still drives):**
|
||||
|
||||
1. **Bounded memory — DONE in this change.** `bulkHardDeleteSubtree` loaded an entire tree level
|
||||
(`loadForBulk(ids, ALL)`) before deleting — a 1M-table service OOMs on the load. Now the level is
|
||||
processed in `BULK_HARD_DELETE_TXN_CHUNK_SIZE`-sized chunks (load → recurse children → purge),
|
||||
bounding peak heap to ~chunk × tree-depth hydrated entities. (Soft-delete / restore share the same
|
||||
ceiling and remain a follow-up.)
|
||||
2. **Per-chunk transaction — follow-up.** The chunk purge is still per-DAO-call autocommit (matching
|
||||
prior behavior). Wrapping each chunk in one `flushInOneTransaction` (from PR #28675) gives
|
||||
per-chunk atomicity + deadlock-retry; the deletes are idempotent so it is safe to add.
|
||||
3. **Concurrency race — deferred (separate "accuracy" PR).** The lock gate is **dormant on main**:
|
||||
`LockManagerInitializer.initialize()` has no caller, so `lockManager` is null and
|
||||
`checkModificationAllowed` is a no-op; even if enabled, `loadLockedFqnPrefixes()` is still a stub
|
||||
and there is no stale-lock reaper (`cleanupStaleLocks()` has no caller). Closing the race safely
|
||||
requires: wire startup init, implement `loadLockedFqnPrefixes` via `DeletionLockDAO` (cached),
|
||||
and schedule the reaper — otherwise a crashed delete blocks ingestion under the prefix forever.
|
||||
4. **Per-entity satellite + search loops — DONE in this change (the headline speedup).** The bulk
|
||||
recursion still ran, *per descendant*: `field_relationship.deleteAllByPrefix`,
|
||||
`tagUsageDAO.deleteTagLabelsByTargetPrefix` + `deleteTagLabelsByFqn`, `usageDAO.delete(id)`, and
|
||||
`deleteFromSearch(entity)` (which serializes a snapshot + submits a lane task each). For an
|
||||
N-entity subtree that is ~3N satellite round-trips + N search dispatches — which a local 100k
|
||||
benchmark showed dominated the wall-clock (see below). Fixed via a capability field
|
||||
`descendantsCoveredByAncestorCascade` (declared on `EntityRepository`, default `false`, set in
|
||||
the constructor like `supportsSearch`; enabled across all service-rooted asset trees —
|
||||
database (`Database`/`DatabaseSchema`/`Table`/`StoredProcedure`), dashboard
|
||||
(`Dashboard`/`Chart`/`DashboardDataModel`), messaging (`Topic`), pipeline (`Pipeline`),
|
||||
mlmodel (`MlModel`), search (`SearchIndex`), storage (`Container`), drive
|
||||
(`Directory`/`File`/`Spreadsheet`/`Worksheet`), and api (`APICollection`/`APIEndpoint`)). When
|
||||
set, the bulk path:
|
||||
- **skips per-entity `deleteFromSearch`** — the root's own `deleteFromSearch` already fires
|
||||
`SearchRepository.deleteOrUpdateChildren`, which deletes *all* descendant docs in one
|
||||
delete-by-query by `service.id` / parent-id;
|
||||
- **skips per-entity `field_relationship` + `tag_usage`** — the root's `cleanup()` already
|
||||
prefix-deletes the whole FQN subtree in one statement each;
|
||||
- **batches `usage`** by id-set (`deleteByIds` IN-list per chunk; usage is id-keyed so the root's
|
||||
FQN-prefix cleanup doesn't cover descendants).
|
||||
|
||||
Default `false` keeps flat-FQN / non-cascade-covered types (Team, User, Role, Policy, …) and
|
||||
reference types whose deletion scrubs refs out of surviving docs (Tag, GlossaryTerm, Domain,
|
||||
DataProduct, TestSuite — `deleteOrUpdateChildren` `updateChildren` cases) on the safe per-entity
|
||||
path. Enabling the api tree also required adding `Entity.API_SERVICE` to the `service.id` case in
|
||||
`SearchRepository.deleteOrUpdateChildren` (it had fallen through to the default `apiService.id`
|
||||
branch, a field api docs don't carry — so the cascade had silently skipped api children).
|
||||
|
||||
### Measured result (local Docker, 1 GB heap, MySQL + Elasticsearch)
|
||||
|
||||
100k tables under one schema (one service → db → schema), recursive hard-delete via
|
||||
`DELETE /v1/services/databaseServices/{id}?hardDelete=true&recursive=true`:
|
||||
|
||||
| | baseline (per-level batched, current `main` + bounded-memory) | + per-entity satellite/search batching |
|
||||
|---|---|---|
|
||||
| **wall-clock** | **1643 s (~27 min)** | **59 s** (~28× faster, ~1700 tbl/s) |
|
||||
| **peak heap** | 546 MB (median 394) | 493 MB (mean 384) — no OOM at 1 GB |
|
||||
| **correctness** | subtree gone | subtree gone; `entity_relationship` 100088 → 86 (all 100,002 subtree edges removed, no orphans); ES table docs for the service = 0 (search clean) |
|
||||
|
||||
Extrapolated: ~1M tables would go from the reported multi-hour range to **~10 min** at this rate.
|
||||
|
||||
Still open as follow-ups: per-chunk `flushInOneTransaction` atomicity (#2); the concurrency race
|
||||
(#3); extending the capability flag to the other service trees; and applying the same skips to
|
||||
`bulkSoftDeleteSubtree` / `bulkRestoreSubtree`.
|
||||
|
||||
## Problem Statement & Root Causes
|
||||
|
||||
### Why it is slow (2–6 hours)
|
||||
|
||||
The cascade walks the tree one entity at a time:
|
||||
|
||||
`EntityResource.deleteByIdAsync → EntityRepository.delete → deleteChildren → batchDeleteChildren → processDeletionBatch → cleanup()`
|
||||
|
||||
The dominant cost is **N independent transactions**: `cleanup()` (`EntityRepository.java:3763`) wraps *each* entity's full cleanup in its own `Jdbi.inTransaction(...)`, and the recursion re-queries children at each level (batches of 50, threshold 100). For ~1M descendants this is millions of transactions + millions of per-entity search calls + millions of per-entity change events. Transaction overhead — not row volume — is the wall.
|
||||
|
||||
### Why relationships are orphaned
|
||||
|
||||
1. **Cross-cutting edges are never reached by the walk.** The recursive walk follows `CONTAINS`/`PARENT_OF` edges. Non-hierarchical edges (lineage `UPSTREAM`, ownership `OWNS`, `HAS` domain, `FOLLOWS`, dataProduct, tags) that point *into* the subtree from outside are only cleaned if the in-subtree endpoint is individually reached and `cleanup()` runs `deleteAll(id, type)` for it. Any entity missed (see #2) leaves its edges dangling.
|
||||
2. **The concurrency window.** During the multi-hour walk, ingestion can re-create children that the walk already passed. Those new entities — and their relationships — survive as orphans. The `HierarchicalLockManager` was introduced to stop this but its create-path gate is **not actually wired** (see Appendix B).
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Speed:** delete a 1M-entity service in **minutes**, bounded by *hundreds* of SQL statements, not millions of transactions.
|
||||
2. **Orphan-free by construction:** after deletion, **zero** `entity_relationship` / `field_relationship` / `tag_usage` / `entity_extension` / time-series / feed rows reference any deleted entity — regardless of relationship type, hash population, or rename history.
|
||||
3. **Atomic & resumable:** a crash/restart mid-delete never leaves a *live* entity stripped of its dependencies; the operation resumes and completes.
|
||||
4. **Concurrency-safe:** ingestion/create under a subtree being deleted is rejected (or queued), closing the orphan race.
|
||||
5. **Faithful side-effects:** change events, audit log, search index, RDF, alerts/governance, and per-type cleanup behave as if each entity were deleted.
|
||||
6. **Bounded blast radius:** the bulk path is available only on hierarchical, FQN-nesting roots.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Changing **soft-delete** semantics. Soft delete keeps the existing tree-walk (it must preserve relationships for restore).
|
||||
- Adding database-level foreign keys. `entity_relationship` references ~60 `*_entity` tables polymorphically via `(fromId, fromEntity)`; `ON DELETE CASCADE` is not expressible, and the schema is FK-free by design.
|
||||
- A general distributed job framework. We reuse the existing async executor + `entity_deletion_lock` table.
|
||||
|
||||
## Core Design Decision: delete by **id-set**, not by FQN-hash prefix
|
||||
|
||||
The single most important decision is the **deletion key**.
|
||||
|
||||
- **FQN-hash prefix is the wrong key.** `entity_relationship.fromFQNHash/toFQNHash` is populated on only a handful of `CONTAINS` code paths; bulk-ingestion (`bulkInsertTo`) and every legacy/lineage/ownership/domain `addRelationship` overload write **NULL**. NULL never matches `= :hash` or `LIKE :hash.%`, so those rows survive. A one-time backfill cannot fix rows created *after* it. Hashes also go stale on rename and are blind to flat-FQN hierarchies (sub-teams).
|
||||
- **The entity id-set is the right key.** `entity_relationship.fromId`/`toId` (and the `id` column of every entity-keyed table) are **always populated** and **stable across renames**. Deleting `WHERE fromId IN (subtree) OR toId IN (subtree)` catches every edge touching the subtree, whatever its type or hash state.
|
||||
|
||||
**Rule:** delete by **id-set** wherever a table stores entity ids; delete by **bounded fqnHash prefix** (`hash + "." + %`, plus exact-match for the root) only for satellite tables that are *keyed by FQN hash* and have no id column.
|
||||
|
||||
| Table | Key column(s) | Deletion strategy |
|
||||
|---|---|---|
|
||||
| `<type>_entity` | `id` | id-set, chunked |
|
||||
| `entity_relationship` | `fromId`, `toId` | **id-set** via `batchDeleteFrom`+`batchDeleteTo` per type (NULL-immune) |
|
||||
| `entity_extension` | `id` | id-set, chunked |
|
||||
| `entity_usage` | `id` | id-set, chunked |
|
||||
| `thread_entity` (feed) | `entityId` (about) | id-set via `findByEntityIds` → delete threads |
|
||||
| `field_relationship` | `fromFQNHash`, `toFQNHash` | bounded fqnHash prefix (already `.`-anchored) |
|
||||
| `tag_usage` | `targetFQNHash` **and** `tagFQNHash` (source) | target by prefix; **source** by `deleteTagLabelsByFqn` per deleted tag/term |
|
||||
| `*_time_series` (profiler, test results, query cost, etc.) | `entityFQNHash` | bounded fqnHash prefix |
|
||||
| search index (ES/OS) | doc `fullyQualifiedName` | bounded prefix delete-by-query + exact root + reverse-reference scrub |
|
||||
| RDF triple store | entity IRI | bulk SPARQL delete by subtree |
|
||||
|
||||
Existing primitives we reuse: `RelationshipDAO.batchDeleteRelationships(ids, type)` / `batchDeleteFrom` / `batchDeleteTo` (`CollectionDAO.java:2409-2433`, chunked), `EntityTimeSeriesDAO.deleteByFqnHashPrefix`, `FieldRelationshipDAO.deleteAllByPrefix`, `FeedDAO.findByEntityIds`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two cooperating pieces, both backed by the existing `entity_deletion_lock` table (used as the durable job record):
|
||||
|
||||
```
|
||||
DELETE /services/.../prefix/{id}
|
||||
│ (synchronous, O(1))
|
||||
▼
|
||||
┌─────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ 1. Acquire tombstone │ │ BulkDeletionExecutor │
|
||||
│ (DELETE_IN_PROGRESS │ │ (async, resumable) │
|
||||
│ lock on root FQN) │──────▶ │ - collect id-set (cursor) │
|
||||
│ 2. Persist job record │ │ - per-chunk TXN: deps+rows │
|
||||
│ 3. Return 202 + jobId │ │ - bulk hooks per type │
|
||||
└─────────────────────────┘ │ - search/RDF/events │
|
||||
│ │ - update cursor in lock row │
|
||||
▼ │ - release lock on success │
|
||||
create/update path └──────────────────────────────┘
|
||||
checks tombstone → 409 ▲
|
||||
│ │ resume on restart
|
||||
└──────────────────────────────────────┘ (StaleLockReaper / boot)
|
||||
```
|
||||
|
||||
### 1. The tombstone closes the race (synchronously, before any work)
|
||||
|
||||
On request, **before** collecting any ids, acquire a `DELETE_IN_PROGRESS` lock on the root FQN (`HierarchicalLockManager.acquireDeletionLock`, which already writes `entity_deletion_lock`). This is the gate; deletion proceeds only if the lock is acquired (no best-effort “continue without lock”).
|
||||
|
||||
The create/update/bulk-upsert paths already *call* `checkModificationAllowed(...)`. The fix is to make that check actually consult the DB:
|
||||
|
||||
- **Wire the gate.** Route `checkModificationAllowed` through the already-correct, **currently-callerless** `checkModificationAllowedByFqn(fqn)` (`HierarchicalLockManager.java:169`), which runs `findParentLocks` (`entityFqn = :fqn OR :fqn LIKE entityFqn || '.%'`). For the bulk path, batch it. Replace the dead `loadLockedFqnPrefixes()` stub (returns `new HashSet<>()`, `:312`) with a real `DeletionLockDAO` query, cached in the existing Caffeine cache (~30s TTL, invalidated on acquire/release) for the hot ingestion path.
|
||||
|
||||
Result: any insert/upsert under a deleting prefix is rejected with `EntityLockedException` → the snapshot-then-orphan race is closed.
|
||||
|
||||
### 2. The executor: collect → chunked-transactional purge → finalize
|
||||
|
||||
```
|
||||
bulkDelete(root):
|
||||
job = lock row for root (DELETE_IN_PROGRESS), with progress cursor in metadata
|
||||
|
||||
# Phase A — collect (read-only, resumable). FQN-hash prefix is fine HERE:
|
||||
# we only use it to FIND descendants; we DELETE by id.
|
||||
idsByType = {}
|
||||
for type in fqnHashKeyedTypes(): # skip flat-FQN types
|
||||
ids = dao(type).findIdsByFqnHashPrefix(hash(root.fqn)) # + the root id
|
||||
if ids: idsByType[type] = ids
|
||||
totalIds = flatten(idsByType) + root.id
|
||||
|
||||
# Phase B — purge in chunks; EACH CHUNK IS ONE TRANSACTION.
|
||||
for chunk in partition(totalIds, CHUNK=25_000): # tune per engine
|
||||
inTransaction:
|
||||
# satellite tables keyed by FQN hash for entities in this chunk
|
||||
# (or once up-front, see "scaling" note)
|
||||
relationshipDAO.batchDeleteFrom(chunk, type) / batchDeleteTo(chunk, type) # id-set
|
||||
entityExtensionDAO.deleteBatch(chunk)
|
||||
usageDAO.deleteBatch(chunk)
|
||||
feedRepository.deleteByAboutBatch(chunk)
|
||||
dao(type).deleteBatch(chunk) # entity rows — CHUNKED (≤ 50k)
|
||||
job.cursor = advance(chunk); persist(job) # durable progress
|
||||
|
||||
# FQN-hash-keyed satellites (bounded prefix, one pass — no id chunking needed)
|
||||
fieldRelationshipDAO.deleteAllByPrefix(root.fqn)
|
||||
for type: timeSeriesDAO(type).deleteByFqnHashPrefix(hash(root.fqn))
|
||||
tagUsageDAO.deleteTagLabelsByTargetPrefix(root.fqn) # target side
|
||||
# source side: for each deleted tag/glossaryTerm id → deleteTagLabelsByFqn(fqn)
|
||||
|
||||
# Phase C — per-type bulk hooks (replaces dropped cleanup()/preDelete/postDelete)
|
||||
for type: repo(type).bulkCleanup(idsByType[type]) # see "Per-type side-effects"
|
||||
|
||||
# Phase D — finalize
|
||||
searchRepo.deleteSubtree(root) # anchored prefix + root + reverse scrub
|
||||
rdf.deleteSubtree(root) # bulk SPARQL
|
||||
changeEventDAO.insert(ENTITY_DELETED for root) # + summary count
|
||||
invalidateCache(ALL deleted ids) # not just root
|
||||
releaseDeletionLock(root)
|
||||
websocket: COMPLETED (or FAILED with detail on any aggregated error)
|
||||
```
|
||||
|
||||
Key properties:
|
||||
|
||||
- **Atomicity at chunk granularity.** Dependency rows and entity rows **for the same id chunk** commit together. A crash leaves a clean *prefix* of fully-deleted chunks; never a live entity with destroyed dependencies. (Contrast: the prefix PR deletes *all* dependency tables first and *then* entity rows, with no transaction — a crash in between corrupts the whole subtree.)
|
||||
- **Resumability.** The cursor lives in the lock row (`entity_deletion_lock.metadata` JSON). On restart, a reaper (or boot scan) finds `DELETE_IN_PROGRESS` locks and **resumes from the cursor**. Re-running a chunk is idempotent (`DELETE … WHERE id IN` of already-gone ids is a no-op).
|
||||
- **No swallowed fatal errors.** Per-chunk failures are retried with backoff; unrecoverable failures mark the job `FAILED`, send the FAILED websocket/notification, and leave the lock for the reaper — the API never reports “completed” on partial failure.
|
||||
|
||||
## Per-Type Side-Effects (the dropped `cleanup()` work)
|
||||
|
||||
Raw `deleteBatch` skips `entitySpecificCleanup`, `preDelete` guards, and `postDelete`. Introduce one bulk hook on `EntityRepository`:
|
||||
|
||||
```java
|
||||
protected void bulkCleanup(List<UUID> ids) { /* default no-op */ }
|
||||
```
|
||||
|
||||
Overrides (mirroring today's per-entity logic, but set-based):
|
||||
|
||||
- **TestCase / TestSuite:** delete `data_quality_data_time_series` results.
|
||||
- **IngestionPipeline / Workflow / WorkflowDefinition:** delete external pipelines/secrets — **batch** the external calls; do not `find(id)` per entity in a loop (the prefix PR re-introduced N+1 here).
|
||||
- **Pipeline / StoredProcedure:** `deleteLineageBySourcePipeline` (lineage edges keyed by JSON `$.pipeline.id`, which an id-IN on `fromId/toId` will **not** catch).
|
||||
- **Team:** sub-team reparenting / membership; **Role/Team:** `PolicyConditionUpdater` SpEL cleanup.
|
||||
- **Tag / DataProduct:** the `IN_REVIEW` reviewer guard must be **checked**, not bypassed (governance). Also source-side `tag_usage` (`deleteTagLabelsByFqn`).
|
||||
- **Base `postDelete`:** RDF removal via a **bulk** SPARQL delete for the subtree (not a per-entity loop).
|
||||
|
||||
`preDelete` system-protection guards (system policies/roles, the `organization` team) must run against the **root** and be enforced before Phase B.
|
||||
|
||||
## Endpoint Scope
|
||||
|
||||
Expose the bulk path **only** on hierarchical, FQN-nesting roots: `DatabaseService`, `Database`, `DatabaseSchema`, and the analogous `*Service`/container roots (dashboard, pipeline, messaging, mlmodel, storage, search, api, drive). **Do not** expose it on flat-FQN, `nameHash`-keyed types (`Team`, `User`, `Role`, `Persona`, `TestDefinition`, `Tag`, `Glossary`, `Domain`, `Policy`): for those `findIdsByFqnHashPrefix` returns `List.of()`, so a bulk delete degenerates to a raw root delete that skips required cleanup — strictly worse than the existing recursive path. Those types keep the existing `cleanup()` path.
|
||||
|
||||
## Search Index Strategy
|
||||
|
||||
The catalog (DB) is the source of truth; search must converge without divergence:
|
||||
|
||||
1. **Anchor the prefix.** Delete-by-query uses `fullyQualifiedName` prefixed with `root.fqn + Entity.SEPARATOR` (the `.`) so deleting `prod` does **not** wipe `prod_backup`. Delete the root doc separately by exact FQN/id. (The prefix PR passed the raw, unanchored `rootFqn`.)
|
||||
2. **Reverse-reference scrub.** Run `deleteOrUpdateChildren`-equivalent for every deleted entity that may be *referenced inside other docs* (tags/terms/domain/dataProduct/owners/lineage on surviving assets), not just the root.
|
||||
3. **Child docs.** Column-level / field-level docs under the deleted entities are covered by the same anchored prefix.
|
||||
4. **Ordering & idempotency.** Search delete runs in Phase D after DB purge; it is idempotent and safe to re-run on resume.
|
||||
|
||||
## Change Events, Audit, Governance
|
||||
|
||||
The bulk path must not silently skip the eventing pipeline:
|
||||
|
||||
- Emit a real `ENTITY_DELETED` `change_event` for the **root** (so `EventSubscription` alerts, the audit log, and governance delete-workflows fire), carrying a **summary** (descendant counts by type, jobId).
|
||||
- For very large subtrees, do **not** emit one event per descendant (that re-creates the millions-of-events cost). The root “subtree deleted” event + counts is the contract; document this as an intentional change from per-entity events. Consumers that need per-entity granularity subscribe to the job-summary payload.
|
||||
|
||||
## Relationship to PR #28675 (one-transaction write path) — build on it, don't reinvent
|
||||
|
||||
PR #28675 ("perf: one-transaction flush + async indexing write path", merged to `main` 2026-06-05) is **not** about deletion, but it landed exactly the infrastructure this design needs. **This deletion branch was last synced with `main` on 2026-04-13, so it does not yet contain #28675** — step 0 of any implementation is to rebase/merge `main`. The pieces and how the deletion path reuses each:
|
||||
|
||||
| #28675 primitive (location) | What it does for writes | How bulk deletion reuses it |
|
||||
|---|---|---|
|
||||
| `EntityRepository.flushInOneTransaction(Runnable)` (`EntityRepository.java:5029`) + `DeadlockRetry.execute(Supplier)` (`jdbi3/DeadlockRetry.java`) | Wraps the create/update/patch flush in `DeadlockRetry.execute(() -> jdbi.inTransaction(...))` — retry OUTER (fresh handle per replay), `inTransaction` INNER; collapses 5–7 commits → 1, atomic + deadlock-replay-safe | **The per-chunk atomic boundary in Phase B.** Each id-chunk purge runs inside `flushInOneTransaction` instead of a hand-rolled transaction. `DeadlockRetry` replays the whole chunk body — and `DELETE … WHERE id IN (…)` is idempotent, so replay is safe. Do not invent a new retry/transaction wrapper. |
|
||||
| Deferred external-side-effect collectors: `DEFERRED_CACHE_INVALIDATIONS` + `beginCacheInvalidationDeferral`/`drainCacheInvalidations`; `RdfTagUpdater.beginDeferral`; `LineageUtil.drainLineageDeferred`; `SearchRepository.beginSearchWriteDeferral`/`drainSearchWriteDeferred` | Captures cache-L2 invalidation, RDF/SPARQL, lineage-ES and search writes *during* the flush and drains them **post-commit**, so the held DB connection makes **zero network round trips** | **Solves "no I/O while holding the delete transaction."** Inside each chunk transaction, only DB deletes run; record a cache invalidation for **every deleted descendant id** (fixes the only-root-invalidated bug) into `DEFERRED_CACHE_INVALIDATIONS`, and defer search/RDF deletes. Drain per chunk on the deletion worker thread (the "request thread" in #28675 just means "the thread that opened the scope"). |
|
||||
| `EntityLifecycleEventDispatcher.onEntityDeleted` → `OrderedLaneExecutor` (per-entity-id lanes) → `SearchIndexHandler.onEntityDeleted` → `searchRepository.deleteEntityIndex`, failures → **`SearchIndexRetryQueue`** durable outbox | Entity-delete search/RDF/lineage propagation is already **async + per-entity-ordered + durable** | **Replaces the prefix PR's synchronous, best-effort `cleanSearchIndex`** (which silently diverges on ES failure). Route search/RDF cleanup through this hub so it inherits durable retry. |
|
||||
| `SearchRepository.deleteEntityByFQNPrefix(EntityInterface)` (`SearchRepository.java:2573`) + `SearchIndexRetryQueue.failureReason(...)` | Prefix delete-by-query for search docs, with durable retry on failure (already used for `Entity.PAGE`) | The subtree search cleanup. **Note the signature differs** from this branch's `deleteByEntityTypeFqnPrefix(type, fqn)` — resolve the merge in favor of `main`'s durable variant; anchor with `Entity.SEPARATOR` (see Search Index Strategy). |
|
||||
| Consistency contract: GET-by-id/name real-time (DB + **synchronous** cache write-through post-commit); `/search`, RDF, lineage **eventually consistent** with durable retry | — | **Adopt verbatim for delete.** The tombstone is the delete-time analog of #28675's synchronous cache write-through: it makes the subtree invisible/locked at the DB+cache layer *immediately*, while search/RDF converge asynchronously and durably. No new contract to invent. |
|
||||
|
||||
### The one adaptation that matters: granularity
|
||||
|
||||
#28675's dispatcher is **per-entity** — one `OrderedLaneExecutor` lane task per entity id (correct for writes, where per-entity ordering prevents a stale create clobbering a newer update). A bulk delete must **not** fan out `onEntityDeleted` across 1M descendants — that re-creates the millions-of-tasks cost this redesign exists to kill. Instead:
|
||||
|
||||
- Enqueue **one** subtree search delete-by-query (`deleteEntityByFQNPrefix` on the anchored root) as a single durable task after the DB purge — not N per-entity deletes.
|
||||
- Emit **one** root `ENTITY_DELETED` summary event through the dispatcher (descendant counts by type), not one per descendant.
|
||||
- Per-entity lane ordering is unnecessary here: the subtree is tombstone-locked, so no concurrent index-write for a deleted id can be in flight to race the delete.
|
||||
|
||||
Net: deletion reuses #28675's transaction wrapper, deferred-collector discipline, durable search outbox, and consistency contract, but operates at **subtree granularity** on the propagation side.
|
||||
|
||||
## Scaling Analysis
|
||||
|
||||
Let **N** = descendant count, **T** = number of entity types present, **C** = chunk size.
|
||||
|
||||
- **Statements:** collection = `T` SELECTs; purge = `~ceil(N/C) × (tables-per-chunk)`; satellites = `O(T)` prefix deletes. For N=1M, C=25k → ~40 chunks → low **hundreds** of statements total. (Baseline: ~N transactions.)
|
||||
- **Transactions:** `~ceil(N/C)` (≈40) vs **~N** (≈1M). This is the headline win.
|
||||
- **Memory:** the id-set is materialized once. 1M UUIDs ≈ tens of MB as `UUID`/`String`. Acceptable, but **stream the collection cursor-style** (page by `fqnHash` ranges) for >2–3M to bound heap; the cursor already supports paging.
|
||||
- **IN-list limits:** entity-row and id-keyed deletes **must be chunked ≤ ~50k** to stay under PostgreSQL's 65,535 bind-parameter limit and MySQL `max_allowed_packet`. (The prefix PR left `EntityDAO.deleteBatch` un-chunked — a deterministic failure at the exact scale it targets.)
|
||||
- **Index usage:** id-set deletes on `entity_relationship` use the existing `from_index (fromId,relation)` / `to_index (toId,relation)` on **both** engines — no dependency on the new fqnHash LIKE indexes (which on default-locale PostgreSQL require `varchar_pattern_ops` to serve `LIKE 'prefix%'` at all). Keep id-set off the hottest table's LIKE path entirely.
|
||||
- **Lock/bloat:** per-chunk transactions keep lock duration and WAL/undo growth bounded; a single giant transaction over 1M rows would bloat and risk lock timeouts.
|
||||
- **Collection note:** for FQN-hash-keyed satellites we can either delete per-chunk by FQN (more queries) or once up-front by bounded prefix (fewer queries, but those rows are deleted before the corresponding entity rows). Prefer the **one-pass bounded-prefix** delete *inside the final chunk's transaction window* or as an explicitly-resumable Phase, since these tables have no cross-entity integrity that a mid-run crash could corrupt beyond what the cursor already protects.
|
||||
|
||||
## Failure Handling & Resumability
|
||||
|
||||
| Failure | Behavior |
|
||||
|---|---|
|
||||
| Chunk SQL error (timeout, deadlock) | retry chunk w/ backoff; chunk TXN rolled back, cursor not advanced |
|
||||
| Unrecoverable chunk error | job → `FAILED`, FAILED notification, lock retained for reaper/operator |
|
||||
| JVM restart mid-job | `DELETE_IN_PROGRESS` lock + cursor survive; **StaleLockReaper** (Quartz) resumes from cursor |
|
||||
| Abandoned/stale lock | reaper (wire the existing, **uncalled** `cleanupStaleLocks()` / `STALE_LOCK_CHECK_INTERVAL_MINUTES=5`) resumes or, past TTL, force-releases |
|
||||
| Concurrent second delete of same root | rejected — lock already held |
|
||||
| Concurrent ingestion under root | rejected with `EntityLockedException` (gate wired) |
|
||||
|
||||
## Observability
|
||||
|
||||
- Job record (`entity_deletion_lock` + metadata): `phase`, `cursor`, `deletedByType`, `startedAt`, `lastHeartbeat`, `error`.
|
||||
- Metrics: rows deleted per table, chunk latency, total duration, retries; expose via `getLockStatistics()` and a status endpoint (`GET …/prefix/{jobId}/status`).
|
||||
- Websocket progress events (already used by the prefix PR) reporting `% complete` from the cursor.
|
||||
|
||||
## Migration & Rollout
|
||||
|
||||
0. **Phase 0a (rebase):** merge/rebase `main` into the branch to pick up **PR #28675** (`flushInOneTransaction`, `DeadlockRetry`, deferred collectors, `OrderedLaneExecutor`, `SearchIndexRetryQueue`, `deleteEntityByFQNPrefix`). Resolve the heavily-overlapping `EntityRepository` changes and the `SearchRepository` prefix-delete signature in favor of `main`'s durable variants. Everything below builds on this.
|
||||
1. **Phase 0b (correctness now / quick wins):**
|
||||
- Add id-set relationship sweep (`batchDeleteRelationships(ids, type)`) to the purge — closes the entire NULL-hash orphan class with an existing primitive.
|
||||
- Chunk `EntityDAO.deleteBatch` at 50k.
|
||||
- Wrap each id-chunk purge in `flushInOneTransaction` (from #28675); stop reporting “completed” on partial failure.
|
||||
- Anchor the search prefix with `.`; restrict the endpoint to hierarchical roots only.
|
||||
2. **Phase 1 (race):** wire `checkModificationAllowed` → real DB gate (`checkModificationAllowedByFqn` / `loadLockedFqnPrefixes`), with Caffeine caching; tombstone before collection; acquire-or-abort (no best-effort).
|
||||
3. **Phase 2 (durability):** cursor in lock metadata + `StaleLockReaper` (Quartz) for resume; status endpoint.
|
||||
4. **Phase 3 (fidelity):** `bulkCleanup` overrides; route search/RDF cleanup through `EntityLifecycleEventDispatcher` + `SearchIndexRetryQueue` (one subtree task, not per-entity); record deferred cache invalidations for all deleted ids; reverse-reference search scrub; root `change_event` with summary.
|
||||
5. **Deprecate / keep** the `fromFQNHash`/`toFQNHash` columns: they are **no longer required** for deletion (id-set replaces them). Decide separately whether to keep them for other features; if dropped, revert the v1.13.0 backfill (which is itself a perf risk — see Appendix A).
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Integration tests that **fail without the fix**:
|
||||
|
||||
1. **NULL-hash orphan:** bulk-ingest a service via the batch import path, prefix-delete it, assert **zero** surviving `entity_relationship` rows referencing any deleted id (proves id-set ≠ fqnHash).
|
||||
2. **Cross-cutting edges:** add lineage/ownership/domain/follows from *outside* the subtree into it; after delete, assert all such edges are gone and the *external* entities survive.
|
||||
3. **Crash atomicity:** inject a failure mid-purge; assert the subtree is either fully intact or fully gone — never a live entity with missing dependencies — and that resume completes it.
|
||||
4. **Postgres scale:** a service with >65,535 descendants of one type deletes successfully (no bind-param failure).
|
||||
5. **Concurrency:** insert-during-delete under the root is rejected; insert outside the root succeeds.
|
||||
6. **Search convergence:** sibling docs (`prod_backup`) survive; subtree docs and reverse references are gone.
|
||||
7. **Governance:** an `IN_REVIEW` tag inside the subtree blocks/honors the reviewer guard rather than being silently bypassed.
|
||||
8. **Benchmark:** assert wall-clock for a seeded large service is within target (minutes), tracked over time.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **A. FQN-hash prefix delete (the current PR).** Rejected as the primary mechanism: NULL-hash blindness re-creates orphans, stale on rename, blind to flat hierarchies, sibling collisions in search. The prefix is still useful for *collecting* descendants and for FQN-keyed satellite tables.
|
||||
- **B. FK `ON DELETE CASCADE`.** Rejected: polymorphic `(fromId, fromEntity)` references prevent DB-level cascade; FK-free schema by design; huge migration to add/validate FKs on existing data.
|
||||
- **C. Soft-tombstone + scheduled GC purge.** Strong for perceived latency (API is O(1), GC purges later) and folds naturally into this design’s tombstone. Adopted as an *option*: the tombstone already makes the subtree invisible/locked; whether the purge runs immediately (this doc’s default) or via a GC app is a deployment choice. The id-set purge mechanics are identical either way.
|
||||
|
||||
## Appendix A — Migration perf risk in the current PR
|
||||
|
||||
`MigrationUtil.backfillRelationshipFqnHashes` (v1.13.0) runs a correlated-subquery `UPDATE … SET fromFQNHash = (SELECT CAST(t.<hashcol> AS CHAR(768)) FROM <table> t WHERE CAST(t.id AS CHAR(36)) = entity_relationship.fromId)` once per entity type per direction (~120 statements). The `CAST(t.id AS CHAR(36))` **defeats the primary-key index** on the entity table, risking a full scan per ER row on instances with tens of millions of relationships. Since the redesign deletes by id-set, this backfill (and the columns) can be dropped. If retained, rewrite as an indexed join without casts.
|
||||
|
||||
## Appendix B — The race the current PR does not close
|
||||
|
||||
`createInternal`/`createOrUpdate`/bulk-upsert *do* call `lockManager.checkModificationAllowed(...)`, but it short-circuits on `isFqnLocked(fqn)` → `loadLockedFqnPrefixes()`, which is a stub returning `new HashSet<>()` (`HierarchicalLockManager.java:312-316`). So no FQN is ever considered locked and ingestion is never blocked. The correct, cache-free gate `checkModificationAllowedByFqn` (`:169`) exists but has **zero callers**. Wiring this (Phase 1) is the single change that makes the lock actually prevent concurrent-ingestion orphans.
|
||||
@@ -0,0 +1,357 @@
|
||||
# RDF/Apache Jena Local Development Guide
|
||||
|
||||
This guide documents how to set up RDF/Knowledge Graph support for local development with OpenMetadata and Apache Jena Fuseki.
|
||||
|
||||
## Overview
|
||||
|
||||
OpenMetadata supports RDF (Resource Description Framework) for knowledge graph capabilities using Apache Jena Fuseki as the triple store. This enables:
|
||||
- SPARQL queries against metadata
|
||||
- JSON-LD serialization of entities
|
||||
- Semantic search and graph exploration
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ OpenMetadata │ │ Apache Jena │
|
||||
│ Server (IntelliJ) │────▶│ Fuseki (Docker) │
|
||||
│ Port: 8585 │ │ Port: 3030 │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- IntelliJ IDEA with the project imported
|
||||
- MySQL or PostgreSQL running (for OpenMetadata backend)
|
||||
- Elasticsearch running (for search)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Choose the Right Startup Mode
|
||||
|
||||
The standard local Docker flow does not enable RDF or start Fuseki:
|
||||
|
||||
```bash
|
||||
cd /path/to/OpenMetadata
|
||||
./docker/run_local_docker.sh -d mysql
|
||||
```
|
||||
|
||||
For PostgreSQL-based development:
|
||||
|
||||
```bash
|
||||
./docker/run_local_docker.sh -d postgresql
|
||||
```
|
||||
|
||||
Use the RDF-specific startup script when you want the full Docker stack with Fuseki enabled:
|
||||
|
||||
```bash
|
||||
./docker/run_local_docker_rdf.sh -d mysql
|
||||
```
|
||||
|
||||
For PostgreSQL-based RDF development:
|
||||
|
||||
```bash
|
||||
./docker/run_local_docker_rdf.sh -d postgresql
|
||||
```
|
||||
|
||||
This RDF startup path starts OpenMetadata, the backing database, search, ingestion services, and Fuseki with:
|
||||
- **Port**: 3030
|
||||
- **Admin Password**: admin
|
||||
- **Dataset**: openmetadata
|
||||
- **Memory**: 2-4GB allocated
|
||||
|
||||
### Step 2: Verify Fuseki is Running
|
||||
|
||||
```bash
|
||||
# Check Fuseki health
|
||||
curl -s http://localhost:3030/$/ping
|
||||
|
||||
# Access Fuseki UI in browser
|
||||
open http://localhost:3030
|
||||
```
|
||||
|
||||
The Fuseki web UI is available at `http://localhost:3030` with credentials:
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
### Step 3: Configure IntelliJ Run Configuration
|
||||
|
||||
If you are running the full RDF Docker stack with `run_local_docker_rdf.sh`, the Docker services already receive the RDF environment variables automatically.
|
||||
|
||||
If you want to run the OpenMetadata server directly from IntelliJ while keeping Fuseki in Docker, start Fuseki separately:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/development/docker-compose.yml -f docker/development/docker-compose-fuseki.yml up -d fuseki
|
||||
```
|
||||
|
||||
If your local backend uses PostgreSQL, swap `docker-compose.yml` for `docker-compose-postgres.yml`.
|
||||
|
||||
Create or modify your IntelliJ run configuration for `OpenMetadataApplication` with these environment variables only when you want to run the OpenMetadata server directly from IntelliJ while keeping Fuseki in Docker:
|
||||
|
||||
```
|
||||
RDF_ENABLED=true
|
||||
RDF_STORAGE_TYPE=FUSEKI
|
||||
RDF_BASE_URI=https://open-metadata.org/
|
||||
RDF_ENDPOINT=http://localhost:3030/openmetadata
|
||||
RDF_REMOTE_USERNAME=admin
|
||||
RDF_REMOTE_PASSWORD=admin
|
||||
RDF_DATASET=openmetadata
|
||||
```
|
||||
|
||||
#### Setting Environment Variables in IntelliJ:
|
||||
|
||||
1. Open **Run** → **Edit Configurations**
|
||||
2. Select your `OpenMetadataApplication` configuration
|
||||
3. Click on **Modify options** → **Environment variables**
|
||||
4. Add the environment variables above (semicolon-separated or using the dialog)
|
||||
|
||||
Example environment variables string:
|
||||
```
|
||||
RDF_ENABLED=true;RDF_STORAGE_TYPE=FUSEKI;RDF_BASE_URI=https://open-metadata.org/;RDF_ENDPOINT=http://localhost:3030/openmetadata;RDF_REMOTE_USERNAME=admin;RDF_REMOTE_PASSWORD=admin;RDF_DATASET=openmetadata
|
||||
```
|
||||
|
||||
### Step 4: Start OpenMetadata Server
|
||||
|
||||
Run `OpenMetadataApplication` from IntelliJ. On startup, you should see in the logs:
|
||||
|
||||
```
|
||||
INFO [main] o.o.s.OpenMetadataApplication - RDF knowledge graph support initialized
|
||||
```
|
||||
|
||||
### Step 5: Verify RDF is Enabled
|
||||
|
||||
```bash
|
||||
# Check RDF status
|
||||
curl http://localhost:8585/api/v1/rdf/status
|
||||
|
||||
# Expected response:
|
||||
# {"enabled": true}
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Server Configuration (conf/openmetadata.yaml)
|
||||
|
||||
The RDF configuration section in `openmetadata.yaml`:
|
||||
|
||||
```yaml
|
||||
rdf:
|
||||
enabled: ${RDF_ENABLED:-false}
|
||||
baseUri: ${RDF_BASE_URI:-"https://open-metadata.org/"}
|
||||
storageType: ${RDF_STORAGE_TYPE:-"FUSEKI"}
|
||||
remoteEndpoint: ${RDF_ENDPOINT:-"http://localhost:3030/openmetadata"}
|
||||
username: ${RDF_REMOTE_USERNAME:-"admin"}
|
||||
password: ${RDF_REMOTE_PASSWORD:-"admin"}
|
||||
dataset: ${RDF_DATASET:-"openmetadata"}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `RDF_ENABLED` | Enable/disable RDF support | `false` |
|
||||
| `RDF_STORAGE_TYPE` | Storage backend type | `FUSEKI` |
|
||||
| `RDF_BASE_URI` | Base URI for RDF resources | `https://open-metadata.org/` |
|
||||
| `RDF_ENDPOINT` | Fuseki SPARQL endpoint URL | `http://localhost:3030/openmetadata` |
|
||||
| `RDF_REMOTE_USERNAME` | Fuseki admin username | `admin` |
|
||||
| `RDF_REMOTE_PASSWORD` | Fuseki admin password | `admin` |
|
||||
| `RDF_DATASET` | Fuseki dataset name | `openmetadata` |
|
||||
|
||||
### Docker Compose Configuration
|
||||
|
||||
The Fuseki container (`docker/development/docker-compose-fuseki.yml`):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
fuseki:
|
||||
image: stain/jena-fuseki:5.0.0
|
||||
container_name: openmetadata-fuseki
|
||||
ports:
|
||||
- "3030:3030"
|
||||
environment:
|
||||
- ADMIN_PASSWORD=admin
|
||||
- JVM_ARGS=-Xmx4g -Xms2g
|
||||
- FUSEKI_BASE=/fuseki
|
||||
volumes:
|
||||
- fuseki-data:/fuseki
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Once RDF is enabled, these endpoints are available:
|
||||
|
||||
### Check RDF Status
|
||||
```bash
|
||||
GET /api/v1/rdf/status
|
||||
```
|
||||
|
||||
### Get Entity as RDF
|
||||
```bash
|
||||
# Get entity in JSON-LD format (default)
|
||||
GET /api/v1/rdf/entity/{entityType}/{id}
|
||||
|
||||
# Get entity in Turtle format
|
||||
GET /api/v1/rdf/entity/{entityType}/{id}?format=turtle
|
||||
|
||||
# Get entity in RDF/XML format
|
||||
GET /api/v1/rdf/entity/{entityType}/{id}?format=rdfxml
|
||||
|
||||
# Get entity in N-Triples format
|
||||
GET /api/v1/rdf/entity/{entityType}/{id}?format=ntriples
|
||||
```
|
||||
|
||||
### Execute SPARQL Query
|
||||
```bash
|
||||
POST /api/v1/rdf/sparql
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10"
|
||||
}
|
||||
```
|
||||
|
||||
### Get Glossary Term Relationship Graph
|
||||
```bash
|
||||
# Get the full glossary term graph
|
||||
GET /api/v1/rdf/glossary/graph
|
||||
|
||||
# Filter primary terms to a glossary
|
||||
GET /api/v1/rdf/glossary/graph?glossaryId=<glossary-id>
|
||||
|
||||
# Filter to a glossary term and its direct incoming/outgoing neighbors
|
||||
GET /api/v1/rdf/glossary/graph?glossaryTermId=<glossary-term-id>
|
||||
|
||||
# Require the selected term to belong to a glossary, while still returning
|
||||
# direct cross-glossary neighbors when relationships cross glossary boundaries
|
||||
GET /api/v1/rdf/glossary/graph?glossaryId=<glossary-id>&glossaryTermId=<glossary-term-id>
|
||||
```
|
||||
|
||||
Optional query parameters:
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `glossaryId` | Filter primary terms to a glossary. |
|
||||
| `glossaryTermId` | Filter to a selected glossary term and its direct incoming/outgoing glossary-term relations. |
|
||||
| `relationTypes` | Comma-separated relation types to include. |
|
||||
| `limit` | Maximum number of terms to return. Default: `500`. |
|
||||
| `offset` | Pagination offset. Default: `0`. |
|
||||
| `includeIsolated` | Include terms without relations. Default: `true`. |
|
||||
|
||||
### Example Queries
|
||||
|
||||
```bash
|
||||
# Check if RDF is enabled
|
||||
curl -s http://localhost:8585/api/v1/rdf/status | jq
|
||||
|
||||
# Get a table entity as JSON-LD
|
||||
curl -s -H "Authorization: Bearer <token>" \
|
||||
"http://localhost:8585/api/v1/rdf/entity/table/<table-id>" | jq
|
||||
|
||||
# Execute a SPARQL query
|
||||
curl -s -X POST \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10"}' \
|
||||
http://localhost:8585/api/v1/rdf/sparql | jq
|
||||
|
||||
# Get a selected glossary term graph
|
||||
curl -s -H "Authorization: Bearer <token>" \
|
||||
"http://localhost:8585/api/v1/rdf/glossary/graph?glossaryId=<glossary-id>&glossaryTermId=<glossary-term-id>" | jq
|
||||
```
|
||||
|
||||
## Indexing Entities to RDF
|
||||
|
||||
### Manual Reindexing
|
||||
|
||||
Trigger the RDF indexing application to populate the triple store with existing entities:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer <admin-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"entities": [], "recreateIndex": true, "batchSize": 100}' \
|
||||
http://localhost:8585/api/v1/apps/trigger/RdfIndexApp
|
||||
```
|
||||
|
||||
### Automatic Indexing
|
||||
|
||||
When RDF is enabled, new entities are automatically indexed to the triple store on create/update/delete operations.
|
||||
|
||||
## Fuseki Web UI
|
||||
|
||||
The Fuseki web interface provides:
|
||||
|
||||
- **Dataset Management**: View and manage datasets at `http://localhost:3030/#/manage`
|
||||
- **SPARQL Query Interface**: Execute queries at `http://localhost:3030/#/dataset/openmetadata/query`
|
||||
- **Data Upload**: Upload RDF data at `http://localhost:3030/#/dataset/openmetadata/upload`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Fuseki Connection Issues
|
||||
|
||||
1. Verify Fuseki is running:
|
||||
```bash
|
||||
docker ps | grep fuseki
|
||||
curl http://localhost:3030/$/ping
|
||||
```
|
||||
|
||||
2. Check Fuseki logs:
|
||||
```bash
|
||||
docker logs openmetadata-fuseki
|
||||
```
|
||||
|
||||
3. Ensure the dataset exists:
|
||||
```bash
|
||||
curl -u admin:admin http://localhost:3030/$/datasets
|
||||
```
|
||||
|
||||
### RDF Not Enabled in Server
|
||||
|
||||
1. Verify environment variables are set correctly in IntelliJ
|
||||
2. Check server logs for RDF initialization message
|
||||
3. Confirm configuration in `openmetadata.yaml`
|
||||
|
||||
### SPARQL Query Errors
|
||||
|
||||
1. Check Fuseki is accessible from OpenMetadata server
|
||||
2. Verify the dataset name matches (`openmetadata`)
|
||||
3. Check Fuseki logs for query errors
|
||||
|
||||
### Reset Fuseki Data
|
||||
|
||||
To clear all RDF data and start fresh:
|
||||
|
||||
```bash
|
||||
# Stop Fuseki
|
||||
docker compose -f docker/development/docker-compose-fuseki.yml down
|
||||
|
||||
# Remove volume
|
||||
docker volume rm openmetadata_fuseki-data
|
||||
|
||||
# Restart Fuseki
|
||||
docker compose -f docker/development/docker-compose-fuseki.yml up -d
|
||||
```
|
||||
|
||||
## Full Stack with Docker Script
|
||||
|
||||
For a complete local environment with RDF enabled (server running in Docker, not IntelliJ):
|
||||
|
||||
```bash
|
||||
./docker/run_local_docker_rdf.sh -m ui -d mysql -f true
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-m ui|no-ui` - Include UI or not
|
||||
- `-d mysql|postgresql` - Database type
|
||||
- `-f true|false` - Start Fuseki for RDF support
|
||||
- `-s true|false` - Skip Maven build
|
||||
- `-x true|false` - Enable JVM debug on port 5005
|
||||
|
||||
## Related Files
|
||||
|
||||
- **Docker Compose**: `docker/development/docker-compose-fuseki.yml`
|
||||
- **Server Config**: `conf/openmetadata.yaml`
|
||||
- **RDF Java Code**: `openmetadata-service/src/main/java/org/openmetadata/service/rdf/`
|
||||
- **Ontology**: `openmetadata-spec/src/main/resources/rdf/ontology/openmetadata.ttl`
|
||||
- **RDF Index App**: `openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/rdf/RdfIndexApp.java`
|
||||
@@ -0,0 +1,518 @@
|
||||
# Multi-Node Session and WebSocket Session Management Design
|
||||
|
||||
## 1. Status
|
||||
|
||||
This document describes the current server-side session and websocket session design for
|
||||
OpenMetadata issue [#21971](https://github.com/open-metadata/OpenMetadata/issues/21971).
|
||||
|
||||
The implementation is centered on these files:
|
||||
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/security/session/SessionService.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/security/session/SessionStore.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/security/session/JdbcSessionStore.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/security/session/RedisSessionStore.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/security/session/SessionStoreFactory.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/security/JwtFilter.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/security/jwt/JWTTokenGenerator.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/socket/SocketAddressFilter.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/socket/WebSocketManager.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/OpenMetadataApplication.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/cache/CacheBundle.java`
|
||||
|
||||
## 2. Problem
|
||||
|
||||
Server-managed login state used to depend on pod-local servlet state. That breaks in a multi-node
|
||||
deployment because login, callback, refresh, logout, and websocket reconnects can land on different
|
||||
pods.
|
||||
|
||||
The key failure modes are:
|
||||
|
||||
- A login or OIDC/SAML callback starts on one node and completes on another.
|
||||
- Refresh state is unavailable when the request is routed to a different node.
|
||||
- Logout or session revocation is not visible to all nodes.
|
||||
- A websocket can remain connected after the browser session is revoked.
|
||||
- A secure websocket handshake can be spoofed if the server trusts a client-supplied `userId`.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
1. Store server-managed user sessions in a shared, authoritative backend.
|
||||
2. Support both JDBC-backed sessions and Redis-backed sessions.
|
||||
3. Bind newly issued browser access JWTs to the server-side session that issued them.
|
||||
4. Keep provider refresh tokens and OpenMetadata refresh tokens server-side.
|
||||
5. Make refresh safe under cross-node concurrency.
|
||||
6. Make logout and revocation visible to API and websocket paths.
|
||||
7. Reject websocket handshakes whose token principal, cookie session, or requested socket user do not match.
|
||||
8. Close websocket connections for revoked or expired sessions.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
1. Changing personal access token or bot token semantics.
|
||||
2. Replacing the browser-managed public OIDC flow.
|
||||
3. Making legacy JWTs without a `sessionId` claim retroactively session-bound.
|
||||
4. Guaranteeing instant cross-node websocket disconnect without pub/sub. Without pub/sub, remote
|
||||
sockets are closed by periodic validation.
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
### 5.1 Component Model
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| `SessionService` | Creates, activates, refreshes, revokes, expires, and prunes sessions. Owns the Caffeine near-cache and revocation listeners. |
|
||||
| `SessionStore` | Shared persistence contract used by both JDBC and Redis stores. |
|
||||
| `JdbcSessionStore` | Default store backed by the `user_session` table through `SessionRepository`. |
|
||||
| `RedisSessionStore` | Optional Redis store with key TTLs, per-user status indexes, and Lua compare-and-set on `version` plus index maintenance. |
|
||||
| `SessionStoreFactory` | Selects Redis when Redis cache is configured and available; otherwise uses JDBC. Refuses Redis-to-JDBC fallback when Redis is configured but unavailable. |
|
||||
| `SessionCookieUtil` | Reads, writes, validates, and clears the opaque `OM_SESSION` cookie. |
|
||||
| `JWTTokenGenerator` | Issues OpenMetadata JWTs and can include the `sessionId` claim for session-backed auth flows. |
|
||||
| `JwtFilter` | Validates JWTs. If the JWT has `sessionId`, it reloads the session from the store and requires an active, unexpired, username-matching session. |
|
||||
| `SocketAddressFilter` | Validates websocket handshake identity and session state before Socket.IO sees the connection. |
|
||||
| `WebSocketManager` | Tracks sockets by user and by session, sends events, and disconnects revoked or inactive session sockets. |
|
||||
| `OpenMetadataApplication` | Wires `SessionService` into auth handlers, websockets, revocation listeners, and the websocket session validator. |
|
||||
| `CacheBundle` | Handles cache invalidation pub/sub. Session invalidation messages also disconnect sockets on remote pods. |
|
||||
|
||||
### 5.2 Storage Selection
|
||||
|
||||
`SessionStoreFactory` chooses the store at application startup:
|
||||
|
||||
- If `cache.provider = redis` and Redis is available, sessions use `RedisSessionStore`.
|
||||
- If Redis is configured but unavailable, startup fails closed.
|
||||
- If Redis is not configured, sessions use `JdbcSessionStore`.
|
||||
|
||||
The system does not fail over live from Redis to JDBC. Mixing stores would split active sessions
|
||||
across backends and make revocation unpredictable.
|
||||
|
||||
### 5.3 Session Cache
|
||||
|
||||
Each pod keeps a local Caffeine near-cache:
|
||||
|
||||
- maximum size: `10_000`
|
||||
- expire after access: `10s`
|
||||
|
||||
The cache is a performance optimization, not a correctness boundary. Security-sensitive checks use
|
||||
fresh reloads where revocation must be observed immediately.
|
||||
|
||||
## 6. User Session Management
|
||||
|
||||
### 6.1 Session ID
|
||||
|
||||
`UserSession.id` is an opaque bearer secret carried in the `OM_SESSION` cookie.
|
||||
|
||||
It is generated by `SessionIdGenerator` from secure random bytes and base64url encoded without
|
||||
padding. It is not a UUID.
|
||||
|
||||
### 6.2 Session Types
|
||||
|
||||
Current session types:
|
||||
|
||||
- `AUTH`: browser or interactive user auth session.
|
||||
- `MCP`: reserved for future interactive MCP session support.
|
||||
|
||||
### 6.3 Session Status
|
||||
|
||||
`SessionStatus` values:
|
||||
|
||||
- `PENDING`: login started, callback not completed.
|
||||
- `ACTIVE`: usable session.
|
||||
- `REFRESHING`: one node holds the refresh lease.
|
||||
- `REVOKED`: logout or session-limit revocation.
|
||||
- `EXPIRED`: timeout reached.
|
||||
|
||||
### 6.4 Session Fields
|
||||
|
||||
The important logical fields are:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "opaque-session-id",
|
||||
"type": "AUTH",
|
||||
"provider": "openmetadata",
|
||||
"status": "ACTIVE",
|
||||
"userId": "uuid",
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"omRefreshToken": "fernet:encrypted-token",
|
||||
"providerRefreshToken": "fernet:encrypted-provider-token",
|
||||
"redirectUri": "https://ui.example.com/callback",
|
||||
"state": "oidc-state",
|
||||
"nonce": "oidc-nonce",
|
||||
"pkceVerifier": "pkce-verifier",
|
||||
"version": 7,
|
||||
"refreshLeaseUntil": 1741300000000,
|
||||
"createdAt": 1741200000000,
|
||||
"updatedAt": 1741200005000,
|
||||
"lastAccessedAt": 1741200005000,
|
||||
"expiresAt": 1743792000000,
|
||||
"idleExpiresAt": 1741804800000
|
||||
}
|
||||
```
|
||||
|
||||
Refresh tokens are encrypted before persistence with `Fernet.encryptIfApplies(...)`. If the Fernet
|
||||
key is not configured, session creation fails instead of writing plaintext refresh tokens.
|
||||
|
||||
### 6.5 Session Creation
|
||||
|
||||
Basic, LDAP, and OpenMetadata login create an `ACTIVE` session directly:
|
||||
|
||||
1. Validate credentials.
|
||||
2. Resolve the provisioned OpenMetadata user.
|
||||
3. Persist or receive the OpenMetadata refresh token.
|
||||
4. Create an `ACTIVE` `AUTH` session.
|
||||
5. Encrypt and store the refresh token in the session.
|
||||
6. Write the `OM_SESSION` cookie.
|
||||
7. Return an OpenMetadata-signed JWT with a `sessionId` claim.
|
||||
|
||||
If user lookup or session creation fails after a refresh token is created, the refresh token is
|
||||
deleted.
|
||||
|
||||
### 6.6 Pending Session Activation
|
||||
|
||||
SAML and confidential OIDC use pending sessions:
|
||||
|
||||
1. Login creates a `PENDING` `AUTH` session containing redirect state, OIDC state, nonce, and PKCE
|
||||
verifier when applicable.
|
||||
2. The callback loads the pending session from the shared store.
|
||||
3. The user is created or updated.
|
||||
4. The OpenMetadata refresh token is inserted.
|
||||
5. `activatePendingSession` expires the pending session.
|
||||
6. A brand-new active session ID is generated and stored.
|
||||
7. The active session cookie replaces the pending cookie.
|
||||
8. The browser receives an OpenMetadata-signed JWT with the active session ID.
|
||||
|
||||
Issuing a new active session ID during activation is the session fixation defense. The pre-auth
|
||||
cookie value is never reused for the authenticated session.
|
||||
|
||||
If activation fails, the newly inserted refresh token is deleted and no JWT is issued.
|
||||
|
||||
### 6.7 Refresh
|
||||
|
||||
Refresh is guarded by an optimistic lease:
|
||||
|
||||
1. Load the session from `OM_SESSION`.
|
||||
2. Reject missing, expired, pending, revoked, or already expired sessions.
|
||||
3. If another node holds a non-stale `REFRESHING` lease, return retry guidance through
|
||||
`SessionRefreshInProgressException`.
|
||||
4. Acquire the lease by writing `REFRESHING`, setting `refreshLeaseUntil`, and incrementing
|
||||
`version` with compare-and-set.
|
||||
5. The winning node decrypts the stored refresh token.
|
||||
6. The provider or OpenMetadata refresh token is rotated as needed.
|
||||
7. `completeRefresh` writes the refreshed session back to `ACTIVE`, clears the lease, updates idle
|
||||
expiry without extending beyond the absolute session expiry, and increments `version`.
|
||||
8. The response contains a new OpenMetadata-signed JWT bound to the same session ID.
|
||||
|
||||
Lease duration is currently `15s`.
|
||||
|
||||
### 6.8 Logout and Revocation
|
||||
|
||||
Logout calls `SessionService.revokeSession(request, response)`:
|
||||
|
||||
1. Read `OM_SESSION`.
|
||||
2. Reload the session from the authoritative store.
|
||||
3. Write `REVOKED` with compare-and-set.
|
||||
4. Clear `refreshLeaseUntil`.
|
||||
5. Clear the `OM_SESSION` cookie.
|
||||
6. Notify local revocation listeners.
|
||||
|
||||
Session limit enforcement also uses `revokeSession` for least-recently-used active sessions.
|
||||
|
||||
The limit is configured by `authenticationConfiguration.maxActiveSessionsPerUser`, exposed through
|
||||
`AUTHENTICATION_MAX_ACTIVE_SESSIONS_PER_USER` in `openmetadata.yaml`. The default is `5`; values
|
||||
below `1` fall back to the default.
|
||||
|
||||
### 6.9 Expiration and Cleanup
|
||||
|
||||
`SessionService` runs cleanup every `15m`:
|
||||
|
||||
- mark expired sessions as `EXPIRED`
|
||||
- prune `REVOKED` and `EXPIRED` rows after `7d`
|
||||
- process in bounded batches
|
||||
|
||||
For Redis, primary keys have TTLs and cleanup methods are no-ops. Session correctness still relies
|
||||
on in-process status and expiry checks.
|
||||
|
||||
Default timeouts:
|
||||
|
||||
- pending session timeout: `10m`
|
||||
- authenticated session expiry: `authenticationConfiguration.sessionExpiry`, default `7d`
|
||||
- refresh lease: `15s`
|
||||
- cleanup retention: `7d`
|
||||
|
||||
The `OM_SESSION` cookie max age is rewritten during refresh lease acquisition and is capped at the
|
||||
remaining effective session lifetime.
|
||||
|
||||
## 7. Session-Bound JWTs
|
||||
|
||||
Server-managed auth flows return OpenMetadata-signed access JWTs with:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "alice",
|
||||
"tokenType": "OM_USER",
|
||||
"sessionId": "opaque-session-id"
|
||||
}
|
||||
```
|
||||
|
||||
`JwtFilter` handles the claim as follows:
|
||||
|
||||
1. Validate the JWT signature, expiry, token type, principal, and token-specific rules.
|
||||
2. If there is no `sessionId` claim, preserve existing stateless behavior.
|
||||
3. If `sessionId` exists, call `SessionService.getFreshSessionById(sessionId)`.
|
||||
4. Require:
|
||||
- session exists
|
||||
- status is `ACTIVE`
|
||||
- session is not expired
|
||||
- session username matches the JWT principal
|
||||
5. Reject the token when any check fails.
|
||||
|
||||
This means session-backed browser API requests now consult the shared session store. That is an
|
||||
intentional tradeoff in the current implementation: revocation is observed on the next request
|
||||
instead of waiting for access-token expiry. PATs, bot tokens, and legacy JWTs without `sessionId`
|
||||
remain stateless.
|
||||
|
||||
## 8. WebSocket Session Management
|
||||
|
||||
### 8.1 Handshake Validation
|
||||
|
||||
`SocketAddressFilter` runs before the Socket.IO server receives the connection.
|
||||
|
||||
When secure websocket connections are enabled:
|
||||
|
||||
1. Extract and validate the `Authorization` header.
|
||||
2. Resolve the token principal from JWT claims.
|
||||
3. Resolve the principal's user UUID server-side.
|
||||
4. Reject the request if the query `userId` is present and does not match the resolved user UUID.
|
||||
5. Inject the server-resolved `UserId` header for `WebSocketManager`.
|
||||
6. If the JWT has `sessionId`, inject a `SessionId` header.
|
||||
7. Validate `OM_SESSION` when present:
|
||||
- reload the session fresh
|
||||
- require `ACTIVE`
|
||||
- require not expired
|
||||
- require session username to match the token principal
|
||||
- require cookie session ID to match token `sessionId` when both are present
|
||||
|
||||
If no `OM_SESSION` cookie is present:
|
||||
|
||||
- session-bound JWTs are accepted because `JwtFilter` has already validated the session ID
|
||||
against `SessionService`
|
||||
- legacy secure JWTs without `sessionId` are rejected with `401 Session is required`
|
||||
- non-secure websocket mode remains compatible with existing query-based behavior
|
||||
|
||||
The filter no longer forwards trust from the user-supplied `userId` query parameter when secure
|
||||
mode is enabled.
|
||||
|
||||
### 8.2 Socket Tracking
|
||||
|
||||
`WebSocketManager` maintains two local maps per pod:
|
||||
|
||||
- `activityFeedEndpoints`: `userId -> socketId -> SocketIoSocket`
|
||||
- `socketSessionIds`: `socketId -> sessionId`
|
||||
|
||||
On connection:
|
||||
|
||||
1. Read `UserId` from the injected header, falling back to query only for legacy/non-secure paths.
|
||||
2. Read `SessionId` from the injected header, falling back to query `sessionId` only for legacy
|
||||
paths.
|
||||
3. Store the socket in the user's local socket map.
|
||||
4. Store the socket-to-session mapping when a session ID is available.
|
||||
|
||||
On disconnect, both maps are cleaned up.
|
||||
|
||||
The connection log records user and remote address only. It does not log initial headers, so bearer
|
||||
tokens are not written to logs.
|
||||
|
||||
### 8.3 Revocation-Driven Disconnect
|
||||
|
||||
`SessionService` exposes revocation listeners. `OpenMetadataApplication` registers a listener that:
|
||||
|
||||
1. Converts the revoked session's `userId` to UUID.
|
||||
2. Calls `WebSocketManager.disconnectForSession(userId, sessionId)` on the local pod.
|
||||
3. Publishes a `"session"` invalidation message through cache invalidation pub/sub when available.
|
||||
|
||||
`CacheBundle` handles remote `"session"` invalidation messages:
|
||||
|
||||
- if the message has a session ID, call `disconnectForSession(userId, sessionId)`
|
||||
- if no session ID is present, fall back to `disconnectAllForUser(userId)` for backward
|
||||
compatibility
|
||||
|
||||
This gives targeted disconnects. Logging out one browser session does not force-close other
|
||||
sessions for the same user.
|
||||
|
||||
### 8.4 Periodic WebSocket Validation
|
||||
|
||||
`OpenMetadataApplication.WebSocketSessionValidator` runs every `60s` by default. Operators can tune
|
||||
the interval with the `openmetadata.websocketSessionValidationIntervalSeconds` system property or
|
||||
the `WEBSOCKET_SESSION_VALIDATION_INTERVAL_SECONDS` environment variable. Values below `15s` are
|
||||
clamped to `15s`.
|
||||
|
||||
Each run calls `WebSocketManager.disconnectInactiveSessions(sessionService, intervalMillis)`, which:
|
||||
|
||||
1. Iterates local sockets with known `sessionId`.
|
||||
2. Reloads a socket's session fresh through `SessionService.getFreshSessionById` only when that
|
||||
socket's revalidation interval is due.
|
||||
3. Disconnects sockets whose session is missing, not `ACTIVE`, expired, or owned by a different
|
||||
user.
|
||||
|
||||
This is the fallback when there is no cross-pod pub/sub. With JDBC and no pub/sub, a socket on a
|
||||
remote node is closed within the validator interval instead of immediately.
|
||||
|
||||
## 9. End-to-End Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Browser["Browser / UI"]
|
||||
NodeA["OpenMetadata Pod A"]
|
||||
NodeB["OpenMetadata Pod B"]
|
||||
AuthHandlers["Auth handlers<br/>Basic / LDAP / SAML / OIDC"]
|
||||
SessionServiceA["SessionService A"]
|
||||
SessionServiceB["SessionService B"]
|
||||
Store[("Shared SessionStore<br/>JDBC user_session or Redis")]
|
||||
JwtFilter["JwtFilter"]
|
||||
SocketFilter["SocketAddressFilter"]
|
||||
WebSocketManagerA["WebSocketManager A"]
|
||||
WebSocketManagerB["WebSocketManager B"]
|
||||
PubSub["Cache invalidation pub/sub<br/>optional"]
|
||||
Validator["WebSocketSessionValidator<br/>default 60s, min 15s"]
|
||||
|
||||
Browser -->|"login / callback"| NodeA
|
||||
NodeA --> AuthHandlers
|
||||
AuthHandlers --> SessionServiceA
|
||||
SessionServiceA -->|"create PENDING or ACTIVE<br/>activate pending<br/>encrypt refresh tokens"| Store
|
||||
SessionServiceA -->|"Set-Cookie: OM_SESSION"| Browser
|
||||
AuthHandlers -->|"JWT with sessionId"| Browser
|
||||
|
||||
Browser -->|"API request<br/>Bearer JWT(sessionId)"| NodeB
|
||||
NodeB --> JwtFilter
|
||||
JwtFilter --> SessionServiceB
|
||||
SessionServiceB -->|"fresh session lookup"| Store
|
||||
JwtFilter -->|"allow only ACTIVE + unexpired + matching username"| NodeB
|
||||
|
||||
Browser -->|"websocket handshake<br/>Authorization + OM_SESSION"| NodeB
|
||||
NodeB --> SocketFilter
|
||||
SocketFilter -->|"validate JWT principal<br/>resolve user UUID<br/>validate session"| SessionServiceB
|
||||
SocketFilter -->|"inject UserId + SessionId"| WebSocketManagerB
|
||||
WebSocketManagerB -->|"track user -> sockets<br/>track socket -> session"| WebSocketManagerB
|
||||
|
||||
Browser -->|"logout / revoke"| NodeA
|
||||
NodeA --> SessionServiceA
|
||||
SessionServiceA -->|"mark REVOKED"| Store
|
||||
SessionServiceA -->|"local disconnectForSession"| WebSocketManagerA
|
||||
SessionServiceA -->|"publish session invalidation"| PubSub
|
||||
PubSub -->|"remote disconnectForSession"| WebSocketManagerB
|
||||
|
||||
Validator --> WebSocketManagerB
|
||||
WebSocketManagerB -->|"fresh validate socket sessions"| SessionServiceB
|
||||
SessionServiceB --> Store
|
||||
WebSocketManagerB -->|"disconnect inactive sockets"| Browser
|
||||
```
|
||||
|
||||
## 10. Consistency Model
|
||||
|
||||
### 10.1 API Requests
|
||||
|
||||
For tokens with `sessionId`, the session store is authoritative. A revoked or expired session is
|
||||
rejected on the next API request that uses that token.
|
||||
|
||||
For tokens without `sessionId`, existing stateless behavior is preserved.
|
||||
|
||||
### 10.2 Refresh
|
||||
|
||||
Refresh uses optimistic compare-and-set on `version`, so only one node can hold the refresh lease
|
||||
for a session at a time.
|
||||
|
||||
JDBC implements this through the session repository update path. Redis implements it with a Lua CAS
|
||||
script over the stored session JSON. The Redis script also removes the session ID from all
|
||||
non-terminal per-user status indexes and adds it to the target non-terminal index before returning,
|
||||
so the JSON write and index movement succeed or fail together.
|
||||
|
||||
### 10.3 WebSockets
|
||||
|
||||
Websocket consistency has two layers:
|
||||
|
||||
- event-driven disconnect through local revocation listeners and optional cache invalidation pub/sub
|
||||
- polling-based validation with a `60s` default interval and `15s` minimum
|
||||
|
||||
The event path is immediate when revocation occurs on the same pod or pub/sub delivers the remote
|
||||
event. The polling path bounds staleness when pub/sub is unavailable, and each socket is fresh
|
||||
loaded at most once per validation interval.
|
||||
|
||||
## 11. Operational Characteristics
|
||||
|
||||
| Path | Store behavior |
|
||||
| --- | --- |
|
||||
| Login | create active or pending session |
|
||||
| OIDC/SAML callback | fresh load pending session, expire pending session, create active session |
|
||||
| Session-bound API request | fresh load session by `sessionId` |
|
||||
| Refresh | load session, acquire CAS lease, complete CAS update |
|
||||
| Logout | fresh load session, CAS revoke, clear cookie |
|
||||
| WebSocket handshake | validate JWT, optionally fresh load cookie session |
|
||||
| WebSocket validator | throttled fresh load for each tracked socket with `sessionId` |
|
||||
|
||||
Redis deployments should monitor Redis availability as auth-critical infrastructure. When Redis is
|
||||
configured for sessions, the service refuses to start without it.
|
||||
|
||||
## 12. Security Properties
|
||||
|
||||
1. `OM_SESSION` is opaque and high entropy.
|
||||
2. `OM_SESSION` is written as an HTTP-only cookie.
|
||||
3. Provider refresh tokens and OpenMetadata refresh tokens are encrypted at rest.
|
||||
4. Refresh tokens are not returned to the browser by server-managed auth flows.
|
||||
5. Pending-session activation issues a brand-new active session ID.
|
||||
6. Session-bound JWTs are invalid once the backing session is revoked, expired, deleted, or owned by
|
||||
a different user.
|
||||
7. Secure websocket mode derives socket user identity from the JWT principal, not from query params.
|
||||
8. Websocket logs do not include initial headers or bearer tokens.
|
||||
9. Revocation targets the revoked session instead of disconnecting every socket for the user.
|
||||
|
||||
## 13. Test Coverage
|
||||
|
||||
Relevant unit coverage includes:
|
||||
|
||||
- `SessionServiceTest`
|
||||
- `SessionCookieUtilTest`
|
||||
- `SessionTimeoutResolverTest`
|
||||
- `SessionStoreContractTest`
|
||||
- `RedisSessionStoreTest`
|
||||
- `JwtFilterTest`
|
||||
- `BasicAuthServletHandlerTest`
|
||||
- `LdapAuthServletHandlerTest`
|
||||
- `SamlAuthServletHandlerTest`
|
||||
- `AuthenticationCodeFlowHandlerTest`
|
||||
- `SocketAddressFilterTest`
|
||||
- `WebSocketManagerTest`
|
||||
|
||||
Relevant integration coverage includes:
|
||||
|
||||
- `SessionMultiNodeIT`
|
||||
- `SessionRedisMultiNodeIT`
|
||||
- `SessionMultiNodeCluster`
|
||||
|
||||
Important scenarios covered or expected from this suite:
|
||||
|
||||
- login on one node and refresh/logout on another
|
||||
- pending OIDC/SAML callback state loaded from shared session storage
|
||||
- refresh lease contention
|
||||
- stale cache behavior after revocation
|
||||
- Redis-backed cross-node sessions
|
||||
- websocket principal binding
|
||||
- per-session websocket disconnect
|
||||
- session-bound JWT rejection for revoked sessions
|
||||
|
||||
## 14. Tradeoff Resolutions
|
||||
|
||||
1. Session-bound browser API requests intentionally reload session state on JWT validation. This is
|
||||
the chosen correctness boundary: logout and revocation are observed on the next browser API
|
||||
request instead of waiting for access-token expiry.
|
||||
2. Tokens without `sessionId` remain on existing JWT semantics. This preserves PAT, bot, public OIDC,
|
||||
and rolling-upgrade compatibility. New server-managed auth responses include `sessionId`.
|
||||
3. Non-secure websocket mode remains query-param based only for backward compatibility. Production
|
||||
deployments should keep secure websocket connections enabled so `SocketAddressFilter` derives the
|
||||
socket user from the JWT principal and records the session ID.
|
||||
4. The active-session cap is now configurable with
|
||||
`authenticationConfiguration.maxActiveSessionsPerUser` and
|
||||
`AUTHENTICATION_MAX_ACTIVE_SESSIONS_PER_USER`; the default remains `5`.
|
||||
5. Secure, session-managed websocket handshakes now record a session ID from the JWT claim or
|
||||
`OM_SESSION` cookie. The validator checks those sockets on a configurable interval with a `60s`
|
||||
default and `15s` minimum; sockets without session IDs are legacy/non-secure compatibility cases.
|
||||
6. Cross-pod websocket revocation has two paths: cache invalidation pub/sub for immediate targeted
|
||||
disconnects when available, and the configurable websocket validator as the bounded-staleness
|
||||
fallback for JDBC-only deployments.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Streamable Ingestion Logs
|
||||
|
||||
This document describes the end-to-end design of OpenMetadata's streamable ingestion-pipeline log system: how logs flow from a running connector to durable S3 storage, how the UI reads them while a run is in progress, and how the system handles long idle gaps, restarts, and abandoned runs.
|
||||
|
||||
## Overview
|
||||
|
||||
Ingestion pipelines (metadata, profiler, lineage, usage, dbt, etc.) emit logs as they run. Operators need to:
|
||||
|
||||
- Watch logs **live** while a pipeline is running, including for long-running connectors that can take hours.
|
||||
- Read logs **after the run ends**, with a single canonical artifact per run.
|
||||
- Recover gracefully from server restarts, network blips, and connector idle gaps.
|
||||
|
||||
OpenMetadata addresses this with a server-side log storage abstraction backed by S3 (or any S3-compatible store like MinIO). The connector pushes log batches over HTTP; the server persists them and serves both live and post-run reads.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ Python ingestion │ POST /logs/{fqn}/{runId} (append)
|
||||
│ connector │ POST /logs/{fqn}/{runId}/close (finalize)
|
||||
│ (logs_mixin.py) │
|
||||
└──────────┬───────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ OpenMetadata server │
|
||||
│ IngestionPipeline │
|
||||
│ Resource │
|
||||
└──────────┬───────────┘
|
||||
│ LogStorageInterface
|
||||
▼
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ S3LogStorage │────────▶│ S3 / MinIO bucket │
|
||||
│ (streaming, in-mem │ │ partial.txt │
|
||||
│ buffers, sweeper) │ │ logs.txt │
|
||||
└──────────┬───────────┘ └──────────────────────┘
|
||||
│ SSE / GET (paginated / download)
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ OpenMetadata UI │
|
||||
│ (live tail + history)│
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
The `LogStorageInterface` abstraction supports multiple backends:
|
||||
|
||||
| Backend | Purpose |
|
||||
|---------|---------|
|
||||
| `S3LogStorage` | Production: stores logs durably in S3 / MinIO. The focus of this document. |
|
||||
| `DefaultLogStorage` | Backward-compat: delegates to the pipeline service client (Airflow / Argo). No first-class storage. |
|
||||
|
||||
This document covers the `S3LogStorage` implementation.
|
||||
|
||||
## Storage Layout
|
||||
|
||||
Each pipeline run is identified by a `(fqn, runId)` tuple. On S3 the layout is:
|
||||
|
||||
```
|
||||
{bucket}/{prefix}/ # prefix defaults to "pipeline-logs"
|
||||
{sanitizedFQN}/{runId}/
|
||||
partial.txt # readable view during the run
|
||||
logs.txt # final artifact, materialized at /close
|
||||
.active/{sanitizedFQN}/{runId}/{serverId} # heartbeat marker
|
||||
```
|
||||
|
||||
**`partial.txt`** is the durable, readable view of an in-progress run. It is updated periodically as the connector appends batches. It carries durable offset state in S3 user-defined metadata:
|
||||
|
||||
| Metadata key | Purpose |
|
||||
|--------------|---------|
|
||||
| `x-amz-meta-last-flushed-line` | Logical line counter at the moment of this PUT. Drives retry idempotency and post-restart recovery. |
|
||||
| `x-amz-meta-total-bytes` | Cross-check on body size; helps detect drift. |
|
||||
| `x-amz-meta-writer-epoch` | Bumped each time a fresh OM-server instance picks up the stream after a restart. |
|
||||
| `x-amz-meta-writer-version` | Identifies the writer code version. Useful during migration windows. |
|
||||
|
||||
**`logs.txt`** is the canonical post-run artifact. It is created **only** at `/close` (or by the abandoned-run sweeper), as a server-side S3 copy of the final `partial.txt`. Content matches `partial.txt` exactly at the moment of close.
|
||||
|
||||
**`.active/...`** markers are dropped as a side effect of `appendLogs`. They have no functional role in correctness; they are operational hints for diagnostics ("which OM-server instance most recently saw this run").
|
||||
|
||||
A bucket lifecycle policy ensures cleanup:
|
||||
- `expirationDays` (default 30) on the `pipeline-logs/` prefix expires all logs after the retention window.
|
||||
|
||||
## Run Lifecycle
|
||||
|
||||
### 1. Connector emits a batch
|
||||
|
||||
The Python ingestion runner buffers log lines and POSTs batches to the server:
|
||||
|
||||
```
|
||||
POST /api/v1/services/ingestionPipelines/logs/{fqn}/{runId}
|
||||
Content-Type: application/json
|
||||
|
||||
"<raw log content>" OR
|
||||
|
||||
{
|
||||
"logs": "<base64-gzipped log content>",
|
||||
"connectorId": "...",
|
||||
"compressed": true
|
||||
}
|
||||
```
|
||||
|
||||
`IngestionPipelineResource.writePipelineLogs` decodes the body and calls `repository.appendLogs(fqn, runId, content)`, which delegates to `S3LogStorage.appendLogs`.
|
||||
|
||||
### 2. Server-side append
|
||||
|
||||
`S3LogStorage.appendLogs` does five things, all in memory, all under a per-stream `ReentrantLock`:
|
||||
|
||||
1. **Increments `totalLinesAppended`**, the monotonic logical line counter that anchors retry idempotency.
|
||||
2. **Appends to `SimpleLogBuffer`** (in-memory ring, capacity 1000 lines). This is the source for the SSE/WebSocket live-tail UI experience. It is bounded; oldest lines evict on overflow. It is **not** load-bearing for durability.
|
||||
3. **Appends to `pendingFlush`** (in-memory queue, no fixed cap, byte-tracked). This is the durable-pending-write queue and survives until the next successful PUT.
|
||||
4. **Notifies SSE listeners**, fanning out the new lines to any open live-tail HTTP connections.
|
||||
5. **Schedules an early flush** if `pendingFlush` exceeds `earlyFlushWatermarkBytes` (default 5 MB). This protects against memory bloat under bursty writes.
|
||||
|
||||
A single-threaded `cleanupExecutor` schedules the periodic flush, the abandoned-run sweeper, and metrics updates.
|
||||
|
||||
### 3. Periodic flush to `partial.txt`
|
||||
|
||||
Every `partialFlushIntervalMinutes` (default 2) and on demand from the early-flush watermark, `writePartialLogsForStream` runs under the per-stream lock:
|
||||
|
||||
1. Snapshot `pendingFlush` and clear it.
|
||||
2. If empty, no-op (idle streams cost nothing).
|
||||
3. `GetObject partial.txt` → reads `Content-Length` and metadata from the response headers. On 404, treat as empty.
|
||||
4. Build new metadata (`last-flushed-line`, `total-bytes`, `writer-epoch`, `writer-version`).
|
||||
5. **If existing body < 5 MB** — read the body, build merged body = existing + `\n`-joined snapshot, `PutObject` atomically.
|
||||
6. **If existing body ≥ 5 MB** — abort the body stream and concatenate server-side via Multipart Upload: `CreateMultipartUpload`, `UploadPartCopy` (existing body as part 1), `UploadPart` (new content as part 2, the last part has no 5 MB minimum), `CompleteMultipartUpload`. The merged body never enters JVM heap and is not re-uploaded.
|
||||
7. On failure, abort any in-flight multipart upload, re-merge the snapshot to the head of `pendingFlush`, and try again next tick. No data loss.
|
||||
|
||||
Because `pendingFlush` is unbounded by the `SimpleLogBuffer` cap, no line is ever evicted before being flushed.
|
||||
|
||||
### 4. Live read while running
|
||||
|
||||
The UI's "live logs" view does two things in parallel:
|
||||
|
||||
- **HTTP GET** `/logs/{fqn}/{runId}?after={cursor}` for paginated history. The server reads `partial.txt` from S3 and concatenates the in-memory `pendingFlush` snapshot for the most-recent-tail bytes that haven't yet been flushed. The cursor is a line offset.
|
||||
- **Server-Sent Events (SSE)** for live tail. The endpoint registers a `LogStreamListener` against the stream key and pushes new lines as `notifyListeners` fires from each `appendLogs`.
|
||||
|
||||
This gives the user "everything written so far" via GET and "everything written in real time from now on" via SSE.
|
||||
|
||||
### 5. `/close` finalization
|
||||
|
||||
When the connector terminates (success, graceful failure, or graceful abort), it calls:
|
||||
|
||||
```
|
||||
POST /api/v1/services/ingestionPipelines/logs/{fqn}/{runId}/close
|
||||
```
|
||||
|
||||
`S3LogStorage.closeStream` runs under the per-stream lock:
|
||||
|
||||
1. **Final flush**: drain remaining `pendingFlush` to `partial.txt` (same path as the periodic flush).
|
||||
2. **Server-side copy** `partial.txt` → `logs.txt`. Bytes do not transit through OM. Cheap and constant-time regardless of log size.
|
||||
3. **Delete `partial.txt`**.
|
||||
4. **Best-effort delete** the `.active/{fqn}/{runId}/{serverId}` marker.
|
||||
5. Drop in-memory state for the stream (`activeStreams`, `pendingFlush`, `totalLinesAppended`, `recentLogsCache`, the per-stream lock).
|
||||
|
||||
`/close` is idempotent. A second call finds no `partial.txt` and no in-memory state; it is a graceful no-op. A `/close` that arrives after the abandoned-run sweeper already finalized the stream behaves the same way.
|
||||
|
||||
### 6. Post-`/close` reads
|
||||
|
||||
Once `/close` completes, `logs.txt` is the canonical artifact. `getLogs(fqn, runId)` reads it directly. Pagination is by line offset; the response includes `after` (next cursor) and `total` (total bytes / lines).
|
||||
|
||||
There is also a download endpoint that streams the full file (or composes from segments / partial in legacy fallbacks).
|
||||
|
||||
## Read Paths
|
||||
|
||||
| Endpoint | Pre-`/close` | Post-`/close` |
|
||||
|----------|-------------|---------------|
|
||||
| `GET /logs/{fqn}/{runId}` | Reads `partial.txt` + appends `pendingFlush` snapshot. Apply cursor pagination. | Reads `logs.txt`. |
|
||||
| `GET /logs/{fqn}/{runId}/download` | Streams `partial.txt`. | Streams `logs.txt`. |
|
||||
| `GET /logs/{fqn}/stream/{runId}` (SSE) | Registers a listener; replays last 100 buffered lines, then live-streams new lines. | (Not used post-close; the run is over.) |
|
||||
|
||||
Legacy `partial.txt` files written by older code (without S3 metadata) read normally; the new flush logic treats them as "no prior offset" and merges any new content correctly.
|
||||
|
||||
## Abandoned-Run Recovery
|
||||
|
||||
Connectors can die without calling `/close` — process killed, OOM, network partition, infrastructure failure. To bound resource use and still produce a final `logs.txt`, a sweeper runs periodically:
|
||||
|
||||
- **Schedule**: every `cleanupIntervalMinutes` (default 60).
|
||||
- **Threshold**: `streamTimeoutMinutes` since last `appendLogs` (default 1440 = 24h).
|
||||
|
||||
For each expired stream, the sweeper does the same finalization steps as `/close` (final flush, copy to `logs.txt`, delete `partial.txt`, drop in-memory state). The end result is identical: an abandoned run produces a finalized `logs.txt` artifact that the UI can read, just delayed.
|
||||
|
||||
The 24h default is intentionally lenient: typical idle gaps in slow connectors (waiting on source queries, batch boundaries, queues) are minutes-to-hours, not days. Operators can tune the threshold downward in deployments where memory pressure from many parallel runs requires more aggressive reclamation.
|
||||
|
||||
## Failure Modes & Recovery
|
||||
|
||||
| Failure | Recovery |
|
||||
|---------|----------|
|
||||
| S3 PUT fails during periodic flush | `pendingFlush` snapshot is restored under the lock. Next tick retries. No data loss. |
|
||||
| OM-server restart mid-run | All in-memory state lost. `partial.txt` on S3 retains all previously-flushed content. The next `appendLogs` re-creates state; the first flush after restart reads `partial.txt` (with metadata) and resumes from `last-flushed-line`. Worst-case loss: lines that were in `pendingFlush` at restart time, bounded above by `partialFlushIntervalMinutes`. |
|
||||
| Connector dies without `/close` | Abandoned-run sweeper finalizes the run after `streamTimeoutHours`. `logs.txt` is materialized from the most recent `partial.txt`. |
|
||||
| `/close` retries after partial success | All steps are idempotent. Second call finds no `partial.txt` and no in-memory state; no-op. |
|
||||
| Concurrent `appendLogs` and cleanup | The per-stream lock serializes them. Cleanup finds the stream "fresh" again and skips it next tick. |
|
||||
| Bucket lifecycle expires `partial.txt` mid-run | Should not happen at default `expirationDays = 30`. If misconfigured (very low retention), the next flush would treat it as a fresh `partial.txt` and start over. Recommended floor: 7 days. |
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live under `LogStorageConfiguration` in `openmetadata.yaml`:
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| `bucketName` | (required) | S3 bucket for log storage. |
|
||||
| `prefix` | `pipeline-logs` | Key prefix within the bucket. |
|
||||
| `enableServerSideEncryption` | `true` | Apply SSE on every PUT. |
|
||||
| `sseAlgorithm` | `AES_256` | Or `AWS_KMS` (requires `kmsKeyId`). |
|
||||
| `storageClass` | `STANDARD_IA` | S3 storage class for log objects. |
|
||||
| `expirationDays` | 30 | Bucket lifecycle: expire all logs after this many days. |
|
||||
| `streamTimeoutMinutes` | 1440 | Idle threshold (in minutes) before the abandoned-run sweeper finalizes a stream. |
|
||||
| `cleanupIntervalMinutes` | 60 | How often the sweeper wakes up to check for abandoned streams. |
|
||||
| `partialFlushIntervalMinutes` | 2 | Periodic `pendingFlush` → `partial.txt` cadence. |
|
||||
| `earlyFlushWatermarkBytes` | 5242880 (5 MB) | Triggers an out-of-band flush when `pendingFlush` exceeds this size. |
|
||||
| `pendingFlushAlertAfterFailures` | 10 | Emit an alerting metric after this many consecutive failed flushes for a stream. |
|
||||
| `maxConcurrentStreams` | 100 | Bound on in-flight pipeline runs per OM-server instance. |
|
||||
| `awsConfig.*` | — | AWS credentials / region / endpoint (also supports IAM role + custom endpoints for MinIO). |
|
||||
|
||||
## Concurrency Model
|
||||
|
||||
Coordination is a per-stream lock keyed by `streamKey = fqn + "/" + runId`. The lock is held for the duration of `appendLogs`, periodic flush, abandoned-run cleanup, and `/close`. Locks are backed by a Guava `Striped<Lock>` with a fixed stripe count, so memory does not grow with completed-run accumulation; the same key always maps to the same lock instance, eliminating the acquire-vs-remove race that a per-key map would have. False contention across stripes is bounded by `maxConcurrentStreams << stripe count`.
|
||||
|
||||
A single-threaded `ScheduledExecutorService` (`cleanupExecutor`) drives:
|
||||
- Periodic flushes (`writePartialLogs`)
|
||||
- Abandoned-run sweeper (`cleanupAbandonedStreams`)
|
||||
- Metrics updates (`updateStreamMetrics`)
|
||||
- One-shot early flushes scheduled by the watermark trigger
|
||||
|
||||
Under sustained burst load, scheduled tasks queue on this single thread. This is intentional: it bounds resource use and avoids unbounded thread creation under spikes. If a deployment regularly sees queue backlog, the watermark or flush interval can be tuned.
|
||||
|
||||
## Observability
|
||||
|
||||
Key metrics exposed by `StreamableLogsMetrics`:
|
||||
|
||||
- `om_streamable_logs_log_shipment_*` — distribution of append latencies.
|
||||
- `om_streamable_logs_logs_sent` / `logs_failed` — counter of successful and failed appends.
|
||||
- `om_streamable_logs_batch_size` — distribution of lines per batch.
|
||||
- `om_streamable_logs_s3_*` — distribution of S3 read/write latencies and counters of S3 errors.
|
||||
- `om_streamable_logs_pending_part_uploads` — gauge for monitoring queue backlog (legacy, will be retired with multipart removal).
|
||||
- `om_streamable_logs_multipart_uploads` — gauge for active multipart uploads (legacy, will be retired).
|
||||
- `om_streamable_logs_pending_flush_bytes` — gauge for in-memory `pendingFlush` size per stream (new).
|
||||
- `om_streamable_logs_consecutive_flush_failures` — gauge per stream (new).
|
||||
|
||||
Recommended alerts:
|
||||
- `pending_flush_bytes` > 50 MB sustained → memory pressure or persistent S3 failures.
|
||||
- `consecutive_flush_failures` ≥ 10 → S3 connectivity or auth issue.
|
||||
- `s3_errors` rate > 1/min → S3 health degradation.
|
||||
|
||||
## Multi-Server Topology
|
||||
|
||||
The design assumes single-writer-per-run: an ALB / load balancer enforces sticky sessions for `(fqn, runId)` via the `PIPELINE_SESSION` cookie set on the first `appendLogs` response. All subsequent requests for the same run land on the same OM-server instance for the lifetime of the run.
|
||||
|
||||
If stickiness is broken (cookie stripped by a proxy, multi-cluster routing without coordination), two OM-server instances could write to the same `partial.txt` and clobber each other. This is **out of scope** for the current design. A future iteration could move offset state to the database for cross-server coordination.
|
||||
|
||||
## References
|
||||
|
||||
- Source files:
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/logstorage/S3LogStorage.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/logstorage/LogStorageFactory.java`
|
||||
- `openmetadata-spec/src/main/java/org/openmetadata/service/logstorage/LogStorageInterface.java`
|
||||
- `openmetadata-service/src/main/java/org/openmetadata/service/resources/services/ingestionpipelines/IngestionPipelineResource.java`
|
||||
- `ingestion/src/metadata/utils/streamable_logger.py`
|
||||
- `ingestion/src/metadata/ingestion/ometa/mixins/logs_mixin.py`
|
||||
- Related PRs: #23590, #24198, #24287, #24410
|
||||
Reference in New Issue
Block a user