Skip to content

feat(versioning): entity-version base infrastructure (gated off, dark launch) - #41176

Merged
rusackas merged 4 commits into
apache:masterfrom
mikebridge:sc-111231-versioning-base-infra
Jul 10, 2026
Merged

feat(versioning): entity-version base infrastructure (gated off, dark launch)#41176
rusackas merged 4 commits into
apache:masterfrom
mikebridge:sc-111231-versioning-base-infra

Conversation

@mikebridge

@mikebridge mikebridge commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

SUMMARY

Lands the entity-versioning base infrastructure (schema + SQLAlchemy-Continuum wiring) gated off by default, so it deploys inert — a "base infra, dark" PR. Capture is activated later by flipping the gate default on, once validated in production.

With ENABLE_VERSIONING_CAPTURE=False (the shipped default), the before-flush capture listeners are not armed, so a save writes zero version_transaction rows, zero version_changes rows, and zero *_version shadow rows — proven by a behavioral test. The migrations are additive and inert; the read-only /versions/ list + get endpoints are wired but return empty until capture is enabled, and the new entity-PUT version fields all come back null (with no extra version queries fired) while the gate is off.

⚠️ Stacked PR. Built on top of the composite-PK reshape in #39859 (Continuum's M2M tracker needs that shape). Until #39859 merges, the diff below includes its commits — review only the versioning commits (from feat(versioning): entity-version base infrastructure (gated off) onward); everything else belongs to #39859.

