Repository navigation
Conversation
There was a problem hiding this comment.
Pull request overview
Extends JDBC metastore cleanup so JdbcBasePersistenceImpl.deleteAll() purges additional realm-scoped tables introduced in newer schema versions (events in v3+, scan/commit metrics report tables in v4+), preventing stale data from remaining after a realm-scoped delete.
Changes:
- Update
JdbcBasePersistenceImpl.deleteAll()to delete fromevents,scan_metrics_report, andcommit_metrics_reportwhen the schema version supports them. - Add a new integration-style test that populates all realm-scoped tables in schema v5 and verifies
deleteAll()purges only the target realm.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImpl.java | Extends deleteAll() to delete newer realm-scoped tables, gated by schema version. |
| persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/JdbcBasePersistenceImplTest.java | Adds coverage verifying realm-scoped purge behavior across all relevant tables for schema v5. |
| if (schemaVersion >= 3) { | ||
| datasourceOperations.execute( | ||
| connection, | ||
| QueryGenerator.generateDeleteQuery( | ||
| ModelEvent.ALL_COLUMNS, ModelEvent.TABLE_NAME, params)); | ||
| } | ||
| if (schemaVersion >= 4) { | ||
| datasourceOperations.execute( | ||
| connection, | ||
| QueryGenerator.generateDeleteQuery( | ||
| ModelScanMetricsReport.ALL_COLUMNS, | ||
| ModelScanMetricsReport.TABLE_NAME, | ||
| params)); | ||
| datasourceOperations.execute( | ||
| connection, | ||
| QueryGenerator.generateDeleteQuery( | ||
| ModelCommitMetricsReport.ALL_COLUMNS, | ||
| ModelCommitMetricsReport.TABLE_NAME, | ||
| params)); | ||
| } |
There was a problem hiding this comment.
make sense. added tests for all versions
| connection, | ||
| QueryGenerator.generateDeleteQuery( | ||
| ModelScanMetricsReport.ALL_COLUMNS, | ||
| ModelScanMetricsReport.TABLE_NAME, |
There was a problem hiding this comment.
The javadoc on deleteAll mentions only "entity and grant record metadata".
deleteAll() is specified by BasePersistence, so I do not think it applies to events and metrics, which are under MetricsPersistence. It's currently detached from BasePersistence in terms of interfaces and its implementation is moving to extensions in #5068 🤔
I believe purging metric should be done under a separate method call and probably after merging #5068.
IMHO, conceptually, events should also not be bound to BasePersistence either. I'd like to isolate them link metrics and then add dedicated maintenance tasks for purging (see my other comment).
WDYT?
| datasourceOperations.execute( | ||
| connection, | ||
| QueryGenerator.generateDeleteQuery( | ||
| ModelEvent.ALL_COLUMNS, ModelEvent.TABLE_NAME, params)); |
There was a problem hiding this comment.
Events a time-series type of data. There may be a lot more of them in the database than Meta Store data. I'm not sure issuing a broad DELETE for them in one Tx is a robust approach.
I tend to think it would be preferable to handle their purge asynchronously and in a retriable / staged manner similar to NoSQL maintenance: https://github.com/apache/polaris/blob/apache-polaris-1.6.0/persistence/nosql/persistence/maintenance/api/src/main/java/org/apache/polaris/persistence/nosql/maintenance/api/package-info.java
|
Thanks @dimas-b — agreed. We shouldn’t extend I’ll revisit after #5068 and the linked PRs land, once metrics are decoupled from Thanks — I’ll park this for now and circle back. |
|
This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days. |
|
Not stale |
flyingImer
left a comment
There was a problem hiding this comment.
The async batching direction makes sense, but cleanup needs to remain safe across schema and realm lifecycle transitions.
| params)); | ||
| // Queue time-series data (events, metrics) for async batched purge instead of | ||
| // deleting inline. realm_purge exists from schema v5 only. | ||
| if (schemaVersion >= 5) { |
There was a problem hiding this comment.
If I didn't miss any context, this needs a migration path for existing databases and support in the current bootstrap schema. Existing v5 databases and the documented v3/v4-to-v5 upgrade do not create realm_purge, so the new enqueue fails and rolls back purge. Current main also bootstraps v6, whose scripts do not create this table. Could we install the queue on both paths and cover them with upgrade and fresh-bootstrap tests?
| String sql = | ||
| "DELETE FROM " | ||
| + fqTable | ||
| + " WHERE realm_id = ? AND " |
There was a problem hiding this comment.
IIUC, this can also delete events and metrics from a realm bootstrapped after the purge. bootstrapRealms does not check the pending marker, so purge R, bootstrap R, new writes, then purge-drain reaches this DELETE with the same realm_id.
If so, could we preserve the new realm's data, either by preventing bootstrap while cleanup is outstanding or by scoping the task to the retired realm incarnation? A regression test for that sequence would make the lifecycle guarantee explicit IMO
| long staleClaimBeforeMs = nowMs - claimLeaseMs; | ||
| int drained = 0; | ||
| for (String realmId : store.listClaimable(staleClaimBeforeMs)) { | ||
| if (!store.claim(realmId, node, nowMs, staleClaimBeforeMs)) { |
There was a problem hiding this comment.
IIRC, this lease does not enforce the documented one-drainer-per-realm guarantee. nowMs is reused for the whole run and the batch loop never renews or checks ownership, so another node can take an expired claim while the first keeps deleting, duplicating database work. The first node can then call complete, which removes the marker without checking the current owner. Could we use fresh claim times and retain ownership checks through draining and completion, with an expiry/takeover test?
|
This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days. |
|
Have rebased the PR and resolved conflicts & have addressed the review comments |
orca-review-bot
left a comment
There was a problem hiding this comment.
Nice rework. The lease/renew/owner-gated complete flow and the requested_at cutoff hold up well, and the H2 tests cover the tricky takeover paths.
One real hole: purging the same realm name twice before a drain finishes orphans the data written in between (see inline on RealmPurgeStore). Also inline: the missing events index and the new core SPI method.
Test gaps not tied to a line:
- a purge→bootstrap→write→purge→drain case in
RealmPurgeTest PurgeDrainCommandTestBaseonly checks the output string. Can it write some events first and assert they're gone, so the producers are verified on PG/CRDB?- nothing exercises a v6 DB upgraded with the CHANGELOG DDL
The PR checklist is also still blank. Security-wise I see nothing new: the command is operator-only and all values are bound parameters.
🤖 AI-generated review comment — agents make mistakes, so please verify before acting on it.
| String sql = | ||
| "INSERT INTO " | ||
| + TABLE | ||
| + " (realm_id, requested_at, status) VALUES (?, ?, 'pending') ON CONFLICT DO NOTHING"; |
There was a problem hiding this comment.
If a realm name is purged, re-bootstrapped, used, and purged again before the drain runs, this keeps the first requested_at. The drain only deletes rows older than that first purge. Then complete drops the marker, so whatever the second incarnation wrote is orphaned for good, which is the bug this PR set out to fix. Can we make this an upsert that moves requested_at forward and resets status to pending? And add a test for purge→bootstrap→write→purge→drain?
🤖 AI-generated review comment — agents make mistakes, so please verify before acting on it.
| claimed_by TEXT, | ||
| claimed_at BIGINT, | ||
| PRIMARY KEY (realm_id) | ||
| ); |
There was a problem hiding this comment.
events only has a primary key on event_id. Unlike the two metrics tables, it has no (realm_id, timestamp_ms) index, so every purgeBatch subquery on events can scan the whole table, and the drain repeats that once per batch. Since v7 is already a required migration, can we add CREATE INDEX IF NOT EXISTS ... ON events(realm_id, timestamp_ms) here (and in the CRDB/H2 scripts and the CHANGELOG DDL)?
🤖 AI-generated review comment — agents make mistakes, so please verify before acting on it.
| * @param claimLeaseMs how long a drain claim is honored before another node may resume it | ||
| * @return the number of realms fully drained during this run | ||
| */ | ||
| default int drainRealmPurges(int batchSize, long claimLeaseMs) { |
There was a problem hiding this comment.
This adds a method to the core MetaStoreManagerFactory SPI that only the JDBC backend implements. NoSQL already keeps its maintenance behind a backend-specific nosql subcommand. Could purge-drain be a JDBC-scoped subcommand the same way, or has this interface change been discussed on the dev list?
🤖 AI-generated review comment — agents make mistakes, so please verify before acting on it.
| } | ||
|
|
||
| /** Records that {@code realmId}'s time-series data should be purged asynchronously. */ | ||
| public void enqueue(String realmId, long nowMs) { |
There was a problem hiding this comment.
Production code never calls enqueue, because deleteAll goes straight to enqueueInTransaction. Same with RealmDataPurger.storeName(), which nothing calls. Can we drop them, or use storeName in the failure warning so you can tell which table broke?
🤖 AI-generated review comment — agents make mistakes, so please verify before acting on it.
| [v5-crd]: https://github.com/apache/polaris/blob/apache-polaris-1.7.0/persistence/relational-jdbc/src/main/resources/cockroachdb/schema-v5.sql | ||
| [v6-pg]: https://github.com/apache/polaris/blob/apache-polaris-1.8.0/persistence/relational-jdbc/src/main/resources/postgres/schema-v6.sql | ||
| [v6-crd]: https://github.com/apache/polaris/blob/apache-polaris-1.8.0/persistence/relational-jdbc/src/main/resources/cockroachdb/schema-v6.sql | ||
| [v7-pg]: https://github.com/apache/polaris/blob/apache-polaris-1.9.0/persistence/relational-jdbc/src/main/resources/postgres/schema.sql |
There was a problem hiding this comment.
v4–v6 link to schema-vN.sql snapshots, but these point at schema.sql on the 1.9.0 tag. Is a schema-v7.sql snapshot planned at release, or should these links change?
🤖 AI-generated review comment — agents make mistakes, so please verify before acting on it.
| ); | ||
|
|
||
| -- Supports the realm purge drain, which deletes a realm's events older than a cutoff. | ||
| CREATE INDEX IF NOT EXISTS idx_events_realm_timestamp ON events (realm_id, timestamp_ms); |
There was a problem hiding this comment.
If we only use it for purge, do we care about ordered deletes? Could we select a few PKs then delete them in a batch?.. or just do repeated DELTEs with a LIMIT?
I know it's overhead, but for purging it's probably manageable (async). On the other hand maintaining an index that is only used for purging will incur overhead in ordinary writes.
There was a problem hiding this comment.
Fair point — dropped. The index is gone from all three schema.sql scripts and from the v7 migration DDL, and the drain may sequential-scan events.
On the alternatives: the current statement already selects PKs and deletes them in a batch — the subquery is a PK select with a LIMIT, in one round-trip (DELETE ... LIMIT isn't available on PostgreSQL, hence that shape). So the cost isn't ordering, it's finding the realm's rows, and that now falls on an operator-scheduled background command rather than any request path. Seems the right trade.
| Polaris. It adds the `realm_purge` table, which records realms whose time-series data is still | ||
| pending deletion, and an index on `events (realm_id, timestamp_ms)` that the drain relies on: | ||
| ```sql | ||
| CREATE TABLE IF NOT EXISTS polaris_schema.realm_purge ( |
There was a problem hiding this comment.
Would it be preferable to keep actual upgrade SQL in https://polaris.apache.org/releases/1.8.0/metastores/relational-jdbc/#schema-upgrades ?
There was a problem hiding this comment.
Yes — and DatasourceOperations spells out exactly that: step 3 is "add the corresponding migration SQL to the Relational JDBC metastore documentation", step 4 "add a note to the changelog". I had it backwards.
Moved: the metastore docs now have a "Migrating to schema v7" section under Schema Upgrades with the DDL, and the CHANGELOG is back to a note linking there.
dimas-b
left a comment
There was a problem hiding this comment.
I'm fine with merging this PR "as is", but leaving some usability comments, in case you might also find them reasonable 🙂
| + TIMESTAMP_COLUMN | ||
| + " < ? LIMIT ?)"; | ||
| try { | ||
| return datasourceOperations.executeUpdate( | ||
| new PreparedQuery(sql, List.of(realmId, realmId, cutoffMs, batchSize))); |
There was a problem hiding this comment.
The timestamp relates to the data in the row (e.g. an event), but the cutoffMs relates to the wall clock time of the purge request. These values may not be logically comparable to each other 🤔
That said, I think in most cases this logic should work fine.
| return EXIT_CODE_USAGE; | ||
| } | ||
| int drained = | ||
| realmPurgeService.drainOnce(RealmPurgeService.localNodeId(), batchSize, claimLeaseMs); |
There was a problem hiding this comment.
Currently localNodeId is rather random. It does not help in identifying the runner.
WDYT about using ProcessHandle.current().pid() instead or in addition to the UUID in the context of the Admin Tool?
Also, it does not feel like localNodeId() belongs in RealmPurgeService. It might as well be a local method here 🤔
| return EXIT_CODE_USAGE; | ||
| } | ||
| int drained = | ||
| realmPurgeService.drainOnce(RealmPurgeService.localNodeId(), batchSize, claimLeaseMs); |
There was a problem hiding this comment.
I guess a --realm option might be convenient to focus the action at user's discretion. WDYT?
| } | ||
| anyRemaining = false; | ||
| for (RealmDataPurger purger : purgers) { | ||
| if (purger.purgeBatch(realmId, pending.requestedAtMs(), batchSize) > 0) { |
There was a problem hiding this comment.
It would be nice to report progress back to CLI 🤔
| while (anyRemaining) { | ||
| if (!store.renew(realmId, node, clock.millis())) { | ||
| LOGGER.info( | ||
| "Stopped draining realm {} for purge: the claim is now held by another drainer", |
There was a problem hiding this comment.
When invoked from CLI, this message probably needs to do to the console 🤔
| while (anyRemaining) { | ||
| if (!store.renew(realmId, node, clock.millis())) { | ||
| LOGGER.info( | ||
| "Stopped draining realm {} for purge: the claim is now held by another drainer", |
There was a problem hiding this comment.
Could we report the other node ID?
| if (!JDBC_PERSISTENCE_TYPE.equals(persistenceConfiguration.type())) { | ||
| spec.commandLine() | ||
| .getErr() | ||
| .printf( | ||
| "purge-drain applies to the '%s' persistence type, but '%s' is configured.%n", | ||
| JDBC_PERSISTENCE_TYPE, persistenceConfiguration.type()); |
There was a problem hiding this comment.
tangential: Persistence type is only loosely coupled to metrics and events persistence. Ideally I think the can use different storage technology than the "Meta Store". In that case NoSQL MetaStore could be combined with JDBC Metrics persistence.
This is just a point for discussion. No need to change anything right now 😉
| compileOnly("com.fasterxml.jackson.core:jackson-annotations") | ||
|
|
||
| runtimeOnly(project(":polaris-relational-jdbc")) | ||
| implementation(project(":polaris-relational-jdbc")) |
|
|
||
| -- Queue of realms whose time-series data (events, metrics reports) is pending asynchronous, | ||
| -- batched deletion by the background purge drainer. One row per realm being purged. | ||
| CREATE TABLE IF NOT EXISTS realm_purge ( |
There was a problem hiding this comment.
I guess schema changes are similar to API spec changes, so it would be nice to have a dev discussion about this, if only for community awareness. WDYT?
9e6bde4 to
86c5f3c
Compare
| return new Outcome(false, rows); | ||
| } | ||
| anyRemaining = false; | ||
| for (RealmDataPurger purger : purgers) { |
There was a problem hiding this comment.
Once a purger returns 0 for this realm it stays at 0 (the cutoff is fixed), but it's still called every round. Since events has no (realm_id, timestamp_ms) index after dropping it, each of those calls is a full scan of events. A realm with ~1M metrics rows at the default batch of 1000 would scan events ~1000 times for nothing. WDYT about dropping a purger from the loop once it returns 0 (or fewer than batchSize)?
| claimable = claimable.stream().filter(p -> requested.contains(p.realmId())).toList(); | ||
| claimable.forEach(p -> requested.remove(p.realmId())); | ||
| // Naming the realms that hold no queued purge beats reporting nothing drained for them. | ||
| requested.forEach(r -> progress.accept("Realm " + r + " has no queued purge.")); |
There was a problem hiding this comment.
nit: listClaimable leaves out realms whose claim is still live, so --realm r while another node drains r reports "no queued purge". Could we tell these apart, e.g. "Realm r is being drained by ", using claimedBy?
Purging a realm on the relational-JDBC backend leaves its time-series data behind. deleteAll removes the realm's entities, grant records, principal authentication data and policy mappings, but nothing deletes its rows in events, scan_metrics_report and commit_metrics_report. Those rows are keyed by a realm id that no longer exists, so no later operation reaches them: purging the realm again does not, and age-based retention cannot, because there is no realm left to run it for. Deleting them inline is not the right fix. A realm with a long history can hold far more time-series rows than metadata rows, and one unbounded delete inside the purge transaction is the statement that makes a purge time out or hold locks for minutes. So the purge of that data becomes asynchronous, queued and batched. deleteAll enqueues the realm into a new realm_purge marker table as the last statement of its own transaction, so the marker never commits without the metadata delete and the metadata delete never commits without the marker. A new operator-run admin command, purge-drain, processes the queue: it claims each realm under a lease so only one drainer works a realm at a time across nodes, deletes its rows in bounded batches, then clears the marker. A realm whose drain fails or is interrupted keeps its marker and is retried once the lease expires, which makes the command safe to run repeatedly and concurrently and suitable for scheduling, the same model as nosql maintenance-run. Per-table purgers are discovered through CDI, so covering another realm-scoped table means adding a producer rather than editing the drainer. realm_purge is a new table, so this requires schema v7 and the migration must be applied before starting the new binary, as for v6. The migration SQL goes in the metastore documentation, with a note in the changelog. A drain is scoped to the realm incarnation that was purged: the marker records when the purge was requested, and a purger deletes only rows older than that, so a realm bootstrapped again under the same name before its drain runs keeps its own data. Purging a realm name again carries the marker forward to the later purge and releases any outstanding claim, so data written between two purges is still reclaimed, and a drain running against the earlier cutoff stands down rather than clearing a marker that no longer describes the work. A claim is timed when it is taken and extended before each batch, and the marker is retired only by the node that still holds it, so a drain that outlives its lease stops instead of racing the node that took it over, and cannot leave a realm with unreferenced data and no marker. No index is added for the drain. events carries only its primary key, so each batch scans for the realm's rows, which is accepted here rather than charging every event write for an index that only an occasional purge reads. A store is dropped from the drain once it returns fewer rows than the batch, since the cutoff is fixed and it can acquire none later, so the scan is paid once per store rather than once per round. The command resolves the drain from the JDBC backend's own CDI bean rather than through a persistence-SPI method, following how the admin tool reaches NoSQL maintenance, and reports a usage error on other backends. It accepts realms to restrict the drain to, and reports a line per realm as it works, naming the rows deleted and the drainer holding any realm it had to leave, so that an operator reading its output can tell what a count of drained realms means.
deleteAll(aBasePersistenceoperation) previously left a realm's time-series data —events,scan_metrics_report,commit_metrics_report— behind. Rather than fix that with a broad inline delete, this makes the time-series purge asynchronous, queued, and batched, matching somewhat the behavior of the NoSQL maintenance command.deleteAllno longer touches time-series tables. It atomically enqueues the realm into a newrealm_purgemarker table, as the last statement in its own transaction — so a purged realm is never left with orphaned time-series data, and the marker never commits without the metastore delete.purge-drain, processes the queue: it claims each realm under a lease (one active drainer per realm across nodes, via a conditionalUPDATE),deletesits rows in bounded batches, then clears the marker.A realm whose drain fails or is interrupted keeps its marker and is retried once its lease expires. It's safe to run repeatedly/concurrently and is meant to be scheduled periodically (e.g. once a day), like nosql maintenance-run.
Per-table purgers are discovered via
CDI (Instance<RealmDataPurger>), so adding a table means adding a producer, not editing the drainer.Checklist
CHANGELOG.md(if needed)site/content/in-dev/unreleased(if needed)