Fixes 27040: bind time-series destruction to hard delete, not every delete - #31842
Open
TeddyCr wants to merge 4 commits into
Open
Fixes 27040: bind time-series destruction to hard delete, not every delete#31842TeddyCr wants to merge 4 commits into
TeddyCr wants to merge 4 commits into
Conversation
…elete IngestionPipelineRepository, DataContractRepository and AppRepository all received postDelete's hardDelete flag and ignored it, so a *soft* delete physically deleted the entity's time series rows. restoreEntity only flips the deleted flag, so the history was unrecoverable. Move the destructive work to entitySpecificCleanup, which is reached only from cleanup() and therefore only on the hard-delete branch of EntityRepository's delete() -- the seat established for TestCaseRepository in 8e5c21d. The base bulkEntitySpecificCleanup already loops that hook, so the cascade hard-delete path is covered without a per-repository override. Two irreversible side effects rode along on the same unguarded path: - IngestionPipelineRepository.deleteDeployedPipeline removed the DAG from the orchestrator on a soft delete (restore cannot redeploy), and with allowUnavailableRunner=false failed the whole soft delete when the runner was down. It is now guarded by hardDelete but stays in postDelete: it is a remote call that must not run inside the cleanup() transaction, and forceDelete threads allowUnavailableRunner through it and reads back the skip flag. - DataContractRepository.deleteTestSuite hardcoded hardDelete=true and resolved the suite through getOrCreateTestSuite, so a soft contract delete created a test suite just to hard-delete it, taking the DQ ingestion pipeline with it. The teardown moves to hardDeleteAdditionalChildren -- the documented hook for related entities the from-to cascade cannot reach, which is the case here because the edge is testSuite CONTAINS dataContract -- and now looks the suite up instead of creating one. Deliberately no soft-delete/restore counterpart for the contract's test suite: that same edge makes the contract a restore-cascade child of the suite and bulkRestoreSubtree runs restoreAdditionalChildren unconditionally, so the two would call each other forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ontracts and apps SoftDeleteRetentionIT asserts, per entity type, that the time series rows survive a soft delete and that the history is readable again through the public API after the restore. The retention assertions count rows directly in entity_extension_time_series / apps_extension_time_series: a soft-deleted entity's statuses are not readable through the API at all, so an API-level check would pass even with the rows already destroyed. Each bug test is paired with a hard-delete guard asserting the rows (and, for a data contract, its logical test suite) are still purged, so the fix cannot regress into leaking orphaned time series. K8sIngestionPipelineResourceIT.test_deletePipeline_withK8sBackend encoded the old behaviour: it soft-deleted and then asserted the K8s CronJob and ConfigMap were gone. It now hard-deletes -- the same resource-teardown assertions, against the delete mode that is supposed to tear resources down. The class is @disabled in the repo, so this change is unverified by a run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…perator Review follow-ups on the open-metadata#27040 fix. No change to what the fix does on the soft-delete path. - The `if (hardDelete)` guard around deleteDeployedPipeline had no runnable coverage: K8sIngestionPipelineResourceIT is @disabled, the existing IngestionPipelineRepositoryTest cases call deleteDeployedPipeline directly and bypass postDelete, and SoftDeleteRetentionIT bootstraps with the pipeline service client disabled. Collapsing postDelete to `super.postDelete(...); return false;` would orphan a DAG against every hard-deleted pipeline with the whole suite still green. Two tests now pin both halves through postDelete itself, using a runner that is down as the observable channel: with allowUnavailableRunner=false the exception surfaces exactly when the orchestrator is reached, so the assertions are "throws" / "does not throw" rather than mock call counts. - hardDeleteAdditionalChildren was handed the operator and dropped it, deleting the contract's test suite as ADMIN_USER_NAME. EntityRepository documents the deletedBy-aware hooks as existing precisely so the audit trail credits the actual operator, so thread it through; the create/update call site passes dataContract.getUpdatedBy() for the same reason. - Say in the postDelete JavaDoc that nothing pauses the DAG either, so the accepted downside of keeping it alive is visible at the call site. - SoftDeleteRetentionIT: hoist the hardDelete query params to Map.of constants, narrow the test-suite assertThrows to ApiException + a 404 status assertion so a transport failure cannot satisfy it, and register every fixture into an @AfterEach teardown list. NamespaceCleanup has no mapping for ingestionPipeline, database or application, so an assertion failing mid-test used to leak fixtures into the shared cluster. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The explanation of why the orchestrator teardown is bound to hard delete sat on the private 3-arg overload, so neither a reader of the protected override nor javadoc tooling picked up the contract it defines. Move it to the override and leave the overload documenting only its own concern: forceDelete's tolerance of an unreachable runner and the skip flag it reports back. Comment-only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
❌ PR checklist incompleteThis PR cannot be merged until the following are addressed on its linked issue:
The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically. Maintainers can bypass this check by adding the |
Code Review ✅ ApprovedBinds time-series data destruction and DAG teardown to hard deletes rather than soft deletes for IngestionPipelines, DataContracts, and Apps to prevent data loss on restore. No issues found. OptionsDisplay: compact → Showing less information. Comment with these commands to change the behavior for this request:
Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Describe your changes:
Fixes #27040
This is silent, irrecoverable data loss. A soft delete of an
IngestionPipeline,DataContractor
Appphysically deleted the entity's time series rows.restoreEntityonly flips thedeletedflag — there is no time-series reinstatement — so the entity came back with its entire run/result
history gone, permanently, with no error shown to the user.
EntityRepository.postDelete(T entity, boolean hardDelete)receives the flag and the baseimplementation guards on it correctly. Three subclasses received the flag and ignored it:
postDeleteIngestionPipelineRepositorydeleteDeployedPipeline(...)+deletePipelineStatuses(...)DataContractRepositorydeleteTestSuite(...)+entityExtensionTimeSeriesDao().delete(fqn, RESULT_EXTENSION)AppRepositoryappExtensionTimeSeriesDao().delete(id, ExtensionType.STATUS)Two irreversible side effects rode along on the same unguarded path, both listed in the issue:
restoreEntityhas no way to redeploy, so arestore produced a live-looking pipeline with no backing DAG. Worse, with
allowUnavailableRunner=false— the mode every delete exceptforceDeleteuses — a plain softdelete threw
IngestionRunnerUnavailableExceptionand failed the whole request whenever theingestion runner happened to be down.
DataContractRepository.deleteTestSuitehardcodedhardDelete=trueand resolved the suite viagetOrCreateTestSuite— so soft-deleting a contract hard-deleted its logical test suite, whichhard-deleted that suite's DQ ingestion pipeline and its DAG; and on a contract that had no suite,
it created one on the delete path purely in order to delete it.
Blast radius — stated precisely, not oversold.
postDeleteis called from the three public deleteentry points and from the bulk hard delete chunk.
bulkSoftDeleteSubtreenever calls it, so thisfired on a direct soft delete of the entity — not when it was cascade-soft-deleted as a child
of its service or table.
Type of change:
High-level design:
Move time-series destruction to a seat that is hard-delete-only by construction, rather than adding
a conditional that a future edit can drop.
EntityRepository's privatedelete(...)reachescleanup(updated)only on the hard-deletebranch.
cleanupisprotected finaland dispatches to the overridableentitySpecificCleanup.Putting the destructive work there means it cannot run on a soft delete — there is no
iftoforget, and the guarantee is structural. The bulk hard-delete path is covered for free: the base
bulkEntitySpecificCleanuploopsentitySpecificCleanup(deletedBy, entity)over the batch.This follows the idiom established by
8e5c21dee6("Fixes 27060: delete test case results once onhard delete…") for
TestCaseRepository. NobulkEntitySpecificCleanupoverride was added here,unlike
TestCaseRepository: that override exists purely to collapse N async dispatches into one,whereas these hooks issue a single synchronous
DELETEeach, so an override would be a byte-for-byteduplicate of the base loop.
deleteDeployedPipelinedeliberately stays inpostDelete, behind anif (hardDelete)guard.Two reasons it does not belong in
entitySpecificCleanup:cleanup()opens a realEntity.getJdbi().inTransaction(...); holding a DB transaction open across an HTTP round-trip tothe orchestrator is exactly what you don't want. (Note
EntityRepository's@Transactionannotations are the JDBI SQL-object annotation on a plain class and are inert —
cleanup()'s isthe real one.)
forceDeletethreadsallowUnavailableRunnerthrough this method and reads back itsbooleanreturn to warn about a DAG left behind;
entitySpecificCleanupreturnsvoid.DataContractRepository's test-suite teardown moved tohardDeleteAdditionalChildren, thedocumented hook for related entities the cascade cannot reach — the edge is
testSuite --CONTAINS--> dataContract, i.e. the contract is the target, so the from→to walk neverreaches the suite. This mirrors
DashboardRepository's handling of charts.getOrCreateTestSuiteonthe delete path is replaced by a lookup that no-ops when there is no suite.
Deliberately no soft-delete/restore counterpart for the test suite. That was implemented, proved
harmful, and reverted: the same
CONTAINSedge makes the contract a restore-cascade child of thesuite,
restoreChildrenruns before the parent's owndeletedflag flips, andbulkRestoreSubtreeruns
restoreAdditionalChildrenunconditionally — so a contract→suite restore hook and thesuite→contract cascade call each other forever. It surfaced as a 302 s client timeout on
PUT /v1/dataContracts/restore. The reason is recorded in the JavaDoc so nobody re-adds it.Accepted behavioural consequence, stated honestly. A soft-deleted pipeline now keeps a live DAG
that nothing pauses — it will keep running on schedule and recording statuses until it is restored or
hard-deleted — and a soft-deleted contract keeps its test suite and DQ pipeline. This is the
deliberate trade-off: everything irreversible is now bound to hard delete. It also aligns direct
soft delete with the cascade behaviour that already existed on
main, where soft-deleting a serviceleft every child pipeline's DAG running because
bulkSoftDeleteSubtreenever calledpostDelete.Pausing instead of leaving it running would need a restore-time redeploy hook that does not exist
today (
PipelineServiceClientInterface.toggleIngestionis the obvious primitive) — that is newbehaviour, not this bug fix.
No schema change, no migration.
Related but not addressed here:
AppResource.delete/deleteAppAsyncandUserRepository.postDeleterun comparable unguarded work on soft delete, and
AppRepository.entitySpecificCleanupstillhard-codes
"admin"for its pipeline teardown. All pre-existing and out of scope for this issue.Tests:
Use cases covered
pipelineStatushistory; restoring it makes the runsreadable again through
GET /v1/services/ingestionPipelines/{fqn}/pipelineStatus/{runId}.dataContractResulthistory; restoring it makesGET /v1/dataContracts/{id}/results/latestwork again.GET /v1/apps/name/{name}/runs/latestwork again.contract after restore.
Unit tests
openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/IngestionPipelineRepositoryTest.java— 2 new tests (
postDeleteLeavesTheDeployedPipelineAloneOnSoftDelete,postDeleteTearsDownTheDeployedPipelineOnHardDelete). They exist because the DAG-teardown guard hasno runnable integration coverage (see NOT VERIFIED below), so without them collapsing
postDeletetosuper.postDelete(...); return false;would orphan a DAG against every hard-deletedpipeline with the whole suite still green. They assert behaviour, not interactions: the stubbed
runner throws on
deletePipeline, so whether the exception propagates out ofpostDeleteis theobservable signal that the orchestrator was reached.
PipelineServiceClientInterfaceis a trueexternal boundary, which is what CLAUDE.md sanctions mocking.
Tests run: 66, Failures: 0, Errors: 0, Skipped: 0across the touched and adjacent classes(
IngestionPipelineRepositoryTest24/24,EntityRepositoryRestoreTest,TestSuiteRepositoryTest,TestCaseRepositoryTest,AppRepositoryStorageStrippingTest,DataContractFieldSupportTest,IngestionPipelineStatusIndexTest).from the
openmetadata-serviceunit-test run:IngestionPipelineRepository13.9% (95/684),AppRepository3.3% (9/272),DataContractRepository0.0% (0/826).These are genuinely low and I am not dressing them up: these repositories are almost entirely
covered by
openmetadata-integration-tests, which runs in a separate module against a live serverand is not instrumented by this JaCoCo run, so the figures measure the unit-test slice only, not
the real coverage of the changed behaviour. The changed hooks themselves are exercised by the 2 unit
tests plus the 8 integration tests below. Raising the module-level unit figure would mean unit-testing
repositories that this codebase deliberately tests through integration tests.
Backend integration tests
openmetadata-integration-tests/.openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SoftDeleteRetentionIT.java(8 tests).entity_extension_time_series/apps_extension_time_series. This is required, not a stylistic choice: a soft-deleted entity'sstatuses aren't readable through the API anyway, so an API-level check passes even when the rows
have already been destroyed. Each test then re-reads the history through the public API after the
restore.
expected: <1> but was: <0>on the row counts, and a 404 on thehard-deleted test suite). The four paired hard-delete tests pass both pre- and post-fix by
design — they are regression guards against over-correcting into leaked orphan rows, not RED
evidence for the bug.
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0.DataContractResourceIT,TestSuiteResourceIT,DataContractPermissionIT→Tests run: 600, Failures: 0, Errors: 0, Skipped: 45.K8sIngestionPipelineResourceIT.test_deletePipeline_withK8sBackendencoded the old behaviour (itsoft-deleted, then asserted the CronJob/ConfigMap were gone). Updated to hard-delete — same
assertions, correct delete mode. No assertion was weakened.
Ingestion integration tests
Playwright (UI) tests
Manual testing performed
Run against a local stack.
entityFQNHashis notmd5('<fqn>')—FullyQualifiedName.buildHashsplits the FQN and MD5s each segment, so join on the entity's own stored
fqnHashinstead.A — IngestionPipeline
PUT /v1/services/ingestionPipelines/<fqn>/pipelineStatuswith{"runId":"manual-1","pipelineState":"success","timestamp":1700000000000}; confirmGET .../pipelineStatus/manual-1→ 200. Record the baseline:DELETE /v1/services/ingestionPipelines/<id>→ 200(before: an
IngestionRunnerUnavailableExceptionerror).orchestrator (before: deleted).
PUT /v1/services/ingestionPipelines/restore{"id":"<id>"}→ 200, thenGET .../pipelineStatus/manual-1→ 200 withrunId: manual-1(before: 404).DELETE /v1/services/ingestionPipelines/<id>?hardDelete=true, then using the saved hash:SELECT COUNT(*) FROM entity_extension_time_series WHERE entityFQNHash = '<fqnHash>' AND extension = 'ingestionPipeline.pipelineStatus'→ 0, and the DAG is gone.B — DataContract
testSuite.id.PUT /v1/dataContracts/<id>/resultswith aSuccessresult; confirmGET /v1/dataContracts/<id>/results/latest→ 200. Record the baseline:DELETE /v1/dataContracts/<id>; re-run the count → still 1 (before: 0).GET /v1/dataQuality/testSuites/<testSuiteId>?include=all→ 200, not deleted(before: 404 — the suite and its DQ pipeline had been hard-deleted).
PUT /v1/dataContracts/restore{"id":"<id>"}→ 200;GET /v1/dataContracts/<id>/results/latest→ 200 with the result from step 2 (before: gone).
DELETE /v1/dataContracts/<id>?hardDelete=true→ the test suite 404s and the row count via thesaved hash → 0.
SELECT COUNT(*) FROM test_suite WHERE name LIKE '%<contractName>%'stays 0 (before:getOrCreateTestSuitecreated one on the delete path).C — App
GET /v1/apps/name/<appName>/runs/latest→ 200.DELETE /v1/apps/<id>;SELECT COUNT(*) FROM apps_extension_time_series WHERE appId = '<id>' AND extension = 'status'→ ≥ 1 (before: 0).
PUT /v1/apps/restore{"id":"<id>"}→ 200;GET /v1/apps/name/<appName>/runs/latest→ 200 withthe pre-delete run (before: "no status found").
DELETE /v1/apps/<id>?hardDelete=true→ the count goes to 0 whileextension = 'limits'rows areuntouched.
NOT VERIFIED — please weigh these in review:
postDeletecalls the orchestrator onhard delete and not on soft delete; they do not prove the real K8s/Airflow client then removes the
CronJob. That assertion lives in
K8sIngestionPipelineResourceIT, which is@Disabledin the repo(
"Flaky: pipelineServiceClient is null in CI"), so my edit to it is unverified by a run.updatedByaudit-trail threading.hardDeleteAdditionalChildrennow credits the realoperator instead of a hard-coded
ADMIN_USER_NAMEwhen deleting the contract's test suite. Every ITauthenticates as
admin, so no automated test can tell the two apart — this is covered by manualstep B only.
UI screen recording / screenshots:
Not applicable — backend-only change (
openmetadata-service+openmetadata-integration-tests), noUI files touched.
Checklist:
Fixes <issue-number>: <short explanation>Fixes #<issue-number>above.SoftDeleteRetentionIT,which references issue [Bug] Soft delete permanently destroys time series data for IngestionPipeline, DataContract, and App — restore cannot recover it #27040 in its class JavaDoc).
🤖 Generated with Claude Code
Greptile Summary
This PR binds irreversible ingestion-pipeline, data-contract, and app cleanup to hard deletion while preserving time-series history and related resources across soft delete and restore.
Confidence Score: 5/5
The PR appears safe to merge; no actionable changed-code defects were identified.
The revised lifecycle hooks preserve reversible state on soft delete while the direct and bulk hard-delete flows still invoke the required time-series, DAG, and logical-suite cleanup before entity storage is removed.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD D[Delete entity] --> H{Hard delete?} H -->|No| S[Mark entity deleted] S --> R[Retain time-series history and related runtime resources] R --> X[Restore can expose retained history] H -->|Yes| C[Run hard-delete cleanup] C --> T[Delete time-series rows] C --> P[Remove deployed ingestion DAG] C --> Q[Delete contract logical test suite] C --> E[Delete entity relationships and storage]Reviews (1): Last reviewed commit: "Fixes 27040: move the postDelete contrac..." | Re-trigger Greptile
Context used (3)