What's included (all inert with the gate off):

  • Two additive Alembic migrations:
    • 56cd24c07170_add_versioning_tables — creates version_transaction (audit log keyed by Continuum's per-flush transaction), version_changes (field-level diff log), and the *_version shadow tables (dashboards_version, slices_version, tables_version, and their child tables).
    • 8f3a1b2c4d5e_shadow_live_row_indexes — additive indexes on the shadow tables' live rows.
      Both have a real, tested downgrade().
  • Continuum wiring: make_versioned(), the version-transaction factory, the versioning Flask plugin, and the superset/versioning/ capture machinery (baseline, change-records, diff engine).
  • The ENABLE_VERSIONING_CAPTURE gate (default off), a permanent operational kill-switch.
  • Read-only endpoints (see API below): GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/ and GET .../versions/<version_uuid>/.
  • New version-identifier fields on the entity PUT response and an ETag header on entity reads/writes (see API).
  • Mapper-level correctness (reset_ownership, UUID coercion) so existing import/clone paths behave correctly with the versioned mappers present.

Deferred to follow-ups: version restore, the cross-entity activity view, version-history retention/prune, and the frontend UI.

THIS IS A RELEASE TOGGLE / OPS KILL-SWITCH, NOT A LONG-LIVED FEATURE FLAG

ENABLE_VERSIONING_CAPTURE is not a long-lived product feature flag. In the Continuous Delivery taxonomy it begins as a Release Toggle — its only job is to decouple deploy from release, so this not-yet-validated, high-blast-radius change (Continuum writes on every flush) can land on master and ship dark, then be switched on once trusted. It does not parameterize product behavior, gate an experiment, or vary per tenant.

Because capture is an active write path with a measurable save-path cost, once validated the default flips off→on and the switch is retained as a permanent Ops Toggle — an operational kill-switch giving a ~30-second recovery (flip off, restart workers) if a versioning-induced regression appears in production, instead of revert-and-redeploy.

References: Martin Fowler — Feature Toggles, "Release Toggles" / "Ops Toggles".

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — backend, gated off; no user-visible change on deploy.

API

All endpoints below are read-only and key on the entity UUID (not the integer pk — note this differs from the dataset API's other routes). They require the resource's read permission via @protect() and run the per-object access gate (security_manager.raise_for_access). Marshmallow schemas live in superset/versioning/schemas.py and are registered in each API's openapi_spec_component_schemas.

1. List version history

GET /api/v1/chart/<uuid>/versions/
GET /api/v1/dashboard/<uuid>/versions/
GET /api/v1/dataset/<uuid>/versions/

  • Path param: uuid_str (entity UUID).
  • 200 response:
    { "result": [ /* VersionListItemSchema, oldest first */ ], "count": 0 }
  • Also sets an ETag header (current live version_uuid).
  • 400 invalid UUID · 403 no access · 404 entity not found / no version rows.

2. Get a single version snapshot

GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/<version_uuid>/

  • Path params: uuid_str (entity UUID), version_uuid_str (a version_uuid from the list endpoint).
  • 200 response:
    { "result": { "/* entity scalar fields at the target version */": "...", "_version": { "/* VersionListItemSchema */": "..." } } }
  • Also sets an ETag header. 400 / 403 / 404 as above.

VersionListItemSchema (superset/versioning/schemas.py):

Field Type Notes
version_uuid uuid Deterministic UUIDv5 (entity UUID + Continuum transaction id); stable across replicas/pruning. The handle the get endpoint accepts.
version_number int 0-based position, oldest first.
transaction_id int Underlying Continuum transaction id.
operation_type string baseline | update | delete (restore surfaces as an ordinary update).
issued_at datetime ISO-8601 UTC commit timestamp.
changed_by object | null {id, username, first_name, last_name}; null for CLI/Celery/import commits.
changes array Field-level diff records {kind, path, from_value, to_value}; empty for baseline.

3. Entity PUT — new response fields + ETag

PUT /api/v1/{chart,dashboard,dataset}/<pk> gains six nullable body fields and an ETag header. All are null (and no version queries are issued) when capture is off:

Field Type Notes
old_version / new_version int | null 0-based version_number of the live row before / after the update.
old_transaction_id / new_transaction_id int | null Continuum transaction id before / after.
old_version_uuid / new_version_uuid uuid | null Deterministic version_uuid before / after.

The entity single-object GET /<pk> response also carries an ETag header (current live version_uuid). CORS_OPTIONS now sets expose_headers: ["ETag"] so cross-origin fetch() clients can read it.

TESTING INSTRUCTIONS

# Behavioral proof of the dark-launch contract (Postgres):
pytest tests/integration_tests/versioning/capture_disabled_tests.py
#   capture-off  -> zero version_transaction, version_changes, and shadow rows
#   capture-on control -> at least one shadow row + one version_changes record

# Snapshot projection + diff-cap coverage:
pytest tests/integration_tests/versioning/snapshot_projection_tests.py
pytest tests/unit_tests/versioning/test_diff_caps.py

QA NOTES

  • Default-off / dark launch: with the shipped default, saving a chart/dashboard/dataset writes zero version_transaction, version_changes, and *_version shadow rows. Confirm no save-path latency regression.
  • PUT response, capture off: PUT on each entity returns the six version fields all null, and issues no extra version queries.
  • /versions/ endpoints, capture off: list returns {"result": [], "count": 0} (or 404 if the entity isn't found); the get-single-version endpoint 404s. Endpoints reachable read-only.
  • Capture on (ENABLE_VERSIONING_CAPTURE=True, restart workers): a save mints a version_transaction, version_changes records, and a shadow row; /versions/ returns populated rows oldest-first with a count; the get endpoint returns the snapshot + _version block.
  • ETag: entity single GET, entity PUT, and both /versions/ responses carry an ETag (current live version_uuid) when capture is on; absent when there are no version rows. Verify cross-origin fetch() can read it.
  • AuthZ: /versions/ honors read permission + per-object access — no access → 403; bad UUID → 400; unknown entity/version → 404.
  • Path keying: endpoints key on entity UUID, including datasets (whose other routes use integer pk).
  • Migrations: 56cd24c07170 and 8f3a1b2c4d5e upgrade cleanly, are additive, and downgrade() restores prior schema. Test on Postgres and MySQL.
  • Regression surfaces: import/clone/duplicate paths (dashboard copy, dataset duplicate, importers) still behave correctly with versioned mappers present.

ADDITIONAL INFORMATION

  • Has associated issue: SIP: Entity versioning & change history
  • Required config flag: ENABLE_VERSIONING_CAPTURE (default off this release; a release toggle that flips on after soak and is retained as a permanent operational kill-switch — not removed).
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible (two additive revisions: 56cd24c07170, 8f3a1b2c4d5e)
    • Confirm DB migration upgrade and downgrade tested
  • Introduces new feature or API (read-only /versions/ endpoints + new PUT response fields, gated off by default)
  • Removes existing feature or API

Rebased onto current master (the composite-PK change #39859 merged standalone; its migration + tests are de-duped out of this branch).

@github-actions github-actions Bot added risk:db-migration PRs that require a DB migration api Related to the REST API risk:ci-script PR modifies scripts that execute in CI (supply chain risk) labels Jun 17, 2026
@netlify

netlify Bot commented Jun 17, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 07051f7
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a4ec067a834090008dd2ae2
😎 Deploy Preview https://deploy-preview-41176--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.68250% with 270 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.75%. Comparing base (3a10f39) to head (38bb2b0).
⚠️ Report is 9 commits behind head on master.

Files with missing lines Patch % Lines
superset/versioning/diff.py 82.87% 36 Missing and 14 partials ⚠️
superset/versioning/queries.py 75.40% 21 Missing and 9 partials ⚠️
superset/initialization/__init__.py 50.00% 22 Missing and 3 partials ⚠️
superset/versioning/changes/listener.py 81.06% 20 Missing and 5 partials ⚠️
superset/versioning/baseline/children.py 51.06% 23 Missing ⚠️
superset/versioning/changes/state.py 76.00% 10 Missing and 8 partials ⚠️
superset/versioning/factory.py 85.18% 12 Missing and 4 partials ⚠️
superset/versioning/baseline/listener.py 64.28% 11 Missing and 4 partials ⚠️
superset/versioning/baseline/dirty.py 81.66% 7 Missing and 4 partials ⚠️
superset/versioning/api_helpers.py 87.83% 4 Missing and 5 partials ⚠️
... and 13 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #41176      +/-   ##
==========================================
+ Coverage   64.60%   64.75%   +0.14%     
==========================================
  Files        2712     2733      +21     
  Lines      151114   152643    +1529     
  Branches    34751    35011     +260     
==========================================
+ Hits        97632    98838    +1206     
- Misses      51659    51909     +250     
- Partials     1823     1896      +73     
Flag Coverage Δ
hive 38.93% <34.05%> (-0.12%) ⬇️
mysql 57.93% <81.68%> (+0.43%) ⬆️
postgres 57.98% <81.68%> (+0.42%) ⬆️
presto 40.90% <56.91%> (+0.34%) ⬆️
python 59.37% <81.68%> (+0.41%) ⬆️
sqlite 57.59% <81.68%> (+0.44%) ⬆️
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mikebridge
mikebridge force-pushed the sc-111231-versioning-base-infra branch 4 times, most recently from 5f4a3a6 to 880efe5 Compare June 29, 2026 17:50
@mikebridge
mikebridge marked this pull request as ready for review June 29, 2026 17:50
@dosubot dosubot Bot added the change:backend Requires changing the backend label Jun 29, 2026
@mikebridge
mikebridge force-pushed the sc-111231-versioning-base-infra branch from 880efe5 to b7c5e3d Compare June 30, 2026 15:56
@mikebridge

mikebridge commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Since it touches superset/migrations/, this still needs a migrations codeowner to clear the CODEOWNERS gate before it can merge.

@mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe — would any of you have a little time to take a look? It ships dark behind ENABLE_VERSIONING_CAPTURE (off by default), so the deploy blast radius is limited, and I'm happy to walk through the migration or anything else. Thanks! 🙏

@rusackas

(Posted via Claude on my behalf.)

@mikebridge

Copy link
Copy Markdown
Contributor Author

A multi-model review pass surfaced one correctness gap worth recording. It doesn't block this PR — it's only reachable with ENABLE_VERSIONING_CAPTURE on, and this ships dark/off — but it should be fixed before the flag is ever flipped:

Parent baseline capture isn't SAVEPOINT-isolated on PostgreSQL. In superset/versioning/baseline/insertion.py, the direct-SQL baseline inserts run under try/except + no_autoflush but not a begin_nested(). On PostgreSQL a failed statement aborts the whole transaction even when the exception is caught, so a baseline-insert failure would poison the user's save — contradicting the "versioning must never break a save" guarantee. The change-record path (changes/listener.py) and the child-baseline path (baseline/collection.py:133) already wrap their direct SQL in session.connection().begin_nested(); the parent-baseline path is the one spot that doesn't.

Rather than disturb this PR at the merge gate, I've put the one-line fix in a small follow-up: #41910 (draft, stacked on this).

(Posted via Claude on my behalf.)


@expose(
"/<uuid_str>/versions/<version_uuid_str>/",
methods=("GET",),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikebridge can you check to make sure that this is using an existing perm rather than creating a new one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep — list_versions and get_version are both mapped to read in MODEL_API_RW_METHOD_PERMISSION_MAP (which method_permission_name spreads in), so they resolve to the existing can_read_Dashboard permission — no new perm is minted. The per-object raise_for_access(dashboard=…) inside list_versions_endpoint adds the object-level gate on top.

(Replying via Claude on my behalf.)

Comment thread superset/datasets/api.py Outdated
"Error refreshing dataset during update %s: %s",
self.__class__.__name__,
str(ex),
exc_info=True,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you use exception instead of error, you won't need the exc_info

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — done in 38bb2b0. logger.exception keeps the ERROR-level traceback without the explicit exc_info.

(Replying via Claude on my behalf.)

fk_col = getattr(child_table.c, fk_column_name)

rows = (
conn.execute(sa.select(child_table).where(fk_col == parent_obj.id))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to use SQLAlchemy instead of executing raw sql?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are SQLAlchemy Core (conn.execute(sa.select(...))) rather than raw text() — parameterized, no string SQL. They run on session.connection() instead of the ORM session.query deliberately: baseline capture fires inside before_flush, and an ORM query there would trigger an autoflush of Continuum's in-progress transaction mid-flush. Reading straight off the connection avoids that re-entrancy. Happy to add an inline comment making that explicit if it'd help.

(Replying via Claude on my behalf.)


attached_slice_ids = [
r.slice_id
for r in conn.execute(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same thing here for raw execution

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here — Core sa.select on the connection, not raw SQL; see the note on the select just above (avoids an autoflush re-entrancy inside before_flush).

(Replying via Claude on my behalf.)

child_map = child_to_parent_registry()
for obj in list(session.dirty) + list(session.new) + list(session.deleted):
if type(obj) in VERSIONED_MODELS:
parents[id(obj)] = obj

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you use get here instead of grabbing the value directly with the key?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make sure I'm on the right line — on current HEAD, collection.py:66 is parents[id(obj)] = obj (a dict assignment), and the child_map lookup just below already uses .get(). Could you point me at the exact access you'd like switched? The anchor may have drifted after the recent master rebase.

(Replying via Claude on my behalf.)

Per review: logger.exception already logs at ERROR with the traceback, so
exc_info=True is redundant. Same behavior, less boilerplate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#: ISO-8601 (Flask's default JSON provider renders datetimes as RFC-1123
#: http-dates) and ``version_uuid`` consistently a string (the list rows
#: carry UUID instances, the snapshot block pre-stringifies).
_version_item_schema = VersionListItemSchema()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add an explicit type annotation to this module-level schema variable to satisfy the type-hint requirement for relevant variables. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

The custom rule requires type hints on relevant variables when they can be annotated. This module-level schema instance is newly introduced and lacks an explicit type annotation, so the suggestion correctly identifies a real violation.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/versioning/api_helpers.py
**Line:** 53:53
**Comment:**
	*Custom Rule: Add an explicit type annotation to this module-level schema variable to satisfy the type-hint requirement for relevant variables.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

json.loads(position_json)
)

dashboard = DashboardDAO.update(self._model, self._properties)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add an explicit type annotation to the newly introduced local variable so it conforms to the required type-hinting rule. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

The new local variable dashboard is introduced in the updated Python code without an explicit type annotation, and it is clearly annotatable from the return type of DashboardDAO.update(...). This matches the rule requiring type hints on new or modified Python variables that can be annotated.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/dashboard/update.py
**Line:** 86:86
**Comment:**
	*Custom Rule: Add an explicit type annotation to the newly introduced local variable so it conforms to the required type-hinting rule.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread superset/daos/dataset.py
# the old-named row is deleted in the same flush — INSERTs flush
# before DELETEs, so that collides on the PK / UNIQUE(table_id,
# column_name) constraints. ``table_id`` is pinned to *model*.
protected_keys = ("id", "table_id")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add an explicit type annotation for this local variable to satisfy the type-hint requirement for relevant variables. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

The variable is a newly added local constant with a clear, inferable tuple type, so it can be annotated under the Python type-hint rule. This is a real omission in the new code.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/daos/dataset.py
**Line:** 485:485
**Comment:**
	*Custom Rule: Add an explicit type annotation for this local variable to satisfy the type-hint requirement for relevant variables.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread superset/daos/dataset.py
# incoming column whose name case-insensitively matches a removed column
# (a case-only rename under a case-insensitive collation, e.g. MySQL)
# would collide on ``UNIQUE(table_id, column_name)`` mid-flush.
deleted_any = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Declare this boolean local with an explicit type hint to comply with the project’s typing rule. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

The boolean flag is newly introduced and could be explicitly annotated as a typed local variable. This matches the stated type-hint rule for relevant variables.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/daos/dataset.py
**Line:** 501:501
**Comment:**
	*Custom Rule: Declare this boolean local with an explicit type hint to comply with the project’s typing rule.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread superset/daos/dataset.py
setattr(metric, key, value)

# Delete removed metrics
ids_to_keep = property_metrics_by_id.keys()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add a type annotation for this derived local collection to keep relevant variables fully typed. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

This is a newly added derived collection with a non-obvious type, so it is a plausible target for an explicit annotation under the rule.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/daos/dataset.py
**Line:** 608:608
**Comment:**
	*Custom Rule: Add a type annotation for this derived local collection to keep relevant variables fully typed.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


# Live version identifiers before the update (empty + query-free when
# ``ENABLE_VERSIONING_CAPTURE`` is off).
old_info = current_entity_version_info(Dashboard, pk)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add an explicit type annotation for this newly introduced version metadata variable to satisfy the type-hint requirement for relevant variables. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

This is newly added Python code that introduces a local variable without an explicit type hint, and the returned value is a well-defined version-info object that can be annotated, so it matches the type-hint rule.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/dashboards/api.py
**Line:** 944:944
**Comment:**
	*Custom Rule: Add an explicit type annotation for this newly introduced version metadata variable to satisfy the type-hint requirement for relevant variables.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +951 to +953
new_info = current_entity_version_info(
Dashboard, changed_model.id, changed_model.uuid
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Add an explicit type annotation for this newly introduced variable holding updated version metadata to comply with required type hints. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

This is also newly added Python code that introduces a local variable without an explicit type hint. The value comes from a typed helper returning a version-info record, so the omission is a real violation of the type-hint requirement.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/dashboards/api.py
**Line:** 951:953
**Comment:**
	*Custom Rule: Add an explicit type annotation for this newly introduced variable holding updated version metadata to comply with required type hints.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread superset/datasets/api.py
Comment on lines +580 to +582
new_info = current_entity_version_info(
SqlaTable, changed_model.id, changed_model.uuid
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Provide a concrete type hint for this new variable assignment so the added code remains fully typed. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

This added assignment omits a type annotation on a newly introduced local variable, which matches the type-hint requirement for modified Python code.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/datasets/api.py
**Line:** 580:582
**Comment:**
	*Custom Rule: Provide a concrete type hint for this new variable assignment so the added code remains fully typed.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread superset/datasets/api.py
new_info = current_entity_version_info(
SqlaTable, changed_model.id, changed_model.uuid
)
etag_version_uuid = new_info.version_uuid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Annotate this newly added variable with its expected type to satisfy the enforced typing rule. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

This is a newly introduced variable assignment in Python code without an explicit annotation, so it violates the stated type-hint rule if the variable is considered annotatable.

Rule source 📖

.cursor/rules/dev-standard.mdc (line 28)

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/datasets/api.py
**Line:** 583:583
**Comment:**
	*Custom Rule: Annotate this newly added variable with its expected type to satisfy the enforced typing rule.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

from superset import db
from superset.versioning.changes import ACTION_KIND_CLONE, ACTION_KIND_KEY

db.session.info[ACTION_KIND_KEY] = ACTION_KIND_CLONE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Setting a session-scoped action marker without guaranteed cleanup leaks state when versioning capture is disabled (the cleanup listeners are not registered in that mode). On a reused SQLAlchemy session, a later unrelated transaction can inherit this stale marker and be misclassified. Clear this key in a local try/finally (or a shared context manager) after the copy operation completes. [logic error]

Severity Level: Major ⚠️
- ❌ Dashboard version history mislabels later edits as clones.
- ⚠️ Operators misinterpret /versions timeline for affected dashboards.
- ⚠️ Multi-app off->on toggles risk polluted action_kind context.
Steps of Reproduction ✅
1. Initialize a Superset app with `ENABLE_VERSIONING_CAPTURE=False`, so
`SupersetAppInitializer.init_versioning()` returns early at
`superset/initialization/__init__.py:36-45` and never calls
`register_change_record_listener()`; as a result, the `after_commit` / `after_rollback`
cleanup handlers in `superset/versioning/changes/listener.py:214-247` are not registered.

2. Trigger a dashboard copy via the MCP tool, which calls `CopyDashboardCommand(source,
data).run()` at `superset/mcp_service/dashboard/tool/duplicate_dashboard.py:26-27`. Inside
`CopyDashboardCommand.run` (decorated with `@transaction` at
`superset/commands/dashboard/copy.py:40`), the code at
`superset/commands/dashboard/copy.py:55` executes `db.session.info[ACTION_KIND_KEY] =
ACTION_KIND_CLONE` before calling `DashboardDAO.copy_dashboard(...)` at line 56.

3. The `@transaction` decorator at `superset/utils/decorators.py:235-263` then calls
`db.session.commit()` on success (line 2 of the second chunk), but because the versioning
change-record listeners were never registered, no `after_commit` handler runs, and nothing
pops `ACTION_KIND_KEY` from `db.session.info`. The marker `"_versioning_action_kind":
"clone"` therefore persists on the long-lived SQLAlchemy session beyond the end of this
transaction.

4. In the same Python process, initialize another Superset app (e.g. in tests or multi-app
setups) with `ENABLE_VERSIONING_CAPTURE=True`, so `init_versioning()` proceeds into the ON
branch at `superset/initialization/__init__.py:47-76` and calls
`register_change_record_listener()` at line 859, attaching `flush_change_records` and
`_stamp_action_kind_on_transaction` from `superset/versioning/changes/listener.py:157-188`
and :49-72 to the existing `db.session`. The next dashboard update (e.g. `PUT
/api/v1/dashboard/<id>`, which calls `UpdateDashboardCommand(pk, item).run()` at
`superset/dashboards/api.py:11-18`) runs under this same session; on its first
`after_flush`, `_stamp_action_kind_on_transaction` reads and pops the stale
`ACTION_KIND_KEY` value set by the earlier copy and stamps
`version_transaction.action_kind='clone'` for this unrelated update, misclassifying the
transaction.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/dashboard/copy.py
**Line:** 55:55
**Comment:**
	*Logic Error: Setting a session-scoped action marker without guaranteed cleanup leaks state when versioning capture is disabled (the cleanup listeners are not registered in that mode). On a reused SQLAlchemy session, a later unrelated transaction can inherit this stale marker and be misclassified. Clear this key in a local try/finally (or a shared context manager) after the copy operation completes.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

# module docstring for the broader init-order rationale.
from superset.versioning.changes import ACTION_KIND_CLONE, ACTION_KIND_KEY

db.session.info[ACTION_KIND_KEY] = ACTION_KIND_CLONE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This writes a per-transaction marker into session.info but relies on versioning listeners for cleanup; with capture gated off, those listeners are detached, so the marker can persist and incorrectly label subsequent transactions on the same session. Ensure the key is removed in the command path itself (for both success and error paths). [logic error]

Severity Level: Major ⚠️
- ❌ Dataset version history may show normal changes as clones.
- ⚠️ Dataset duplicate endpoint /api/v1/dataset/<pk>/duplicate impacted.
- ⚠️ Off->on capture toggles can inherit stale clone markers.
Steps of Reproduction ✅
1. Start a Superset app with `ENABLE_VERSIONING_CAPTURE=False` so
`SupersetAppInitializer.init_versioning()` returns at
`superset/initialization/__init__.py:36-45` without calling
`register_change_record_listener()`, meaning the cleanup hooks that pop `ACTION_KIND_KEY`
in `reset_processed_after_commit` and `reset_action_kind_after_rollback`
(`superset/versioning/changes/listener.py:214-242`) are not attached.

2. Call the dataset duplicate endpoint `PUT /api/v1/dataset/<pk>/duplicate`
(OpenAPI-documented around `superset/datasets/api.py:790-829`), which loads the payload
and invokes `DuplicateDatasetCommand(item).run()` at `superset/datasets/api.py:18-20`.
Inside `DuplicateDatasetCommand.run` (decorated with `@transaction` at
`superset/commands/dataset/duplicate.py:52`), the code at
`superset/commands/dataset/duplicate.py:62-64` imports `ACTION_KIND_CLONE,
ACTION_KIND_KEY` and sets `db.session.info[ACTION_KIND_KEY] = ACTION_KIND_CLONE` before
creating and adding the new `SqlaTable` and related objects.

3. The transaction decorator at `superset/utils/decorators.py:235-263` commits on success
(`db.session.commit()`), but because no change-record listeners are registered when
capture is disabled, no `after_commit` handler fires to clear `ACTION_KIND_KEY`. As a
result, the session-scoped `db.session.info` retains `"_versioning_action_kind": "clone"`
after the duplicate finishes, even though that transaction is complete.

4. Later in the same Python process, a second Superset app is initialized with
`ENABLE_VERSIONING_CAPTURE=True`, causing `init_versioning()` to execute the ON branch at
`superset/initialization/__init__.py:47-76`, which calls
`register_change_record_listener()` at line 859 and attaches `flush_change_records` to
`db.session`. When a subsequent dataset update (for example, `PUT /api/v1/dataset/<pk>`,
which calls `UpdateDatasetCommand` in `superset/datasets/api.py`) performs the first
versioned flush, `flush_change_records` invokes `_stamp_action_kind_on_transaction` at
`superset/versioning/changes/listener.py:49-72`. That helper pops the stale
`ACTION_KIND_KEY` and stamps `version_transaction.action_kind='clone'` for this unrelated
update, causing the dataset version history to misclassify a normal edit as a clone.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/dataset/duplicate.py
**Line:** 64:64
**Comment:**
	*Logic Error: This writes a per-transaction marker into `session.info` but relies on versioning listeners for cleanup; with capture gated off, those listeners are detached, so the marker can persist and incorrectly label subsequent transactions on the same session. Ensure the key is removed in the command path itself (for both success and error paths).

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

# module docstring for the broader init-order rationale.
from superset.versioning.changes import ACTION_KIND_IMPORT, ACTION_KIND_KEY

db.session.info[ACTION_KIND_KEY] = ACTION_KIND_IMPORT

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The import command sets a session-level action marker before running import work, but this file does not guarantee key removal itself. In the dark-launch default (ENABLE_VERSIONING_CAPTURE=False), listener-based cleanup is not active, so this marker can leak to later operations on the same scoped session and mis-tag them as imports. Add explicit cleanup around the _import call. [logic error]

Severity Level: Major ⚠️
- ❌ Dashboard version audit may misclassify edits as imports.
- ⚠️ Dashboards import endpoint /api/v1/dashboard/import/ implicated.
- ⚠️ Off->on capture toggles risk stale import markers.
Steps of Reproduction ✅
1. Boot a Superset app with `ENABLE_VERSIONING_CAPTURE=False`, so
`SupersetAppInitializer.init_versioning()` takes the OFF path at
`superset/initialization/__init__.py:36-45`, logging that capture is disabled and
returning without calling `register_change_record_listener()` at line 859; thus the
`after_commit` and `after_rollback` handlers that clear `ACTION_KIND_KEY` in
`superset/versioning/changes/listener.py:214-242` are not registered.

2. Use the dashboard import endpoint `POST /api/v1/dashboard/import/`, which constructs an
`ImportDashboardsCommand` at `superset/dashboards/api.py:2176-2190`.
`ImportDashboardsCommand` subclasses `ImportModelsCommand`
(`superset/commands/dashboard/importers/v1/__init__.py:5-18`), whose `run` method is
decorated with `@transaction()` at `superset/commands/importers/v1/__init__.py:85-87`.
Inside `run`, after `self.validate()`, the code at
`superset/commands/importers/v1/__init__.py:98-100` imports `ACTION_KIND_IMPORT,
ACTION_KIND_KEY` and sets `db.session.info[ACTION_KIND_KEY] = ACTION_KIND_IMPORT` before
calling `self._import(...)` in the surrounding try/except block at lines 102-107.

3. The `@transaction` decorator in `superset/utils/decorators.py:235-263` commits on
success (`db.session.commit()` at line 2 of the second chunk) or rolls back on exception
(`db.session.rollback()` at line 5). Because no versioning change-record listeners are
attached when capture is disabled, neither `reset_processed_after_commit` nor
`reset_action_kind_after_rollback` is invoked, so `db.session.info` retains
`"_versioning_action_kind": "import"` after this import transaction completes.

4. In the same interpreter process (for example, in multi-app test setups), initialize
another Superset app with `ENABLE_VERSIONING_CAPTURE=True`, allowing `init_versioning()`
to execute the ON branch at `superset/initialization/__init__.py:47-76` and call
`register_change_record_listener()` at line 859 to attach `flush_change_records` to
`db.session`. On the first subsequent versioned write, such as a dashboard update via `PUT
/api/v1/dashboard/<id>` (`superset/dashboards/api.py:936-975`), the `after_flush` handler
calls `_stamp_action_kind_on_transaction` at
`superset/versioning/changes/listener.py:49-72`, which pops the stale `ACTION_KIND_KEY`
and stamps `version_transaction.action_kind='import'` for this normal edit, causing the
audit trail to present it as an import rather than a regular update.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/commands/importers/v1/__init__.py
**Line:** 100:100
**Comment:**
	*Logic Error: The import command sets a session-level action marker before running import work, but this file does not guarantee key removal itself. In the dark-launch default (`ENABLE_VERSIONING_CAPTURE=False`), listener-based cleanup is not active, so this marker can leak to later operations on the same scoped session and mis-tag them as imports. Add explicit cleanup around the `_import` call.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@eschutho eschutho left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posting on Elizabeth's behalf — this is her PR reviewer agent. Forward any pushback to her and she'll loop me back in.

Approving — this is base infra behind a dark-launch gate, and nothing below needs to hold up today's merge. That said, I found enough real correctness issues that I'd like to see them land as a fast-follow PR before the next release cuts, rather than open-ended: a few are ungated (they run on every deployment starting now, flag or not) and a few would corrupt version data or break a save the moment the flag flips on later. Grouped below so they're easy to turn into a checklist. All line numbers verified against PR HEAD 4f6254183.

Summary

This PR lands the base infrastructure for entity versioning — SQLAlchemy-Continuum wiring, two additive Alembic migrations (shadow tables for dashboards/slices/datasets plus a version_transaction/version_changes pair), a flush-time capture engine (baseline synthesis + field-level change records), and read-only GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/ endpoints — all inert behind ENABLE_VERSIONING_CAPTURE=False. With the gate off, Continuum's write listeners are detached at init and a save writes zero versioning rows; the read endpoints exist but return empty. Restore, retention, and UI are deferred to follow-ups.

The authorization story checks out: every new endpoint carries @protect(), maps to can_read via MODEL_API_RW_METHOD_PERMISSION_MAP, and goes through security_manager.raise_for_access on the specific entity before touching version data; version-UUID resolution is scoped per entity (UUIDv5 of entity_uuid:transaction_id), so cross-entity substitution 404s. Snapshot payloads exclude query_context, perm strings, and audit columns via the __versioned__ exclude lists. The migrations are genuinely additive, column parity with the live models is exact, and downgrade ordering is correct.


Ungated behavior changes (live on merge, regardless of the flag)

superset/daos/dataset.py:448-513

The _override_columns rewrite changes REST semantics beyond the PK preservation the docstring describes. Master's delete-all + bulk_insert_mappings reset every attribute not present in the payload to column defaults; the new in-place setattr loop preserves unspecified attributes (description, verbose_name, is_dttm, groupby, …) on name-matched columns — so a PUT ?override_columns=true with sparse column dicts now leaves stale values where it used to clear them. Two smaller shifts ride along: payload ids were previously honored on insert and are now stripped, and duplicate column_names in a payload previously raised IntegrityError but now silently last-win via incoming_by_name.

WDYT — are these intentional semantic changes? If so, could the docstring/UPDATING.md spell them out (the "override means replace" contract is one clients may rely on); if not, would clearing unspecified attributes on matched columns restore parity?


superset/commands/dashboard/update.py

UpdateDashboardCommand.run is now wrapped in no_autoflush unconditionally, so process_tab_diff, process_native_filter_diff, update_tags, and set_dash_metadata run without autoflush on every deployment even with capture off. Any query inside those helpers that relied on autoflush to see pending inserts/deletes would silently change behavior.

Could this be gated on ENABLE_VERSIONING_CAPTURE, or is there analysis showing those paths are flush-independent? Happy to keep as-is if the latter — a comment noting it would help.


superset/commands/dashboard/importers/v1/__init__.py

The overwrite-import path replaces the Core DELETE FROM dashboard_slices WHERE dashboard_id=… with an ORM dashboard.slices = […] assignment. The relationship load is filtered by the global soft-delete listener, so association rows pointing at soft-deleted slices aren't in the loaded collection and survive the reassignment — master purged them.

Small one, but WDYT about explicitly clearing rows for soft-deleted slices too, or confirming they're harmless to leave behind?


superset/models/helpers.py

Two global shifts worth a conscious ack, since they apply to all models/flows with the flag off: @validates("uuid") now coerces valid-UUID strings to UUID objects at assignment (pre-flush comparisons like obj.uuid == "abc-…" flip from True to False, and the coercion is asymmetric — malformed strings pass through), and reset_ownership stamps g.user onto created_by/changed_by instead of NULLing when a request context exists (overwrite-imports now attribute created_by to the importer). Both look deliberate and UPDATING.md mentions them — flagging mostly so reviewers land on them consciously. Totally fine to keep if intended.


superset/charts/api.py:373-374

if resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"): result["extra_owners"] = resolver(dash) — this config key appears nowhere else in the PR, master, or the docs. It's an undocumented extension point executing an operator-supplied callable inside chart GET, and it looks unrelated to versioning — probably worth pulling into the fast-follow so it can get its own docs/tests (or just come out), rather than living unexplained in the versioning PR.


Capture-engine correctness (latent until the gate flips on)

superset/versioning/baseline/insertion.py:59-76

The baseline INSERTs (version_transaction row + shadow row + child baselines) run on the session's connection with no SAVEPOINT, but the enclosing try/except swallows failures. On PostgreSQL a failed statement aborts the whole transaction, so swallowing the exception doesn't save the user — their flush/COMMIT then dies with InFailedSqlTransaction. That defeats the "versioning must never break a user's save" contract at exactly the layer with the widest failure surface. The codebase clearly knows the pattern — shadow_row_count (collection.py:133) and _persist_buffered_records (changes/listener.py:368) both wrap in begin_nested() for this exact reason; the baseline write path is the inconsistent omission. Same pattern applies to _stamp_action_kind_on_transaction (changes/listener.py:334-344).

Could the baseline insert path get the same begin_nested() treatment? Relatedly: if _baseline_children_for_parent fails after the parent row is written (insertion.py:142-149), the parent's shadow count is nonzero forever, so the count == 0 gate in baseline/listener.py:117-118 never retries — pre-versioning children then permanently lack baselines and their later edits produce predecessor-less UPDATE rows. Making parent+child baselining atomic under one savepoint would close both at once. WDYT?


superset/versioning/changes/shadow_queries.py:156-181

prior_tx for dataset child diffs is MAX(table_columns_version.transaction_id) < tx, falling back to the metrics shadow only when the columns result is None. If a dataset is baselined at tx B, a metric is edited at M1 (> B, writes only sql_metrics_version), and any later save happens at N, then prior_tx = B (columns untouched since B) even though M1 exists — so pre_metrics is read at pre-M1 state and M1's metric change is re-emitted under transaction N (duplicated/conflated change records for a very common edit sequence).

Should this be max(col_max, metric_max) across both shadows rather than columns-first-with-fallback?


superset/versioning/changes/listener.py:432-438

Once a flush persists records for tx N, N enters the processed set, and any later flush in the same DB transaction discards its freshly-buffered records. A command that mutates a dashboard, triggers a mid-command autoflush (flush 1 persists records for tx N), mutates further, then commits (flush 2, same Continuum tx) loses flush 2's diffs — while Continuum's shadow row is updated to the final state, so the shadow history and change log disagree. The comment frames the discard as dropping stale leftovers, but the buffer can hold genuinely new records.

Would a merge-with-dedup (or per-entity sequence continuation) on the second flush be safer than the blanket discard? Or is there an invariant that record-bearing flushes can't happen twice per transaction that could be documented?


superset/versioning/diff.py:256-258 and :834-859

cap_records groups by path[:1] to enforce MAX_RECORDS_PER_FIELD, but layout records use path=[node_id] — every layout node is its own group, so the cap never binds across a position_json rewrite. The docstring explicitly claims "a thousand-node layout churn … is collapsed"; as written it isn't, and a large dashboard layout rewrite writes one version_changes row per node per save — the unbounded blowup the cap exists to prevent.

Could layout-kind records be grouped under a synthetic ("position_json",) key (or an overall per-entity cap added)?


superset/versioning/diff.py:491-502

_metric_key returns None for non-dict metrics, but chart params["metrics"] is commonly a list of plain strings (saved-metric names). Those all fall back to positional keys, so removing/reordering saved metrics emits mislabeled edit-at-index records instead of add/remove by name. _dimension_key right below already handles str.

Could _metric_key do the same?


superset/versioning/baseline/dirty.py:184-189

The docstring above (lines 156-169) explains carefully why uuid must never be the force-dirty flag column — a documented prior failure where the UUIDType/BLOB round-trip breaks an FK check — yet the fallback chain is still descriptionuuidcol_keys[0]. Dead code today since all three versioned parents have description, but any future versioned model without one would walk straight into the failure mode the comment warns about.

Small suggestion — could the chain skip from description straight to col_keys[0] so the documented footgun is unreachable?


superset/versioning/changes/shadow_queries.py:301-315 and changes/listener.py:412

Two smaller scope gaps, likely fine to defer but worth confirming as deliberate: (a) a dashboard baselined with zero attached slices has no M2M shadow rows, so the first chart added yields prior_tx is None → skip → no "added chart" record, even though this isn't the dashboard's first edit; (b) the change-record listener iterates only session.dirty, so entity deletions never produce a version_changes row (Continuum's own delete marker still lands in the shadow table). Intentional for the base-infra scope?


Per-flush query fan-out (superset/versioning/factory.py:271, changes/state.py:159, changes/shadow_queries.py)

Nothing quadratic, but each capture layer issues its own independent SELECTs per dirty/affected entity in a flush — one per updated row for the skip-unmodified check, one per dirty parent for the pre-state read, and several per affected dataset/dashboard for the shadow queries. Noise for a single-chart save; for a bulk import or a save touching hundreds of entities in one transaction it stacks up to several O(N) round-trip passes.

Totally fine for a dark launch — just wondering whether a load test on a large dashboard/import is planned as part of the validation before the flag-flip PR?


API surface

superset/versioning/schemas.py:29-35

VersionChangedBySchema returns id, username, first_name, last_name. The existing chart/dashboard payloads deliberately expose only first/last name — usernames were removed from those payloads as prior hardening — so /versions/ reintroduces username (and internal user id) exposure to any principal with read access to the entity.

Could username/id be dropped for consistency with the rest of the API?


superset/versioning/queries.py (list_versions)

The list endpoint returns the full version history with no limit/pagination — unbounded for a heavily-edited entity until retention pruning lands. Worth a simple limit now, or fine to defer alongside the retention follow-up?


ETag (informational, no action needed)

The new ETag is emit-only — no If-Match/If-None-Match handling exists yet, so there's no lost-update protection or 304 path. Presumably that's the follow-up PR; flagging only so it's a conscious sequencing decision. The header itself is safe (same value for all authorized users, no Cache-Control: public added).


Migrations

superset/migrations/versions/2026-05-28_19-50_56cd24c07170_add_versioning_tables.py:112-119, 328-334 and superset/versioning/changes/table.py:43

version_transaction.id/version_changes.id are BigInteger autoincrement PKs. On SQLite only a literal INTEGER PRIMARY KEY gets rowid autoincrement — a bare BIGINT PK fails inserts with NOT NULL constraint failed. This works today only because sqlalchemy_continuum registers a global @compiles(BigInteger, "sqlite")INTEGER override as an import side effect, and Superset's boot path always imports it before migrations run. That's a load-bearing, undocumented coupling.

Would sa.BigInteger().with_variant(sa.Integer(), "sqlite") on both make this self-contained (plus a one-line comment)?


Both migration files — MySQL retry safety (minor)

The docstring justifies raw op.create_table only for version_transaction's sequence, but all 7 shadow tables and the indexes (including migration 2's upgrade(), whose downgrade() is inspector-guarded) use unguarded raw ops. Since MySQL DDL isn't transactional, a mid-migration failure leaves partial schema that a retried alembic upgrade can't resume past ("table already exists"). Worth switching the non-sequence tables/indexes to the migrations.shared.utils guarded helpers?


superset/migrations/versions/2026-06-03_12-00_8f3a1b2c4d5e_shadow_live_row_indexes.py — blocking index builds (informational)

Plain op.create_index, no CONCURRENTLY/batching. Inert on the expected path (both migrations ship together against empty tables), but if this ever runs after the shadow tables have accumulated real history — e.g. a delayed follow-on deploy — the index builds take blocking locks proportional to table size. No existing Superset migration uses CONCURRENTLY either, so not a convention break; a comment for future deployers would do. Happy to keep as-is.


Test coverage

Coverage is strong where it exists and refreshingly non-mocked — the integration suite runs with ENABLE_VERSIONING_CAPTURE=True so capture is exercised broadly, the capture-off test does a real PUT and asserts zero rows across all version tables (with a capture-on control guarding against vacuous passes), and the listener detach/re-attach round-trip runs against the real SQLAlchemy event registry. Gaps worth a look, roughly in priority order:

  • No test forces an exception inside the capture path during a flush to prove the save survives — the fail-open machinery (changes/listener.py:339-374 savepoint recovery, the broad excepts) is the highest-risk untested behavior in the PR, and it's where the PostgreSQL-poisoning finding above lives.
  • No test asserts diff contentchanges items are only checked for having operation/kind keys, never that renaming a chart yields a slice_name record with the right from/to values. The prior_tx and _metric_key bugs above would both sail through the current suite.
  • Authorization denial is tested only for Gamma × chart × list — no denial coverage for get_version, dashboards, datasets, or anonymous.
  • No cross-entity version-UUID test (chart A's version UUID against chart B) — the code defends via UUIDv5 derivation, but nothing pins it.
  • The capture-on control test soft-skips if the "Boys" slice is missing (capture_disabled_tests.py:148-149) — the one test guarding against vacuous zero-row assertions can silently vacate on fixture drift; a hard assert (like its capture-off sibling uses) seems safer.

Suggested fast-follow checklist

Merging this as-is looks right to me — it's gated off, additive, and the auth/migration fundamentals are sound. For the follow-up PR before the next release cuts, here's what I'd prioritize, roughly in order:

  1. _override_columns semantics (daos/dataset.py:448-513) — this one's user-facing and ungated, so it's already live; worth confirming with Mike whether it's intentional and documenting it either way.
  2. PostgreSQL savepoint gap in baseline inserts (baseline/insertion.py:59-76) — would break a user's save the moment the flag flips on, contradicting the PR's own "never break a save" contract.
  3. EXTRA_OWNERS_RESOLVER (charts/api.py:373-374) — unrelated rider, cheap to pull out or properly scope.
  4. prior_tx metric-diff bug and the cap_records layout-cap bypass (shadow_queries.py:156-181, diff.py:256-258) — both would produce incorrect/unbounded version data once capture is on.
  5. Everything else in this review (the remaining capture-engine items, the migration portability notes, the test gaps) — nice to have before the flag flips, but lower urgency than 1–4.

No need to block today's merge on any of this — happy to track it as a linked follow-up issue if that's easier than a checklist in the PR description.

@rusackas

Copy link
Copy Markdown
Member

OMG that review comment @eschutho :P

Love the line at the top saying it's AI. Imagine how long you'd be up at night typing all that if it weren't!

Anyway, merging now before the length of the thread breaks github ;)

@rusackas
rusackas merged commit a899e1d into apache:master Jul 10, 2026
60 checks passed
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 10, 2026
A Celery beat task that prunes version_transaction / version_changes /
shadow rows beyond the configured retention window, so the version tables
don't grow unbounded once capture is enabled. Preserves live child/M2M rows,
batches the deletes with bind/retry hardening, and adds an index on
version_transaction.issued_at for the prune scan. Stacked on the versioning
base infrastructure (apache#41176).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 10, 2026
A per-entity activity feed built on the versioning base infrastructure: it
assembles chart/dashboard/dataset change records into a single visibility-
scoped, paginated timeline (the /activity/ read surface), with SQL-side
access filtering, tombstone redaction for deleted related entities, and
headline rendering. Adds two OpenAPI contract fields the write side already
emits (the operation verb, path nullability). Stacked on apache#41176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 10, 2026
A per-entity activity feed built on the versioning base infrastructure: it
assembles chart/dashboard/dataset change records into a single visibility-
scoped, paginated timeline (the /activity/ read surface), with SQL-side
access filtering, tombstone redaction for deleted related entities, and
headline rendering. Adds two OpenAPI contract fields the write side already
emits (the operation verb, path nullability). Stacked on apache#41176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 13, 2026
A Celery beat task that prunes version_transaction / version_changes /
shadow rows beyond the configured retention window, so the version tables
don't grow unbounded once capture is enabled. Preserves live child/M2M rows,
batches the deletes with bind/retry hardening, and adds an index on
version_transaction.issued_at for the prune scan. Stacked on the versioning
base infrastructure (apache#41176).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 13, 2026
A per-entity activity feed built on the versioning base infrastructure: it
assembles chart/dashboard/dataset change records into a single visibility-
scoped, paginated timeline (the /activity/ read surface), with SQL-side
access filtering, tombstone redaction for deleted related entities, and
headline rendering. Adds two OpenAPI contract fields the write side already
emits (the operation verb, path nullability). Stacked on apache#41176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 13, 2026
A Celery beat task that prunes version_transaction / version_changes /
shadow rows beyond the configured retention window, so the version tables
don't grow unbounded once capture is enabled. Preserves live child/M2M rows,
batches the deletes with bind/retry hardening, and adds an index on
version_transaction.issued_at for the prune scan. Stacked on the versioning
base infrastructure (apache#41176).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 13, 2026
A per-entity activity feed built on the versioning base infrastructure: it
assembles chart/dashboard/dataset change records into a single visibility-
scoped, paginated timeline (the /activity/ read surface), with SQL-side
access filtering, tombstone redaction for deleted related entities, and
headline rendering. Adds two OpenAPI contract fields the write side already
emits (the operation verb, path nullability). Stacked on apache#41176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 14, 2026
A per-entity activity feed built on the versioning base infrastructure: it
assembles chart/dashboard/dataset change records into a single visibility-
scoped, paginated timeline (the /activity/ read surface), with SQL-side
access filtering, tombstone redaction for deleted related entities, and
headline rendering. Adds two OpenAPI contract fields the write side already
emits (the operation verb, path nullability). Stacked on apache#41176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 14, 2026
A Celery beat task that prunes version_transaction / version_changes /
shadow rows beyond the configured retention window, so the version tables
don't grow unbounded once capture is enabled. Preserves live child/M2M rows,
batches the deletes with bind/retry hardening, and adds an index on
version_transaction.issued_at for the prune scan. Stacked on the versioning
base infrastructure (apache#41176).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
devin-ai-integration Bot pushed a commit to raymondtangsc/superset that referenced this pull request Jul 20, 2026
A Celery beat task that prunes version_transaction / version_changes /
shadow rows beyond the configured retention window, so the version tables
don't grow unbounded once capture is enabled. Preserves live child/M2M rows,
batches the deletes with bind/retry hardening, and adds an index on
version_transaction.issued_at for the prune scan. Stacked on the versioning
base infrastructure (apache#41176).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
devin-ai-integration Bot pushed a commit to raymondtangsc/superset that referenced this pull request Jul 20, 2026
A Celery beat task that prunes version_transaction / version_changes /
shadow rows beyond the configured retention window, so the version tables
don't grow unbounded once capture is enabled. Preserves live child/M2M rows,
batches the deletes with bind/retry hardening, and adds an index on
version_transaction.issued_at for the prune scan. Stacked on the versioning
base infrastructure (apache#41176).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 27, 2026
A Celery beat task that prunes version_transaction / version_changes /
shadow rows beyond the configured retention window, so the version tables
don't grow unbounded once capture is enabled. Preserves live child/M2M rows,
batches the deletes with bind/retry hardening, and adds an index on
version_transaction.issued_at for the prune scan. Stacked on the versioning
base infrastructure (apache#41176).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mikebridge pushed a commit to mikebridge/superset that referenced this pull request Jul 27, 2026
…squash

The restore engine is the sole consumer of single_flush_scope; the
version-read slice that squash-merged as apache#41176 dropped it from
versioning/utils.py because nothing merged referenced it. Ported back
verbatim alongside its consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Related to the REST API change:backend Requires changing the backend review:draft risk:db-migration PRs that require a DB migration size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants