fix(migration): v200 thread_entity guard, PostgreSQL jsonb cast, self-loop cycle detection#27756
fix(migration): v200 thread_entity guard, PostgreSQL jsonb cast, self-loop cycle detection#27756
Conversation
…-loop cycle detection - Add tableExists guard to migrateThreadTasksToTaskEntity so it skips safely when thread_entity has been renamed to thread_entity_legacy by postDataMigration SQL, preventing crashes on fresh installs and migration re-runs - Fix PostgreSQL jsonb type mismatch in migrateSuggestionsToTaskEntity and migrateLegacyActivityThreadsToActivityStream: JDBI binds String as varchar which PostgreSQL rejects for jsonb columns; use ::jsonb cast when ConnectionType is POSTGRES - Re-enable migrateThreadTasksToTaskEntity for MySQL (was commented out as workaround) now that the tableExists guard makes it safe - Fix hasCycleDFS in WorkflowDefinitionRepository to skip self-loop edges so BPMN reassignment cycles (AssignedStage -> AssignedStage) are not flagged as cycles, allowing IncidentLifecycleWorkflow to seed without error - Fix initSeedDataFromResources in EntityRepository to continue seeding remaining entities when one entity fails, instead of aborting the entire seed operation - Add unit tests covering MySQL/PostgreSQL variants for all three migration methods, tableExists guard, and jsonb cast behavior (13 tests total, all passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens v200 migrations and seed/workflow initialization by making migrations resilient to schema drift (renamed tables), fixing PostgreSQL JSONB inserts, and relaxing workflow validation to allow intentional self-loop transitions.
Changes:
- Add v200 migration guards and PostgreSQL
::jsonbcasts for task/activity inserts viaConnectionType. - Re-enable MySQL thread-task migration with a
thread_entityexistence guard. - Improve workflow seeding robustness by skipping self-loop edges in cycle detection and continuing seed initialization after per-entity failures.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v200/MigrationUtil.java | Adds ConnectionType-aware ::jsonb binding and a thread_entity existence guard; threads/suggestions/activity migrations now pass connection type into inserts. |
| openmetadata-service/src/main/java/org/openmetadata/service/migration/postgres/v200/Migration.java | Updates v200 Postgres migration to pass POSTGRES into updated MigrationUtil APIs. |
| openmetadata-service/src/main/java/org/openmetadata/service/migration/mysql/v200/Migration.java | Updates v200 MySQL migration to pass MYSQL and re-enables thread-task migration call. |
| openmetadata-service/src/test/java/org/openmetadata/service/migration/utils/v200/MigrationUtilTest.java | Adds tests for table-missing guards and attempts to cover Postgres/MySQL jsonb casting behavior. |
| openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/WorkflowDefinitionRepository.java | Skips self-loop edges during DFS cycle detection to allow valid reassignment transitions. |
| openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java | Makes seed initialization resilient by catching/logging per-entity failures instead of aborting all seeding. |
| void migrateThreadTasksUsesJsonbCastForPostgres() { | ||
| when(handle.createQuery("SELECT 1 FROM thread_entity LIMIT 1").mapTo(Integer.class).one()) | ||
| .thenReturn(1); | ||
| when(handle | ||
| .createQuery( | ||
| "SELECT json FROM thread_entity WHERE type = 'Task' ORDER BY createdAt ASC") | ||
| .mapToMap() | ||
| .list()) | ||
| .thenReturn(java.util.List.of()); | ||
|
|
||
| assertDoesNotThrow(() -> MigrationUtil.migrateThreadTasksToTaskEntity(handle, POSTGRES)); | ||
|
|
||
| verify(handle, never()).createUpdate(contains(":json,")); | ||
| } |
There was a problem hiding this comment.
migrateThreadTasksUsesJsonbCastForPostgres doesn’t currently validate the ::jsonb insert behavior because the mocked thread list is empty, so insertTask(...) is never reached and Handle.createUpdate(...) is never called. Consider returning a non-empty threads result (one row with a minimal json payload) and then asserting that the SQL passed to createUpdate contains :json::jsonb for POSTGRES.
… tests - Fix tableExists to use findFirst() instead of one(): one() throws on an empty result set (0 rows from an existing but empty table), causing the guard to incorrectly return false and log "table does not exist" - Replace vacuous jsonb cast tests with direct insertTask reflection tests: the previous tests mocked an empty thread list so insertTask was never reached, making assertions trivially true regardless of cast logic; now call insertTask directly via reflection so the ::jsonb cast path is actually exercised for POSTGRES and absent for MYSQL - Update tableExists mock stubs in tests from .one() to .findFirst() to match the corrected implementation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@gitar-bot Intentional design choice — each failure is already individually logged with full context (entity type, FQN, message, and full stack trace) via |
That's a fair point — individual WARN logs with full context per entity do provide better diagnostic coverage than surfacing a single arbitrary |
…_entity_legacy - Replace INSERT IGNORE/ON CONFLICT DO NOTHING with UPSERT so stale entity_relationship rows written by 1.12.x FeedRepository (toEntity='thread') get corrected to toEntity='task' on PK conflict instead of being silently skipped - Fall back to thread_entity_legacy when thread_entity is absent so subsequent server starts (after post-DDL rename) still process thread tasks - For tasks already in task_entity, still call insertTaskLinkRelationships instead of skipping entirely, ensuring relationship rows are backfilled idempotently Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| String createdByName = | ||
| threadJson.has("createdBy") ? threadJson.get("createdBy").asText() : "system"; |
There was a problem hiding this comment.
createdBy in thread JSON is optional (and may be present as null). Using threadJson.has("createdBy") ? threadJson.get("createdBy").asText() : "system" will treat a null value as the literal string "null", causing lookupUserId to fail and the CREATED relationship to be skipped. Prefer a null-safe read (e.g., threadJson.path("createdBy").asText("system") or an explicit isNull() check) so reruns backfill createdBy reliably.
| String createdByName = | |
| threadJson.has("createdBy") ? threadJson.get("createdBy").asText() : "system"; | |
| String createdByName = threadJson.path("createdBy").asText("system"); |
There was a problem hiding this comment.
Fixed — using threadJson.path("createdBy").asText("system") now.
| if (entityLinkStr != null) { | ||
| setAboutFromEntityLink(aboutJson, entityLinkStr, suggestionJson); | ||
| } | ||
| insertTaskLinkRelationships( | ||
| handle, suggestionId, null, null, null, createdByUserId, aboutJson, connectionType); |
There was a problem hiding this comment.
In the alreadyExists branch you backfill link relationships, but domain relationships (DOMAIN --HAS--> TASK) are still skipped. If a previous run inserted the task_entity row but didn’t complete insertTaskDomainRelationships (or domains were resolvable only after later steps), reruns will continue to leave tasks without domains and break domain-scoped task queries. Consider resolving domains and calling insertTaskDomainRelationships here too.
There was a problem hiding this comment.
Not applicable here. TaskRepository didn't exist before v2.0.0 (task entity is new in this redesign), so there are no pre-existing domain relationships to backfill. Additionally, the original migration code already called insertTaskDomainRelationships for new tasks — only insertTaskLinkRelationships was missing. If old code ran, domain rows were already inserted.
| threadJson.has("createdBy") ? threadJson.get("createdBy").asText() : "system"; | ||
| String createdByUserId = lookupUserId(handle, createdByName); | ||
| ObjectNode aboutJson = JsonUtils.getObjectNode(); | ||
| setAboutFromEntityLink(aboutJson, aboutLink, threadJson); |
There was a problem hiding this comment.
In the alreadyExists branch you backfill ASSIGNED_TO/REVIEWS/FOLLOWS/CREATED/MENTIONED_IN relationships, but you don’t backfill domain relationships. If a previous run inserted the task row but didn’t insert domains (or domains were resolvable only after later steps), reruns will still leave tasks without DOMAIN --HAS--> TASK rows. Consider re-running resolveDomainsForTaskAbout + insertTaskDomainRelationships in this branch as well.
| setAboutFromEntityLink(aboutJson, aboutLink, threadJson); | |
| setAboutFromEntityLink(aboutJson, aboutLink, threadJson); | |
| insertTaskDomainRelationships( | |
| handle, | |
| threadId, | |
| resolveDomainsForTaskAbout(handle, aboutJson, connectionType), | |
| connectionType); |
There was a problem hiding this comment.
Not applicable here. TaskRepository didn't exist before v2.0.0 (task entity is new in this redesign), so there are no pre-existing domain relationships to backfill. Additionally, the original migration code already called insertTaskDomainRelationships for new tasks — only insertTaskLinkRelationships was missing. If old code ran, domain rows were already inserted.
Use threadJson.path("createdBy").asText("system") to avoid treating a
JSON null value as the literal string "null" which would cause lookupUserId
to fail and skip the CREATED entity_relationship row.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code Review 👍 Approved with suggestions 2 resolved / 3 findingsMigration adds thread_entity guards, PostgreSQL jsonb casting, and cycle detection, addressing faulty assertions on booleans and empty input tests. Consider refining the seed data initialization to explicitly handle or escalate failures rather than swallowing them. 💡 Edge Case: Seed data failures silently swallowed may hide systemic issues📄 openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java:1231-1240 The new try/catch in Suggested fix✅ 2 resolved✅ Quality: Jsonb cast tests pass vacuously with empty input data
✅ Bug: assertNotNull on boolean always passes — assertion is a no-op
🤖 Prompt for agentsOptionsDisplay: compact → Showing less information. Comment with these commands to change:
Was this helpful? React with 👍 / 👎 | Gitar |
| public final void initSeedDataFromResources() throws IOException { | ||
| List<T> entities = getEntitiesFromSeedData(); | ||
| for (T entity : entities) { | ||
| initializeEntity(entity); | ||
| try { | ||
| initializeEntity(entity); | ||
| } catch (Exception e) { | ||
| LOG.warn( | ||
| "Failed to initialize {} '{}': {}", | ||
| entityType, | ||
| entity.getFullyQualifiedName(), | ||
| e.getMessage(), | ||
| e); | ||
| } | ||
| } |
There was a problem hiding this comment.
initSeedDataFromResources now swallows exceptions from initializeEntity and continues. This is a behavioral change that can impact startup/seed correctness, but there’s no unit test asserting that the loop continues (and that subsequent entities are still initialized) when one seed fails. Consider adding a focused test using a spy/stub repository to throw on the first initializeEntity call and verify later entities are still processed (and/or that the failure is logged with the entity FQN).
|



Context
This PR fixes six bugs introduced or exposed during the v2.0.0 migration, all of which caused CI failures or incorrect data on both MySQL and PostgreSQL. The bugs compound each other: the workflow seeding failures (#4 and #5) were only visible because the migration bugs (#1–3) caused the server to restart mid-migration and hit edge cases on re-run.
Bug 1:
thread_entitycrash on migration re-run (MySQL + PostgreSQL)Root cause: OpenMetadata uses two independent tracking systems for migrations:
flyway_schema_history— each script is committed atomically and cannot be rolled back once applied.server_change_loginside a transaction — if a later migration step fails, the transaction rolls back and the Java state reverts to "v200 not yet run."The v2.0.0
postDataMigrationSQLScript.sqlrenamesthread_entity → thread_entity_legacyafter the Java data migration runs. If any subsequent migration throws and rolls back theserver_change_logtransaction, Flyway has already committed the rename permanently. On the next server startup, Java re-executesmigrateThreadTasksToTaskEntity, which tries toSELECT FROM thread_entity— a table that no longer exists — and crashes.This also affects upgrades from certain versions where
thread_entitywas never present.Fix: Guard
migrateThreadTasksToTaskEntitywith atableExists(handle, "thread_entity")check at the top of the method and return early if the table is absent.Bug 2: PostgreSQL
jsonbtype mismatch — all 1032 suggestion inserts failRoot cause: The v2.0.0 PostgreSQL schema declares the following columns as
jsonb NOT NULL:task_entity.jsonactivity_stream.jsonactivity_stream.domainsJDBI's
.bind("fieldName", someString)always binds a JavaStringas SQL typevarchar. PostgreSQL enforces strict type checking and does not implicitly castvarchartojsonb, throwing:MySQL's
jsontype is lenient and accepts string input without an explicit cast, so MySQL never surfaced this bug. PostgreSQL's strictness caused every single suggestion insert and every activity event insert to fail.Fix: Pass
ConnectionTypethrough the call chain intoinsertTaskandinsertActivityEvent. WhenConnectionType.POSTGRES, use an explicit::jsonbcast in the SQL string (e.g.,:json::jsonb). MySQL continues to use the plain:jsonbinding.Bug 3: MySQL
migrateThreadTasksToTaskEntitysilently disabledRoot cause: The MySQL migration had
migrateThreadTasksToTaskEntitycommented out as a temporary workaround after it caused CI failures:The actual root cause was the
thread_entitycrash described in Bug 1 — not the method itself. This meant MySQL deployments silently skipped migrating thread-based tasks totask_entity, resulting in missing task data for all MySQL installations.Fix: Re-enable the method now that Bug 1's
tableExistsguard makes it safe to call. The commented-out code is removed.Bug 4: BPMN self-loop edges falsely detected as cycles
Root cause:
hasCycleDFSinWorkflowDefinitionRepositoryiterates over all outgoing edges of a node and recurses into each neighbor. It does not skip self-loop edges — edges where a node transitions to itself (e.g.,AssignedStage → AssignedStage).BPMN self-loops are a valid and intentional pattern for reassignment transitions: an incident can be reassigned to a different team while remaining in the
AssignedStagestate. The DFS treated this as a cycle and threw a validation exception whenIncidentLifecycleWorkflowwas seeded.Fix: Skip the recursion when
neighbor.equals(node)— a self-loop cannot form a multi-node cycle.Bug 5: One failing seed entity aborts all subsequent seeds
Root cause:
initSeedDataFromResourcesinEntityRepositoryiterates over workflow definitions alphabetically. Seed files are processed in alphabetical order, soIncidentLifecycleWorkflowis initialized beforeTableCertificationWorkflow. When Bug 4 causedIncidentLifecycleWorkflowinitialization to throw an exception, the exception propagated out of the loop uncaught, aborting the entire seed operation.TableCertificationWorkflowand every other definition that came after were never seeded.Fix: Wrap each entity initialization in a try-catch. Log the failure with context (entity type, FQN, error message and stack trace) and continue to the next entity. One bad workflow definition should not prevent all others from loading.
Bug 6: Migrated tasks missing
assigneesandcreatedByin the API responseRoot cause:
TaskRepository.storeEntity()deliberately stripsassignees,createdBy,about,reviewers, andwatchersfrom the task JSON before persisting — these relationships are stored exclusively as rows inentity_relationship. Reads viagetAssignees(),getTaskCreatedBy(), etc. all queryentity_relationship, not the JSON column.The v2.0.0 migration (
migrateThreadTasksToTaskEntity,migrateSuggestionsToTaskEntity) inserted task rows via raw SQL INSERT, bypassingTaskRepository.storeRelationships(). Noentity_relationshiprows were ever created for migrated tasks. As a result:assigneesalways returned empty — "No Owners" in the UIcreatedByalways returned nullaboutentity link was not registeredFix: After each
insertTask(...)call in both migration methods, callinsertTaskLinkRelationships(...)which inserts the requiredentity_relationshiprows:assignees[]ASSIGNED_TOcreatedBy(user)CREATEDaboutentityMENTIONED_INreviewers[]REVIEWSwatchers[]FOLLOWSInserts are idempotent: MySQL uses
INSERT IGNORE, PostgreSQL usesON CONFLICT DO NOTHING.Tests
Added unit tests in
MigrationUtilTestcovering:tableExistsguard skips gracefully formigrateThreadTasksToTaskEntity(MySQL + PostgreSQL)tableExistsguard skips gracefully formigrateSuggestionsToTaskEntity(MySQL + PostgreSQL)tableExistsguard skips gracefully formigrateLegacyActivityThreadsToActivityStream(MySQL + PostgreSQL)migrateThreadTasksToTaskEntityuses::jsonbcast for PostgreSQLmigrateThreadTasksToTaskEntitydoes not use::jsonbcast for MySQLmigrateThreadTasksToTaskEntityinsertsentity_relationshiprows for assignees and about entitymigrateSuggestionsToTaskEntityinsertsentity_relationshiprows for createdByAll 15 tests pass.
Test plan
thread_entity does not existcrashcolumn "json" is of type jsonb but expression is of type character varyingMigrationUtilTestpass (15 total)IncidentLifecycleWorkflowseeds successfully on fresh installTableCertificationWorkflowseeds successfully on fresh installassigneesandcreatedByfrom the APImvn spotless:apply -pl openmetadata-servicereports no formatting issues🤖 Generated with Claude Code