You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Scope: Backend only. This SIP covers the version capture, restore, retention, structured change-record APIs, and an informational ETag header. A user-facing UI (version history browser, "what changed" view, one-click restore confirmation flow) is out of scope here and lands in a follow-up SIP. Optimistic-locking enforcement on writes (If-Match → 412) is also out of scope for V1; the data backbone for it ships here as ETag headers, but the actual gate lands with the UI SIP. See Phasing below.
Motivation
Every save to a dashboard, chart, or dataset in Superset is destructive — the previous state is permanently lost. Users who accidentally overwrite a dashboard layout, corrupt a set of filters, or remove calculated metrics have no recovery path short of a database backup, which requires admin intervention and may not be available.
This is a consistent source of support tickets and user frustration. Other platforms offer built-in version history with one-click restore. The absence of this capability in Superset is a pain point for organizations managing critical dashboards.
Phasing
V1 (this SIP) ships only the backend. Two follow-up SIPs round out the user-visible experience:
Phase
Scope
Status
V1 (this SIP)
Backend version capture; structured change records; REST API for list / get / restore; time-based retention via Celery; ETag response header (informational).
Proposed
V2 (follow-up UI SIP)
Version-history UI in Explore + Dashboard list pages; restore confirmation flow; localized rendering of changes records into human-readable strings; cross-entity warning panel for dataset restore (e.g. "this restore may affect 3 charts").
Planned, scope TBD
V3 (locking SIP)
Server-side If-Match enforcement on save endpoints; 412 Precondition Failed responses; UX for stale-token recovery (banner / blocking modal / merge view). Builds on V1's ETag header.
Planned, scope TBD; depends on V2 UI
V1 is intentionally back-end-only so that the persistence layer can stabilise and migrate forward (UI changes are far more sensitive to breaking-change feedback than back-end API additions). V1's APIs are designed to be stable for V2 to consume without further migration.
Success criteria
V1 is complete when all of the following hold:
Capture correctness: every save of a dashboard, chart, or dataset produces exactly one new version row when any user-authored content field changed. Saves whose only changes are to non-content metadata (e.g. editing the owners list, the roles list, or tags — fields that are deliberately excluded from versioning, see Per-entity restore scope below) produce zero version rows. The user making the save is irrelevant to this rule — content edits by an owner produce a row; ownership-list edits by anyone do not.
Baseline correctness: the first save of a pre-existing entity produces two rows — a synthetic baseline (state before the edit) plus the edit itself.
Save-path overhead: median save latency with versioning enabled increases by less than 50ms relative to versioning disabled, measured across the three entity types.
List-endpoint latency: GET /api/v1/{resource}/<uuid>/versions/ returns p95 under 1s for entities with up to 30 days of history at the default retention.
Restore latency: POST /api/v1/{resource}/<uuid>/versions/<v>/restore returns p95 under 3s.
Restore non-destructiveness: every restore produces a new version row attributed to the restoring user; the restored-from version remains in the history.
Multi-database support: all three migrations and the listener chain pass integration tests on PostgreSQL, MySQL, and SQLite.
Retention safety: the live row of every entity is preserved regardless of issued_at; closed historical rows older than SUPERSET_VERSION_HISTORY_RETENTION_DAYS are pruned by a single Celery beat task running daily.
Proposed Change
Add automatic version history to dashboards, charts, and datasets. Every save creates a new version. Users can browse the history of any entity, inspect what each save changed, and restore a previous version. Restore is non-destructive: it produces a new version row that brings the entity back to a prior state, leaving the version chain intact.
Strategy at a glance
Concern
Strategy
Capture
Use SQLAlchemy-Continuum to track every save automatically. Continuum mirrors each versioned class (parents Dashboard / Slice / SqlaTable and children TableColumn / SqlMetric) into a shadow table; the dashboard_slices M2M gets its own shadow. No save-path instrumentation needed beyond a single make_versioned() call at app init.
No-op suppression
Saves that don't change any versioned (non-excluded) field — e.g. saves that only edit the owners list / roles list / tags, or any save where the resulting column values are content-equal to the previous version — don't produce a version row. (The save itself is unaffected; only the version row is suppressed.) Two pieces work together: audit columns (changed_on, created_on, changed_by_fk, created_by_fk, plus last_saved_at / last_saved_by_fk on Slice) are excluded from __versioned__ so their unconditional auto-bump can't trigger a row on its own; for the content-equivalent case (e.g. set_dash_metadata re-serialising json_metadata to a different byte sequence with identical parsed content), a Continuum plugin (SkipUnmodifiedPlugin) compares post-flush column values against the previous live shadow row and marks matching operations processed. For Dashboard.json_metadata specifically, the comparison parses the JSON and drops a registered set of frontend-stamped sub-keys (map_label_colors, chart_configuration, global_chart_configuration, show_chart_timestamps, color_namespace) before equating — these are regenerated client-side on every save from runtime singletons (the LabelsColorMap registry, the cross-filter configuration etc.), so two consecutive saves with no user-authored change produce different bytes for json_metadata; the audit-key filter keeps the skip-plugin's comparison aligned with the diff engine's DASHBOARD_JSON_METADATA_AUDIT_KEYS. A _COLUMN_NORMALIZERS registry is the extension point for analogous transient fields on other entities. Continuum still allocates the version_transaction row for a suppressed operation; it ends up as a short-lived orphan that doesn't surface in the version-list endpoint and gets swept by retention.
Baseline
The first save of a pre-existing entity captures a synthetic operation_type=0 row representing the state before that save, so the version history for that entity isn't a single mystery edit. The baseline is treated like any other historical row by retention — it ages out alongside the rest of the history.
Restore
Continuum's Reverter is the engine; VersionDAO.restore_version() calls it once across all related collections inside db.session.no_autoflush, then issues a single explicit flush — single Continuum transaction so the change-records listener captures the full child diff in one pass. Stamps changed_on / changed_by_fk on the live entity so the restoring commit is attributed to the user who clicked Restore. The new shadow row that the restoring commit produces is what gives "non-destructive restore."
Change log
A diff-on-flush listener writes structured per-field change records into version_changes (one row per atomic change, keyed to version_transaction.id). Captured forward at save time so the UI can render "Added column 'country'" / "Renamed dashboard" without diffing snapshots at read time.
Retention
Time-based, default 30 days, configurable via SUPERSET_VERSION_HISTORY_RETENTION_DAYS env var. A scheduled Celery beat task ages out old shadow rows. Only the live (current) row is preserved regardless of age; closed historical rows including the baseline age out.
API surface
Two new endpoints per entity type — GET /api/v1/{resource}/<uuid>/versions/ and POST /api/v1/{resource}/<uuid>/versions/<version_uuid>/restore. Plus an ETag: "<version_uuid>" header on save responses and editor-fetch GETs so future UI work can do optimistic locking.
The four save-time pieces (Continuum capture, baseline listener, change-record listener, and the M2M / child shadow tables that come along with __versioned__) are all wired up in a single init_versioning() step at app start. The three persistence layers (Continuum shadows, version_transaction, version_changes) are added by three Alembic migrations. The Celery beat task is registered in the existing CELERYBEAT_SCHEDULE. No feature flag, no UI in v1.
Per-entity restore scope
Entity
Restored
Not restored
Dashboard
scalar fields (dashboard_title, position_json, json_metadata, slug, css, description, certified_by, certification_details, published, external_url, theme_id, is_managed_externally); chart membership (dashboard_slices — which charts are attached, in what layout)
owners, row_level_security_filters, tags, charts that depend on this dataset
Cross-entity effects of restore
Restore brings back the asset's own state, not the state of its dependencies at that time. This principle — well articulated by SIP-192 #36145 — keeps the operation predictable but produces three scenarios worth flagging explicitly. The unifying test for whether each is acceptable: would the same outcome have occurred if the asset had simply been edited that way live? In each case below the answer is yes — restore puts you in a state that's reachable through normal editing.
Restoring a dataset may break dependent charts. If a column or metric a chart relied on was renamed or removed at the restore target, the chart renders errors until a user updates it. A chart broken by a forward edit to its dataset would break in the same way — restore doesn't take you somewhere unreachable; it takes you to a previous state that produced its own breakage.
Restoring a chart is reflected in every dashboard it appears in. The dashboard itself isn't modified by the restore, but its next render shows the restored chart in every position it occupies. No version row is minted for any of those dashboards.
Restoring a dashboard restores its chart membership but not chart content. Two sub-scenarios:
If a chart that was attached at the saved version has since been deleted, the restored dashboard shows the standard "chart not found" placeholder in that position. Restore does not undelete charts.
If a chart has since been edited, the dashboard shows the current chart, not the chart as it was when the dashboard version was saved. Dashboard restore does not "snapshot through" to chart-level state — that would conflate two distinct operations (revert this dashboard vs. snapshot all its charts to their state at this point too) and force users to choose between them without UI support for either.
V1 does not detect or warn before any of these. The user is expected to verify dependent assets after a restore. A pre-restore warning panel ("this restore may break 3 charts") is a V2 (UI SIP) concern; the cross-entity relationships needed to power it (chart → datasets, dashboard → charts) are already exposed through existing endpoints, so no further backend work is needed before the UI SIP can build on them.
The remainder of this section drills into each piece.
Continuum-driven scalar capture
SQLAlchemy-Continuum is added as a dependency and configured in superset/extensions/__init__.py:
VersionTransactionFactory renames Continuum's default transaction table to version_transaction (avoids collision with downstream extensions that use the unqualified name) and the PostgreSQL sequence to version_transaction_id_seq.
VersioningFlaskPlugin overrides transaction_args() to read the acting user via superset.utils.core.get_user_id() instead of flask_login.current_user. The default plugin reads current_user, which Flask-Login populates under cookie-authenticated sessions but not under JWT auth (JWT bypasses Flask-Login). get_user_id() reads g.user, populated by both auth modes, so the custom plugin attributes API saves consistently.
The three entity types declare which columns to track:
Slice.query_context is excluded for three reasons: (a) it's a derived field — the result of running the chart, populated by /api/v1/chart/data and reproducible from params plus the datasource; (b) its size (typically 10–50 KB, can exceed 100 KB) would dominate slices_version rows and version_changes.from_value/to_value payloads; (c) it's touched on every chart run, not just on user edits, so versioning it would flood the history with non-user-authored noise. Restoring a chart's params is enough to recover its definition — the next render regenerates query_context from scratch.
Soft-delete coordination. The soft-delete SIP (sc-103157) is a parallel proposal. The two are independent in design and orthogonal in implementation, but they touch the same model columns, so the merge order matters:
If this SIP merges first, soft-delete inherits four searchable placeholder comments (# deleted_at exclusion will be added when sc-103157 (soft delete) is merged (T043)) in superset/models/dashboard.py, superset/models/slice.py, superset/connectors/sqla/models.py, and superset/daos/version.py. The soft-delete PR adds deleted_at to each __versioned__.exclude list and removes the placeholders. No coordination beyond a one-line per model change.
If soft-delete merges first, those placeholders ship as already-resolved code in this SIP's PR — the exclude lists name deleted_at from day one. The PR description should note the merge order taken.
An open semantic question worth flagging for the soft-delete SIP regardless of merge order: should restore clear deleted_at? The conservative answer (yes — restoring a version where the entity was alive brings the entity back to alive) is consistent with how Continuum's Reverter would behave, since deleted_at is excluded from versioning. We expect this to fall out cleanly but the soft-delete PR should add an integration test that exercises "soft-delete then restore an earlier version" to lock in the semantics.
make_versioned() registers SQLAlchemy mapper events that activate when __versioned__ models go through configure_mappers() (lazily, at first session use). It must therefore run before any code path that triggers mapper configuration. For Flask-SQLAlchemy this means in extensions/__init__.py immediately after db = SQLAlchemy(), before any model module is imported by the app initialiser.
Synthetic baseline capture and parent-on-child-change
A before_flush listener (register_baseline_listener()) does two things:
Synthetic baseline. Inserts a synthetic baseline row (operation_type=0) the first time a pre-existing entity is updated under versioning. The very first save of any pre-existing dashboard, chart, or dataset produces two version rows — the baseline (state before the edit) and the edit itself — giving users an immediate rollback point. The listener is prepended (insert=True) in the SQLAlchemy event chain so the baseline's transaction id is allocated before Continuum's own listener allocates the update's, keeping the baseline sorted first in the version history.
Force-parent-dirty on child change. When a versioned child (TableColumn / SqlMetric for SqlaTable) is in session.dirty / new / deleted but the parent's own scalar columns haven't been touched, the listener attributes.flag_modifieds the first non-excluded versioned column on the parent. This puts the parent in session.dirty so Continuum's UnitOfWork creates a parent UPDATE operation — without it, child-only edits (e.g. editing a column description) would leave the parent out of the dirty set, no parent shadow row would be written, and the version dropdown — which lists parent shadow events — would be empty for column/metric-only saves. The parent's new shadow row's scalar columns mirror the previous version (only the children actually changed); the row exists to anchor the transaction in the parent's version chain. SkipUnmodifiedPlugin._is_no_op_update is taught to recognise this case (_has_dirty_versioned_children) so it doesn't fall back to "scalars match → skip".
Continuum shadow tables for child state
TableColumn, SqlMetric, and the dashboard_slices association are versioned via Continuum's auto-generated shadow tables on the same validity strategy as the parents:
Continuum-default per-class shadows with the validity strategy
table_columns_version, sql_metrics_version
dataset children
__versioned__ on TableColumn / SqlMetric excludes the four audit fields (changed_on/by_fk, created_on/by_fk) so child diffs don't surface "changed_on shifted" noise on every parent save
dashboard_slices_version
dashboard slices M2M
depends on PR #39859 for the live composite-PK shape — Continuum's M2M tracker builds shadow inserts from **params + transaction_id + operation_type, where params is what the live INSERT carries; the composite-PK shape lets that map to the shadow's PK cleanly
Three pieces of the design let Continuum-tracked children work cleanly against Superset's existing entity model:
Natural-key upsert in DatasetDAO._override_columns. The dataset-edit path keys child writes on column_name (and metric_name for SqlMetric), updating in place rather than deleting and reinserting under fresh primary keys. Stable PKs across edits keep the shadow trail unambiguous — no overlapping (id, column_name) rows for the same logical column.
Composite PK on dashboard_slices (PR #39859). The live association table uses a composite PK on (dashboard_id, slice_id) instead of a surrogate id. Continuum's M2M tracker builds shadow inserts from **params + transaction_id + operation_type, where params is what the live INSERT carries. With the composite-PK live shape, that maps to the auto-generated dashboard_slices_version PK cleanly.
Slice baseline under the dashboard's baseline tx. The baseline listener synthesises operation_type=0 shadow rows in dashboard_slices_version AND in slices_version for each attached slice that has no prior shadow, using the dashboard's baseline transaction id. The M2M version-side restore query joins dashboard_slices_version with slices_version and filters by validity at the dashboard's tx — without the slice-side baseline, dashboards whose membership predates versioning would restore to an empty slices list.
The restore path uses Continuum's Reverter natively, wrapped in db.session.no_autoflush, to avoid a Reverter / autoflush / cascade-add interaction that fires when revert(relations=['a','b']) is given two or more uselist relations and the target tx requires removing live children — see Restore below.
Restore
VersionDAO.restore_version() uses Continuum's Reverter natively. It calls target_version.revert(relations=_RESTORE_RELATIONS[<class>]) once with the full relation list, wrapped in db.session.no_autoflush, then issues a single explicit db.session.flush() afterwards. (_RESTORE_RELATIONS = {"SqlaTable": ["columns", "metrics"], "Dashboard": ["slices"], "Slice": []}.)
After the revert, the live entity's changed_on and changed_by_fk are overwritten with datetime.now() and the restoring user's id, so the resulting new version row attributes the restore to the right user. created_on / created_by_fk are left untouched (they continue to record original authorship).
The no_autoflush wrap addresses a Reverter / SQLAlchemy autoflush / cascade-add interaction that fires when revert(relations=['a','b']) is given two or more uselist relations and the target tx requires removing live children. Without it, iterating from relation a to relation b triggers an internal getattr(self.obj, prop.key) whose autoflush flushes pending session.delete(live_child) calls from relation a, transitioning those instances to state.deleted=True; the final session.add(version_parent) then walks the parent's save-update cascade through the in-memory collection (still holding the deleted-state instances) and raises InvalidRequestError: Instance has been deleted. no_autoflush keeps marked-for-deletion instances in state.persistent (queued in session.deleted but not yet transitioned), so the cascade walk in session.add only sees live instances. The single explicit db.session.flush() below then drains all DELETEs + INSERTs atomically.
Keeping the restore in a single flush is also load-bearing for the change-records listener: it computes the structured per-field diff in after_flush, and an earlier per-relation flush loop split the restore across multiple Continuum transactions, causing the listener's tx-dedup guard to skip the second pass and silently drop child-addition records (e.g. a re-added calculated column would not appear in version_changes for the restore's transaction). The unified flush feeds the listener a complete shadow state, so the diff captures every child change in one record set. The test_restore_emits_full_child_diff_in_one_transaction integration test locks the invariant in.
Tags, owners, and roles are deliberately excluded across all three entity types — they are access/organisation metadata, not user-authored content. Restoring them would surprise users by retroactively changing collaborator or permission state. The exclusion is enforced by __versioned__["exclude"] on each parent class.
Structured change records
A version_changes table records an atomic per-field diff for every save. Schema:
Column
Type
Notes
id
BIGINT PK
transaction_id
BIGINT FK → version_transaction.id, ON DELETE CASCADE
entity_kind
VARCHAR(32)
chart / dashboard / dataset
entity_id
INTEGER
sequence
SMALLINT
0-based, per (transaction_id, entity_kind, entity_id)
e.g. ["dashboard_title"], ["params", "adhoc_filters", "country"], ["json_metadata", "refresh_frequency"]
from_value, to_value
JSON
JSON-safe scalar values (datetime / UUID / bytes coerced to ISO / hex / str)
Indexes and constraints:
UNIQUE (transaction_id, entity_kind, entity_id, sequence) — guards against duplicate inserts when after_flush re-fires within a single transaction (see Listener registration order above).
INDEX (transaction_id) — supports the FK lookup and the per-version "what changed" query.
INDEX (entity_kind, entity_id) — supports per-entity history queries (e.g., "show all change records for chart 42").
INDEX (kind) — supports kind-filtered queries (e.g., "show every filter change across the workspace").
A pure-Python diff engine (superset/versioning/diff.py) walks pre-state vs post-state and emits the structured records that power per-version labels in the UI ("Added filter country", "Changed time range", etc.). The records themselves are machine-readable and language-neutral; rendering them into localized strings is a UI concern via Flask-Babel t(), deferred to the follow-up UI SIP. Highlights:
Slice.params is JSON-parsed and walked: known keys (adhoc_filters, metrics, time_range, etc.) are promoted to first-class kinds; unknown keys fall through to kind="field".
Dashboard.json_metadata and position_json are JSON-parsed and walked at the top level (full Phase 2 nested-structure diff is deferred).
null ↔ "" transitions are filtered as audit noise (Superset's save paths normalize nullable strings to "").
Per-entity audit-field exclusions: Slice.last_saved_at, Slice.last_saved_by_fk, and params.slice_id are stamped on every chart save and produce no record.
Datetime / UUID / bytes are coerced to JSON-safe strings before storage.
The capture listener (register_change_record_listener()) runs before_flush (read parent pre-state, compute scalar diff) and after_flush (read child shadow tables for child-collection diffs, bulk-insert records).
Listener registration order
Three listeners cooperate at flush time, and their registration order in init_versioning() is load-bearing:
Continuum's own before_flush (registered by make_versioned() at app init) — allocates version_transaction.id and writes shadow rows.
register_baseline_listener() — registered with insert=True so its before_flush runs before Continuum's, allocating the synthetic baseline tx id ahead of the update tx id. Also (a) walks dirty / new / deleted children back to their parent so a child-only flush still triggers the parent's baseline; (b) force-flags a versioned parent as dirty when any of its versioned children are dirty / new / deleted, so child-only edits produce a parent shadow row at the same transaction (otherwise the version dropdown would be empty for column/metric-only saves); (c) synthesises op=0 shadows for pre-versioning children + dashboard slices.
register_change_record_listener() — registered LAST so its after_flush runs after Continuum has written the new shadow rows, then reads child shadow rows (table_columns_version / sql_metrics_version / dashboard_slices_version joined with slices_version) and emits per-change records into version_changes.
The change-record listener also has to handle after_flush re-firings within a single transaction (autoflush triggered by mid-commit queries can fire the chain more than once). It tracks processed transaction ids on session.info so the second firing is a no-op; without this dedup the unique constraint on version_changes would trip on duplicate inserts.
Time-based retention via Celery
Retention is time-based: version rows whose owning version_transaction.issued_at is older than SUPERSET_VERSION_HISTORY_RETENTION_DAYS (default 30) are pruned. The setting reads from the environment variable of the same name (with superset_config.py override); 0 disables pruning entirely.
Pruning runs as a scheduled Celery beat task rather than synchronously on the save path. This avoids adding latency to user saves and lets the prune run at off-peak hours. The task — version_history.prune_old_versions, defined in superset/tasks/version_history_retention.py — is registered in CELERYBEAT_SCHEDULE to run daily at 03:00 by default. Operators can change the schedule via the existing CELERYBEAT_SCHEDULE override in superset_config.py.
The task:
Resolves the candidate transaction set: version_transaction rows with issued_at < (now() - retention_days), then filters out any transaction whose parent shadow rows include a live row (end_transaction_id IS NULL). The live row of any versioned entity must always remain queryable, so its owning transaction is preserved no matter how old issued_at is. Closed historical rows — including the synthetic baseline (operation_type=0) — are NOT preserved separately; they age out with the rest of the history.
Deletes shadow rows in the parent tables (dashboards_version, slices_version, tables_version) for the surviving candidate transactions.
Deletes shadow rows in the child tables (table_columns_version, sql_metrics_version, dashboard_slices_version) for the same transactions.
Drops the version_transaction rows themselves. version_changes rows cascade automatically via the ON DELETE CASCADE FK on version_transaction.id.
Failures (DB errors, missing tables) are caught, logged, and the next scheduled run retries from a clean slate. The task is idempotent — a second run with no time elapsed prunes nothing.
A pure-Python _prune_old_versions_impl(retention_days) is exposed alongside the Celery wrapper so unit / integration tests can exercise the prune deterministically by passing a fake retention value (and optionally backdating version_transaction.issued_at to force eligibility).
A synchronous (per-save) prune was considered and rejected: it added per-save latency proportional to retention work, and gave installations no way to skip pruning during peak hours.
Storage cost at default retention
Order-of-magnitude estimate to size the operational impact. The dominant cost is the parent shadow row, which mirrors the live row at roughly 1–10 KB per dashboard / chart / dataset depending on json_metadata / position_json / params size; child shadows (TableColumn, SqlMetric) and dashboard_slices_version are ~150–300 bytes each; version_changes records average ~200 bytes (one record per atomic field change).
The last row is the storage budget operators should think about. A single dashboard saved 50 times/day by 10 collaborators against 200 charts is not unusual in larger installs; the storage is bounded by SUPERSET_VERSION_HISTORY_RETENTION_DAYS. Operators who want capture but not retained history can set the value low (e.g. 7) and accept a shorter rollback window. Setting it to 0 keeps every row ever captured — useful for compliance but unbounded growth, paired with operator monitoring is recommended.
New configuration keys
Key
Type
Default
Description
SUPERSET_VERSION_HISTORY_RETENTION_DAYS
int
30
Version rows older than this many days are pruned by the version_history.prune_old_versions Celery beat task. 0 disables pruning (the task still runs daily — it logs and exits without deleting). Read from environment variable of the same name; can be overridden in superset_config.py.
New or Changed Public Interfaces
New endpoints (same shape across the three entity types)
Endpoints are UUID-keyed end-to-end. version_uuid is a deterministic v5 derivation from (entity_uuid, transaction_id), stable across requests, so clients can bookmark a specific version.
All three endpoints carry the standard Superset decorator stack: @protect() (FAB model-level can_write check), @safe, @statsd_metrics, and @event_logger.log_this_with_context so each call appears in FAB's action_log alongside other audited API operations.
Authorization layering:
All three endpoints enforce both model-level access (@protect() — FAB's can_write check) and row-level access (security_manager.raise_for_ownership(entity) inside each handler), for parity. A user with can_write Dashboard who lacks per-dashboard role access on a specific dashboard can neither read its version history nor restore it. Workspace admins bypass via the existing FAB mechanism.
(An earlier draft loosened this on the read endpoints; we tightened to match restore so the read and write surfaces have the same authorization model. Cheap to implement, no net cost, avoids a class of "I can read but not act" surprises.)
GET /versions/ and GET /versions/<version_uuid>/ responses include a changes array on each entry, shape [{kind, path, from_value, to_value}]. For operation_type=0 (baseline / first-create) transactions the array is empty by design.
Concurrency tokens via ETag
To enable optimistic locking on saves (and to give the future UI SIP a stable handle for "you're editing version N" affordances), V1 emits an ETag response header carrying the current version_uuid on:
GET /api/v1/chart/<pk> / <uuid> — single chart fetch (drives the Explore editor)
GET /api/v1/dashboard/<pk> / <uuid> — single dashboard fetch (drives the dashboard editor)
GET /api/v1/dataset/<pk> / <uuid> — single dataset fetch (drives the dataset editor)
PUT /api/v1/{resource}/<pk> save responses — carry the newversion_uuid produced by the save
The new version endpoints (GET /versions/, GET /versions/<version_uuid>/) — carry the version's own version_uuid so clients can directly cache it
The header value is the strong-form ETag "<version_uuid>" (RFC 7232 quoted), where version_uuid is the same deterministic v5 derivation from (entity_uuid, transaction_id) documented above. Cross-origin clients reading the API (notably future write-enabled embedded surfaces, third-party integrations, and the OpenAPI-generated SDK) will see the header via Access-Control-Expose-Headers: ETag set on the affected responses.
V1 ships the data backbone only. The server emits the ETag everywhere a client would need it to track entity versions, but does NOT enforce If-Match on writes — saves go through whether or not the request includes a precondition header. The locking semantics (412 Precondition Failed on stale If-Match, the UI conflict-resolution flow, whether to block or warn) are deferred to the follow-up UI SIP. Clients adopting V1 can already cache the ETag from every response and use it in their own client-side conflict detection if they want; the server-side gate lands later.
ETag was chosen over a body field (json.result.version_uuid) because (a) headers don't pollute the response body schema, (b) ETag / If-Match is the standard HTTP optimistic-locking pattern that every client library already understands, and (c) embedded dashboards (read-only via guest tokens; the surface most sensitive to body-shape changes) are unaffected by adding response headers — the header is simply ignored by clients that don't read it.
Model changes
Dashboard, Slice, and SqlaTable gain a __versioned__ class attribute. TableColumn and SqlMetric also gain __versioned__ (with the four audit fields excluded). dashboard_slices is reshaped by PR #39859 to a composite PK on (dashboard_id, slice_id) — no other columns or relationships are modified.
New tables
Table
Purpose
version_transaction
Continuum's transaction log (renamed from default transaction)
Continuum shadow for the dashboard ↔ chart M2M (depends on PR #39859)
version_changes
Structured per-field diff records
Naming note: shadows follow Continuum's <entity>_version convention; the bookkeeping/diff tables (version_transaction, version_changes) use a version_* prefix.
New Dependencies
sqlalchemy-continuum >= 1.6.0, < 2.0.0 — automatic shadow table versioning for SQLAlchemy models. No other runtime dependencies are added; the diff engine, listeners, and restore logic are pure stdlib + SQLAlchemy.
Stack dependency on PR #39859 — reshapes the live dashboard_slices table to a composite PK on (dashboard_id, slice_id) so Continuum's M2M tracker produces shadow inserts whose shape matches the auto-generated dashboard_slices_version. Addresses SQLAlchemy-Continuum issue #129 (M2M restore against junction tables with surrogate PKs).
Maintenance considerations for sqlalchemy-continuum. The library is single-maintainer but actively maintained on a 2–3-releases/year cadence against SQLAlchemy 1.4 and 2.x in parallel. SQLAlchemy 2.0 support shipped in Continuum 1.4.0 (2023-07-12), well ahead of Superset's own 2.x migration. The 1.5 series (2025) added Python 3.13/3.14 support and an SQLAlchemy 2.x cleanup pass; 1.6.0 (2026-01-22) is the current release and the one this SIP pins to (>= 1.6.0, < 2.0.0).
Integrating Continuum productively meant learning a few non-obvious usage patterns — none of them Continuum bugs:
M2M secondary tables need composite primary keys for Continuum's tracker to map columns through cleanly. Superset's existing dashboard_slices table had a surrogate id; PR refactor(db): composite PK on M2M association tables #39859 reshapes it to a composite PK on (dashboard_id, slice_id) — the conventional SQL shape that Continuum (and most ORM tooling) expects.
Multi-relation Reverter calls need an explicit no_autoflush scope. SQLAlchemy's autoflush — triggered by the lazy-load query between iterating one relation and the next — transitions marked-for-deletion children to state.deleted=True mid-iteration. The fix is using SQLAlchemy correctly (one flush per logical operation, expressed as single_flush_scope), not patching Continuum.
The M2M restore query joins through slices_version. Dashboards that pre-date versioning need synthesised baseline shadow rows for their attached slices so a "restore to baseline" brings back the right slice set. This is application logic specific to "adopting versioning on a populated DB," not a Continuum gap.
The integration is well-understood and we don't depend on undocumented surface area. Single-maintainer dependencies always carry tail risk regardless of how healthy they are today; the realistic contingency if upstream stalls in the future is a Superset-side vendored fork limited to the surface we use (Reverter, version_class, make_versioned, is_modified, the M2M tracker, and the validity strategy) — small enough to maintain as a few hundred lines under superset/_vendor/. Trigger conditions would be either (a) a security issue in Continuum with no upstream response within a release cycle, or (b) a SQLAlchemy upgrade Superset needs to take that Continuum hasn't shipped support for within the same window.
Migration Plan and Compatibility
Three Alembic migrations land in order:
add_entity_version_history_tables — creates version_transaction and the three parent shadow tables (dashboards_version, slices_version, tables_version).
add_child_continuum_shadow_tables — creates table_columns_version, sql_metrics_version, and dashboard_slices_version.
All three migrations are reversible. downgrade() drops every table they create. Round-trip tested on SQLite, PostgreSQL, and MySQL.
The migrations are backwards-compatible — adding new tables does not affect existing rows or behaviour. Rolling back loses captured version history but leaves the live entity tables untouched.
Deploy footprint. Each migration is a CREATE TABLE against a new name plus FK and index DDL — no ALTER on existing tables, no row-level reads or writes, no data backfill. On managed Postgres / MySQL the wall-clock cost is bounded by DDL latency (typically seconds, not minutes) regardless of dataset size. Installs already running the composite-PK migration #39859 have their largest schema-rewrite cost in that PR; this SIP is additive on top.
Kill plan if the feature regresses post-merge. Because this SIP ships without a feature flag (see Alternative #8), the rollback path is the three Alembic downgrades, in reverse order: add_child_continuum_shadow_tables → add_version_changes_table → add_entity_version_history_tables. After downgrade, the listener chain becomes a no-op (each listener narrow-catches OperationalError for "no such table"), so the rest of the application continues working. Captured version history is lost; the live entity tables are untouched. Operators can disable the Celery beat task by removing the entry from CELERYBEAT_SCHEDULE if they want to keep the schema but stop pruning during incident response.
Import/export: the v1 import pipeline (ImportDashboardsCommand, ImportChartsCommand, ImportDatasetsCommand) uses ORM add() / merge() / setattr and per-entity setattr for property updates, so all overwrites trigger the SQLAlchemy event chain and produce version rows attributed to the importing user. Two bulk-DML edge cases are rewritten to keep Continuum's flush hooks engaged:
DatasetDAO.update_columns()'s bulk_insert_mappings / query().delete() path is replaced with individual ORM session.delete() / session.add() calls so column changes fire the listener chain.
The dashboard_slices association writes in both commands/importers/v1/assets.py (asset import) and commands/dashboard/importers/v1/__init__.py (dashboard import) replace the previous Core delete(...) + insert(...) pair with ORM-level dashboard.slices = [...] reassignment followed by an explicit db.session.flush(). Core DML on dashboard_slices would emit malformed shadow inserts into dashboard_slices_version (Continuum's M2M tracker can't see the composite-PK columns through the Core layer); and the explicit flush prevents an inner-flush event handler from resetting the relationship change before it's persisted (cf. the SAWarning: Attribute history events accumulated on 1 previously clean instances within inner-flush event handlers have been reset that originally surfaced this).
No additional special handling for imports is required beyond those rewrites.
Direct database access: the version-history tables (Continuum shadows + version_transaction + version_changes) are written only by the listener chain. External tooling that writes directly to the main entity tables will not have version rows captured for those writes.
Operational notes
Brief notes on how versioning interacts with adjacent surface areas. None of these are open design questions:
Embedded dashboards / guest tokens. Version endpoints all require can_write on the underlying entity model (@protect() decorator). Guest tokens grant read-only access scoped to a specific dashboard, so they cannot reach the version endpoints; the embedded surface is unaffected.
CLI imports.superset import-dashboards <file> and the equivalent commands flow through the same v1 import pipeline as the API and produce version rows the same way: a baseline (if the entity had no prior versions) plus an update row per imported entity. A 100-dashboard bulk import grows each entity's history by at most one row; subsequent retention pruning ages those rows out by SUPERSET_VERSION_HISTORY_RETENTION_DAYS.
UUID-keyed version endpoints alongside integer-keyed CRUD. Existing /api/v1/{resource}/<pk> endpoints keep their integer <pk> parameter; the new version endpoints take <uuid> to align with the broader Superset UUID migration. Clients fetch the entity's UUID from the standard list/get response and use it in subsequent version calls. The two paths coexist deliberately; nothing about the existing endpoints changes.
Plugin / inheritance behaviour — warning. Forks and downstream deployments that subclass Slice, Dashboard, or SqlaTable inherit __versioned__ automatically — derived classes get versioned without explicit opt-in. Plugin authors adding columns to a subclass need to think about versioning impact at design time: any new column will be shadowed unless explicitly added to __versioned__.exclude on the subclass. Large blob columns added to subclasses (e.g. a plugin storing rendered HTML, cached query payloads, or large derived state) can dominate the shadow table and version_changes payload sizes if not excluded. Subclasses themselves don't need their own __versioned__ declaration to be captured, but they DO need one to opt columns out.
is_managed_externally entities. Entities flagged as externally-managed (Terraform, CI/CD, or repeated CLI imports as the source of truth) follow the same restore rules as any other entity — restore_version works, the new version row is attributed to the restoring user, and dependent assets behave per Cross-entity effects of restore. Operators should be aware that a local restore drifts from the external source of truth: the next sync from the upstream tool will overwrite the restored state with whatever it considers current. Restore on these entities is therefore best used to recover from a bad sync run before re-running it, not as a permanent edit. Documented in: an UPDATING.md entry (the canonical operator-visible release note) plus a tooltip on the restore confirmation modal in the V2 UI SIP.
Audit attribution and deleted users.version_transaction.user_id is an unkeyed INTEGER column (no FK), deliberately, so that CLI / Celery / import / unauthenticated saves can write rows without an active Flask user. The trade-off is that when a user is later deleted from ab_user, their user_id references in version_transaction become dangling — GET /versions/ returns changed_by: null for those rows rather than 500'ing. This is the right behavior for an audit history (the history shouldn't disappear with the user) but should be on operator radar: a workspace that frequently deletes-and-recreates user accounts will see "system" attributions on older versions.
Alternatives Considered
Hybrid (Continuum for parent scalars, JSON snapshot tables for child collections) — Two purpose-built JSON-blob tables (dataset_snapshots, dashboard_snapshots) keyed on (entity_id, transaction_id) could store child state as columns_json / metrics_json / slice_ids_json instead of using Continuum's auto-generated child shadow tables. Restore would deserialize the JSON, expunge live children, and re-insert. Rejected because the storage cost of full-collection snapshots on every save (one row of N child rows per save) exceeds the row-per-changed-child cost of shadow tables for any non-trivial edit cadence, and because maintaining schema changes for a JSON blob is more challenging than maintaining table schemas.
Custom JSON snapshot table for everything — A single entity_versions table with a snapshot JSONB column. Restore would be simpler (deserialize and setattr), and Continuum's shadow tables would be avoided entirely. Rejected because Continuum captures scalar updates automatically across all three entity types without instrumenting every save path, and a fully-custom approach would need manual schema synchronisation as entity models evolve.
SQLAlchemy-History — A fork of Continuum. Inactive, smaller community, no meaningful feature advantage.
Database-level temporal tables / system versioning — Native or extension-based row-history tracking via SQL semantics (FOR SYSTEM_TIME AS OF …). Would shift capture into the engine and eliminate the Python library dependency. Rejected for two reasons. (a) Cross-database support is uneven: MariaDB 10.3+ has native WITH SYSTEM VERSIONING; PostgreSQL has no native system versioning (the third-party temporal_tables extension exists but isn't in core); MySQL has no native system versioning either (commonly confused with MariaDB); SQLite has none and would need triggers that sacrifice atomicity. Supporting all four would require four backend-specific paths plus a fallback. (b) Application-level concerns still cost code: temporal tables capture what changed but not who (user attribution would still need triggers or an application-level transaction table — re-inventing Continuum's version_transaction); historical queries are read-only, so restore is still application code; the version_changes field-level diff records need a diff engine regardless; and the cross-table identity problems (override_columns, dashboard_slices) that drove R-017/R-018 are independent of where snapshots live. With Continuum the Python implementation is identical across all four backends.
Split-revert pattern (one relation per call, with flush + expire between calls) — was the original chosen workaround for the Reverter / autoflush / cascade-add interaction described in Restore above. Initially shipped that way; rejected once the change-records listener was wired up. Per-relation flushes spread one logical restore across multiple Continuum transactions, the listener's tx-dedup guard (necessary to prevent UNIQUE-constraint conflicts on version_changes from autoflush re-firings) skipped the second pass, and child-addition records were silently dropped from version_changes — the dropdown rendered the restore as an empty "Baseline" entry. Replaced with the no_autoflush wrap, which closes the autoflush window without splitting the restore. See test_restore_emits_full_child_diff_in_one_transaction.
Synchronous (per-save) retention pruning — A second after_commit listener could prune at the moment a new version row is committed (e.g., delete the oldest row whenever a per-entity count threshold is exceeded). Rejected because the prune work falls onto the user's save path, adding latency proportional to retention churn; installations have no way to defer it to off-peak hours; and the model couples pruning cadence to save cadence (a rarely-edited entity never gets pruned). The Celery beat approach decouples retention from the request lifecycle.
Hand-rolled diff engine vs. external library (deepdiff, dictdiffer, jsonpatch) — Considered and rejected. The on-disk path shape and kind classification (filter vs. metric vs. field etc.) are co-located with diff walking; external libraries return JSON-Pointer or string paths that would need translation. Child-collection identity uses natural keys (column_name, metric_name, slice uuid), not list indices, which is also non-default. The hand-rolled diff is ~500 LOC of pure functions with comprehensive unit-test coverage.
Default-off feature flag (e.g. VERSION_HISTORY_ENABLED) — Considered and not added. The implementation is engineered to no-op cleanly when its tables don't exist (each listener narrow-catches OperationalError on the SQLite "no such table" startup race; broader DB errors propagate so operators can fix migrations rather than silently lose capture). A flag adds a configuration surface that can drift from the migration state — deployments could disable the flag while leaving the tables populated, then re-enable months later with stale data. The cleaner kill switch, if needed, is to skip the three migrations entirely. If post-launch evidence shows operators want a runtime off-switch, a flag can be added in a follow-up without breaking compatibility.
Reuse the import/export YAML pipeline for storage (the approach proposed by SIP-192 #36145) — Store each version as a YAML bundle produced by ExportDashboardsCommand / ExportChartsCommand / ExportDatasetsCommand; restore by feeding the bundle back through the corresponding Import*Command. Pragmatic upside: reuses well-tested serialization that already handles UUID-based identity, audit-field omission, and JSON normalization. Rejected because (a) the format is optimized for cross-system entity transfer, not same-system temporal storage — full snapshots per save (even when one field changed) inflate storage relative to Continuum's per-column delta, and field coverage is bound by each model's export_fields rather than the full column set; (b) diff records would have to be computed at read time by parsing the YAML, which both increases list-endpoint latency and pushes the diff engine onto the read path instead of the write path; (c) no native validity intervals — end_transaction_id-style linking would have to be reinvented to answer "what was live at time T"; (d) child collections (TableColumn / SqlMetric / dashboard_slices) are embedded inside parent YAML, so restore becomes a delete-and-reinsert on every related table rather than a targeted shadow-row revert, and atomic capture of child-only edits requires re-exporting the parent on each child save. The shared primitives (UUID identity, audit-field exclusion) are layered into this SIP's design directly; the storage shape is purpose-built for the temporal access pattern. (SIP-192's analysis of cross-entity restore effects — dataset restore may break dependent charts, dashboard restore doesn't snapshot-through to chart content — was excellent and is incorporated here; see Cross-entity effects of restore above.) The export-pipeline approach is, however, a good shape for an extension: an installation that wants Git-backed version history — every save committed to a repo as a YAML bundle, with restore done by replaying a bundle through the import command — could implement that on top of this SIP's listener hooks (or independently) without any change to V1. We see the two as complementary rather than competing: this SIP gives every install the inline safety net by default; an export-to-Git extension layers a durable, externally-auditable record on top for installs that want it.
Manual / labeled versions only (also from SIP-192 #36145) — Versions created only when a user explicitly clicks "save a new version" and supplies a description. Conceptually simpler (no listener chain, no retention task, no no-op suppression) and arguably more aligned with how users think about "milestones." Rejected as the V1 default because most users won't remember to checkpoint before a destructive edit — the safety-net use case that motivates this SIP only works if capture is automatic. Auto-capture with optional user-supplied labels is a strictly larger feature (labels can be layered on top of automatic versions in a follow-up); manual-only forecloses the safety-net path. Labels-as-an-enhancement are flagged in Open Questions below.
Future Work
Items intentionally deferred out of V1 with the phase they belong to:
Deeper structural diff inside layout components — V2 (UI SIP).diff_dashboard_layout currently emits one record per logical action on a top-level component (chart added/removed/moved/edited, row added, etc.). Edits inside a chart's meta (resized, restyled) surface as a single edit record carrying the full meta dict. Per-property records would be more useful for a UI, but the right granularity is UX-driven; we wait for V2 to validate before committing to a shape.
Multi-entity transaction display — V2 (UI SIP). A single save (e.g. a dashboard import) can produce versions across multiple entities sharing one version_transaction.id. The backend records them correctly keyed on (transaction_id, entity_kind, entity_id) and the list endpoint filters per-entity. How V2's UI surfaces a transaction that touches multiple entities (a single combined "this save changed X, Y, and Z" entry vs. per-entity entries that cross-reference each other) is a UX decision deferred to that SIP.
If-Match enforcement on writes — V3 (locking SIP). V1 emits the ETag header carrying version_uuid on the save and editor-fetch endpoints listed above, so the data backbone for optimistic locking is in place. V1 does NOT enforce If-Match on writes — saves succeed regardless of whether the client sent a precondition header. When and how to turn enforcement on (412 response semantics, banner / blocking modal / merge view) is UX-driven and belongs in V3.
User-supplied labels on versions — V2 (UI SIP). Every V1 version is identified by version_uuid, version_number, and issued_at. SIP-192 #36145 proposed a user-facing flow where the user types a description ("before Q4 numbers") when saving a meaningful checkpoint. Auto-capture and labels aren't mutually exclusive — a label (nullable text) column on version_transaction plus a PUT /api/v1/{resource}/<uuid>/versions/<version_uuid>/label endpoint can be layered on top in V2 once the UI side has the entry point. Labelling without a UI to enter labels in is low-value, so V1 doesn't add the data model.
Runtime versioning toggles — none in V1. Two distinct toggles were considered and deferred:
Global runtime disable (e.g. a RETENTION = -1 / NULL sentinel, or a separate VERSION_HISTORY_ENABLED flag): an env-var off switch that doesn't require rolling back migrations. Deferred because (a) it adds a config-vs-migration-state drift surface — capture could be disabled while shadow tables stay populated, producing a half-filled history if it's re-enabled later; (b) the migration-skip path (see Migration Plan and Compatibility — Kill plan) already provides a drift-free off switch; (c) SUPERSET_VERSION_HISTORY_RETENTION_DAYS = 1 keeps only one day of history — operationally close to "off" for installs that want near-zero on-disk capture. See Alternatives Considered Preventing bad json from creating problems #8 for the broader feature-flag discussion.
Per-workspace toggle: different workspaces inside one Superset install running with different versioning policies (e.g. some require capture for audit, others legally cannot retain any). Out of scope as a different feature shape — needs a workspace-scoped config surface that doesn't exist yet, and the same surface would apply to several adjacent capabilities (RLS, tag visibility, retention windows) not just versioning.
Either can be added post-launch without breaking compatibility if operator evidence supports it.
Scope boundary for V2. V2's job is the UI for V1 — version-history dropdown, restore confirmation flow, localised rendering of version_changes records, the cross-entity-effects warning panel, and (optionally) the label-editing surface that #4 above unlocks. If V2 grows further — multi-version comparison views, "blame"-style per-line attribution, cross-entity bulk restore — those split out into V2/V3 to keep each SIP shippable. Locking (V3) is the only feature deliberately reserved for a separate phase regardless of V2's growth.
[SIP] Entity Version History for Dashboards, Charts, and Datasets
Motivation
Every save to a dashboard, chart, or dataset in Superset is destructive — the previous state is permanently lost. Users who accidentally overwrite a dashboard layout, corrupt a set of filters, or remove calculated metrics have no recovery path short of a database backup, which requires admin intervention and may not be available.
This is a consistent source of support tickets and user frustration. Other platforms offer built-in version history with one-click restore. The absence of this capability in Superset is a pain point for organizations managing critical dashboards.
Phasing
V1 (this SIP) ships only the backend. Two follow-up SIPs round out the user-visible experience:
ETagresponse header (informational).changesrecords into human-readable strings; cross-entity warning panel for dataset restore (e.g. "this restore may affect 3 charts").If-Matchenforcement on save endpoints;412 Precondition Failedresponses; UX for stale-token recovery (banner / blocking modal / merge view). Builds on V1'sETagheader.V1 is intentionally back-end-only so that the persistence layer can stabilise and migrate forward (UI changes are far more sensitive to breaking-change feedback than back-end API additions). V1's APIs are designed to be stable for V2 to consume without further migration.
Success criteria
V1 is complete when all of the following hold:
GET /api/v1/{resource}/<uuid>/versions/returns p95 under 1s for entities with up to 30 days of history at the default retention.POST /api/v1/{resource}/<uuid>/versions/<v>/restorereturns p95 under 3s.issued_at; closed historical rows older thanSUPERSET_VERSION_HISTORY_RETENTION_DAYSare pruned by a single Celery beat task running daily.Proposed Change
Add automatic version history to dashboards, charts, and datasets. Every save creates a new version. Users can browse the history of any entity, inspect what each save changed, and restore a previous version. Restore is non-destructive: it produces a new version row that brings the entity back to a prior state, leaving the version chain intact.
Strategy at a glance
Dashboard/Slice/SqlaTableand childrenTableColumn/SqlMetric) into a shadow table; thedashboard_slicesM2M gets its own shadow. No save-path instrumentation needed beyond a singlemake_versioned()call at app init.changed_on,created_on,changed_by_fk,created_by_fk, pluslast_saved_at/last_saved_by_fkonSlice) are excluded from__versioned__so their unconditional auto-bump can't trigger a row on its own; for the content-equivalent case (e.g.set_dash_metadatare-serialisingjson_metadatato a different byte sequence with identical parsed content), a Continuum plugin (SkipUnmodifiedPlugin) compares post-flush column values against the previous live shadow row and marks matching operationsprocessed. ForDashboard.json_metadataspecifically, the comparison parses the JSON and drops a registered set of frontend-stamped sub-keys (map_label_colors,chart_configuration,global_chart_configuration,show_chart_timestamps,color_namespace) before equating — these are regenerated client-side on every save from runtime singletons (theLabelsColorMapregistry, the cross-filter configuration etc.), so two consecutive saves with no user-authored change produce different bytes forjson_metadata; the audit-key filter keeps the skip-plugin's comparison aligned with the diff engine'sDASHBOARD_JSON_METADATA_AUDIT_KEYS. A_COLUMN_NORMALIZERSregistry is the extension point for analogous transient fields on other entities. Continuum still allocates theversion_transactionrow for a suppressed operation; it ends up as a short-lived orphan that doesn't surface in the version-list endpoint and gets swept by retention.operation_type=0row representing the state before that save, so the version history for that entity isn't a single mystery edit. The baseline is treated like any other historical row by retention — it ages out alongside the rest of the history.Reverteris the engine;VersionDAO.restore_version()calls it once across all related collections insidedb.session.no_autoflush, then issues a single explicit flush — single Continuum transaction so the change-records listener captures the full child diff in one pass. Stampschanged_on/changed_by_fkon the live entity so the restoring commit is attributed to the user who clicked Restore. The new shadow row that the restoring commit produces is what gives "non-destructive restore."version_changes(one row per atomic change, keyed toversion_transaction.id). Captured forward at save time so the UI can render"Added column 'country'"/"Renamed dashboard"without diffing snapshots at read time.SUPERSET_VERSION_HISTORY_RETENTION_DAYSenv var. A scheduled Celery beat task ages out old shadow rows. Only the live (current) row is preserved regardless of age; closed historical rows including the baseline age out.GET /api/v1/{resource}/<uuid>/versions/andPOST /api/v1/{resource}/<uuid>/versions/<version_uuid>/restore. Plus anETag: "<version_uuid>"header on save responses and editor-fetch GETs so future UI work can do optimistic locking.The four save-time pieces (Continuum capture, baseline listener, change-record listener, and the M2M / child shadow tables that come along with
__versioned__) are all wired up in a singleinit_versioning()step at app start. The three persistence layers (Continuum shadows,version_transaction,version_changes) are added by three Alembic migrations. The Celery beat task is registered in the existingCELERYBEAT_SCHEDULE. No feature flag, no UI in v1.Per-entity restore scope
dashboard_title,position_json,json_metadata,slug,css,description,certified_by,certification_details,published,external_url,theme_id,is_managed_externally); chart membership (dashboard_slices— which charts are attached, in what layout)owners,roles, tagsslice_name,params,viz_type,description,cache_timeout,certified_by,certification_details,external_url,is_managed_externally); the dataset linkage (datasource_id,datasource_type)owners, tags,query_context(cached/regenerated), the dataset's own statetable_name,schema,catalog,database_id,sql,description,main_dttm_col,default_endpoint,offset,cache_timeout,params,template_params,extra,fetch_values_predicate,is_sqllab_view,is_managed_externally); calculated columns (TableColumnrows —column_name,expression,type,verbose_name,is_dttm,groupby,filterable, etc.); metrics (SqlMetricrows —metric_name,expression,metric_type,verbose_name,d3format,currency, etc.)owners,row_level_security_filters, tags, charts that depend on this datasetCross-entity effects of restore
Restore brings back the asset's own state, not the state of its dependencies at that time. This principle — well articulated by SIP-192 #36145 — keeps the operation predictable but produces three scenarios worth flagging explicitly. The unifying test for whether each is acceptable: would the same outcome have occurred if the asset had simply been edited that way live? In each case below the answer is yes — restore puts you in a state that's reachable through normal editing.
Restoring a dataset may break dependent charts. If a column or metric a chart relied on was renamed or removed at the restore target, the chart renders errors until a user updates it. A chart broken by a forward edit to its dataset would break in the same way — restore doesn't take you somewhere unreachable; it takes you to a previous state that produced its own breakage.
Restoring a chart is reflected in every dashboard it appears in. The dashboard itself isn't modified by the restore, but its next render shows the restored chart in every position it occupies. No version row is minted for any of those dashboards.
Restoring a dashboard restores its chart membership but not chart content. Two sub-scenarios:
V1 does not detect or warn before any of these. The user is expected to verify dependent assets after a restore. A pre-restore warning panel ("this restore may break 3 charts") is a V2 (UI SIP) concern; the cross-entity relationships needed to power it (chart → datasets, dashboard → charts) are already exposed through existing endpoints, so no further backend work is needed before the UI SIP can build on them.
The remainder of this section drills into each piece.
Continuum-driven scalar capture
SQLAlchemy-Continuum is added as a dependency and configured in
superset/extensions/__init__.py:Two custom subclasses fix integration friction:
VersionTransactionFactoryrenames Continuum's defaulttransactiontable toversion_transaction(avoids collision with downstream extensions that use the unqualified name) and the PostgreSQL sequence toversion_transaction_id_seq.VersioningFlaskPluginoverridestransaction_args()to read the acting user viasuperset.utils.core.get_user_id()instead offlask_login.current_user. The default plugin readscurrent_user, which Flask-Login populates under cookie-authenticated sessions but not under JWT auth (JWT bypasses Flask-Login).get_user_id()readsg.user, populated by both auth modes, so the custom plugin attributes API saves consistently.The three entity types declare which columns to track:
Soft-delete coordination. The soft-delete SIP (sc-103157) is a parallel proposal. The two are independent in design and orthogonal in implementation, but they touch the same model columns, so the merge order matters:
# deleted_at exclusion will be added when sc-103157 (soft delete) is merged (T043)) insuperset/models/dashboard.py,superset/models/slice.py,superset/connectors/sqla/models.py, andsuperset/daos/version.py. The soft-delete PR addsdeleted_atto each__versioned__.excludelist and removes the placeholders. No coordination beyond a one-line per model change.deleted_atfrom day one. The PR description should note the merge order taken.deleted_at? The conservative answer (yes — restoring a version where the entity was alive brings the entity back to alive) is consistent with how Continuum'sReverterwould behave, sincedeleted_atis excluded from versioning. We expect this to fall out cleanly but the soft-delete PR should add an integration test that exercises "soft-delete then restore an earlier version" to lock in the semantics.make_versioned()registers SQLAlchemy mapper events that activate when__versioned__models go throughconfigure_mappers()(lazily, at first session use). It must therefore run before any code path that triggers mapper configuration. For Flask-SQLAlchemy this means inextensions/__init__.pyimmediately afterdb = SQLAlchemy(), before any model module is imported by the app initialiser.Synthetic baseline capture and parent-on-child-change
A
before_flushlistener (register_baseline_listener()) does two things:Synthetic baseline. Inserts a synthetic baseline row (
operation_type=0) the first time a pre-existing entity is updated under versioning. The very first save of any pre-existing dashboard, chart, or dataset produces two version rows — the baseline (state before the edit) and the edit itself — giving users an immediate rollback point. The listener is prepended (insert=True) in the SQLAlchemy event chain so the baseline's transaction id is allocated before Continuum's own listener allocates the update's, keeping the baseline sorted first in the version history.Force-parent-dirty on child change. When a versioned child (
TableColumn/SqlMetricforSqlaTable) is insession.dirty/new/deletedbut the parent's own scalar columns haven't been touched, the listenerattributes.flag_modifieds the first non-excluded versioned column on the parent. This puts the parent insession.dirtyso Continuum's UnitOfWork creates a parent UPDATE operation — without it, child-only edits (e.g. editing a column description) would leave the parent out of the dirty set, no parent shadow row would be written, and the version dropdown — which lists parent shadow events — would be empty for column/metric-only saves. The parent's new shadow row's scalar columns mirror the previous version (only the children actually changed); the row exists to anchor the transaction in the parent's version chain.SkipUnmodifiedPlugin._is_no_op_updateis taught to recognise this case (_has_dirty_versioned_children) so it doesn't fall back to "scalars match → skip".Continuum shadow tables for child state
TableColumn,SqlMetric, and thedashboard_slicesassociation are versioned via Continuum's auto-generated shadow tables on the samevaliditystrategy as the parents:dashboards_version,slices_version,tables_versionvaliditystrategytable_columns_version,sql_metrics_version__versioned__onTableColumn/SqlMetricexcludes the four audit fields (changed_on/by_fk,created_on/by_fk) so child diffs don't surface "changed_on shifted" noise on every parent savedashboard_slices_version**params + transaction_id + operation_type, whereparamsis what the live INSERT carries; the composite-PK shape lets that map to the shadow's PK cleanlyThree pieces of the design let Continuum-tracked children work cleanly against Superset's existing entity model:
DatasetDAO._override_columns. The dataset-edit path keys child writes oncolumn_name(andmetric_nameforSqlMetric), updating in place rather than deleting and reinserting under fresh primary keys. Stable PKs across edits keep the shadow trail unambiguous — no overlapping(id, column_name)rows for the same logical column.dashboard_slices(PR #39859). The live association table uses a composite PK on(dashboard_id, slice_id)instead of a surrogateid. Continuum's M2M tracker builds shadow inserts from**params + transaction_id + operation_type, whereparamsis what the live INSERT carries. With the composite-PK live shape, that maps to the auto-generateddashboard_slices_versionPK cleanly.operation_type=0shadow rows indashboard_slices_versionAND inslices_versionfor each attached slice that has no prior shadow, using the dashboard's baseline transaction id. The M2M version-side restore query joinsdashboard_slices_versionwithslices_versionand filters by validity at the dashboard's tx — without the slice-side baseline, dashboards whose membership predates versioning would restore to an emptysliceslist.The restore path uses Continuum's
Reverternatively, wrapped indb.session.no_autoflush, to avoid a Reverter / autoflush / cascade-add interaction that fires whenrevert(relations=['a','b'])is given two or more uselist relations and the target tx requires removing live children — see Restore below.Restore
VersionDAO.restore_version()uses Continuum'sReverternatively. It callstarget_version.revert(relations=_RESTORE_RELATIONS[<class>])once with the full relation list, wrapped indb.session.no_autoflush, then issues a single explicitdb.session.flush()afterwards. (_RESTORE_RELATIONS={"SqlaTable": ["columns", "metrics"], "Dashboard": ["slices"], "Slice": []}.)After the revert, the live entity's
changed_onandchanged_by_fkare overwritten withdatetime.now()and the restoring user's id, so the resulting new version row attributes the restore to the right user.created_on/created_by_fkare left untouched (they continue to record original authorship).The
no_autoflushwrap addresses a Reverter / SQLAlchemy autoflush / cascade-add interaction that fires whenrevert(relations=['a','b'])is given two or more uselist relations and the target tx requires removing live children. Without it, iterating from relationato relationbtriggers an internalgetattr(self.obj, prop.key)whose autoflush flushes pendingsession.delete(live_child)calls from relationa, transitioning those instances tostate.deleted=True; the finalsession.add(version_parent)then walks the parent'ssave-updatecascade through the in-memory collection (still holding the deleted-state instances) and raisesInvalidRequestError: Instance has been deleted.no_autoflushkeeps marked-for-deletion instances instate.persistent(queued insession.deletedbut not yet transitioned), so the cascade walk insession.addonly sees live instances. The single explicitdb.session.flush()below then drains all DELETEs + INSERTs atomically.Keeping the restore in a single flush is also load-bearing for the change-records listener: it computes the structured per-field diff in
after_flush, and an earlier per-relation flush loop split the restore across multiple Continuum transactions, causing the listener's tx-dedup guard to skip the second pass and silently drop child-addition records (e.g. a re-added calculated column would not appear inversion_changesfor the restore's transaction). The unified flush feeds the listener a complete shadow state, so the diff captures every child change in one record set. Thetest_restore_emits_full_child_diff_in_one_transactionintegration test locks the invariant in.Tags, owners, and roles are deliberately excluded across all three entity types — they are access/organisation metadata, not user-authored content. Restoring them would surprise users by retroactively changing collaborator or permission state. The exclusion is enforced by
__versioned__["exclude"]on each parent class.Structured change records
A
version_changestable records an atomic per-field diff for every save. Schema:idtransaction_idversion_transaction.id, ON DELETE CASCADEentity_kindchart/dashboard/datasetentity_idsequence(transaction_id, entity_kind, entity_id)kindfield,filter,metric,time_range,color_palette,dimension,column,chartpath["dashboard_title"],["params", "adhoc_filters", "country"],["json_metadata", "refresh_frequency"]from_value,to_valueIndexes and constraints:
UNIQUE (transaction_id, entity_kind, entity_id, sequence)— guards against duplicate inserts whenafter_flushre-fires within a single transaction (see Listener registration order above).INDEX (transaction_id)— supports the FK lookup and the per-version "what changed" query.INDEX (entity_kind, entity_id)— supports per-entity history queries (e.g., "show all change records for chart 42").INDEX (kind)— supports kind-filtered queries (e.g., "show every filter change across the workspace").A pure-Python diff engine (
superset/versioning/diff.py) walks pre-state vs post-state and emits the structured records that power per-version labels in the UI ("Added filtercountry", "Changed time range", etc.). The records themselves are machine-readable and language-neutral; rendering them into localized strings is a UI concern via Flask-Babelt(), deferred to the follow-up UI SIP. Highlights:Slice.paramsis JSON-parsed and walked: known keys (adhoc_filters,metrics,time_range, etc.) are promoted to first-class kinds; unknown keys fall through tokind="field".Dashboard.json_metadataandposition_jsonare JSON-parsed and walked at the top level (full Phase 2 nested-structure diff is deferred).null ↔ ""transitions are filtered as audit noise (Superset's save paths normalize nullable strings to"").Slice.last_saved_at,Slice.last_saved_by_fk, andparams.slice_idare stamped on every chart save and produce no record.The capture listener (
register_change_record_listener()) runsbefore_flush(read parent pre-state, compute scalar diff) andafter_flush(read child shadow tables for child-collection diffs, bulk-insert records).Listener registration order
Three listeners cooperate at flush time, and their registration order in
init_versioning()is load-bearing:before_flush(registered bymake_versioned()at app init) — allocatesversion_transaction.idand writes shadow rows.register_baseline_listener()— registered withinsert=Trueso itsbefore_flushruns before Continuum's, allocating the synthetic baseline tx id ahead of the update tx id. Also (a) walks dirty / new / deleted children back to their parent so a child-only flush still triggers the parent's baseline; (b) force-flags a versioned parent as dirty when any of its versioned children are dirty / new / deleted, so child-only edits produce a parent shadow row at the same transaction (otherwise the version dropdown would be empty for column/metric-only saves); (c) synthesises op=0 shadows for pre-versioning children + dashboard slices.register_change_record_listener()— registered LAST so itsafter_flushruns after Continuum has written the new shadow rows, then reads child shadow rows (table_columns_version/sql_metrics_version/dashboard_slices_versionjoined withslices_version) and emits per-change records intoversion_changes.The change-record listener also has to handle
after_flushre-firings within a single transaction (autoflush triggered by mid-commit queries can fire the chain more than once). It tracks processed transaction ids onsession.infoso the second firing is a no-op; without this dedup the unique constraint onversion_changeswould trip on duplicate inserts.Time-based retention via Celery
Retention is time-based: version rows whose owning
version_transaction.issued_atis older thanSUPERSET_VERSION_HISTORY_RETENTION_DAYS(default30) are pruned. The setting reads from the environment variable of the same name (withsuperset_config.pyoverride);0disables pruning entirely.Pruning runs as a scheduled Celery beat task rather than synchronously on the save path. This avoids adding latency to user saves and lets the prune run at off-peak hours. The task —
version_history.prune_old_versions, defined insuperset/tasks/version_history_retention.py— is registered inCELERYBEAT_SCHEDULEto run daily at 03:00 by default. Operators can change the schedule via the existingCELERYBEAT_SCHEDULEoverride insuperset_config.py.The task:
version_transactionrows withissued_at < (now() - retention_days), then filters out any transaction whose parent shadow rows include a live row (end_transaction_id IS NULL). The live row of any versioned entity must always remain queryable, so its owning transaction is preserved no matter how oldissued_atis. Closed historical rows — including the synthetic baseline (operation_type=0) — are NOT preserved separately; they age out with the rest of the history.dashboards_version,slices_version,tables_version) for the surviving candidate transactions.table_columns_version,sql_metrics_version,dashboard_slices_version) for the same transactions.version_transactionrows themselves.version_changesrows cascade automatically via theON DELETE CASCADEFK onversion_transaction.id.Failures (DB errors, missing tables) are caught, logged, and the next scheduled run retries from a clean slate. The task is idempotent — a second run with no time elapsed prunes nothing.
A pure-Python
_prune_old_versions_impl(retention_days)is exposed alongside the Celery wrapper so unit / integration tests can exercise the prune deterministically by passing a fake retention value (and optionally backdatingversion_transaction.issued_atto force eligibility).A synchronous (per-save) prune was considered and rejected: it added per-save latency proportional to retention work, and gave installations no way to skip pruning during peak hours.
Storage cost at default retention
Order-of-magnitude estimate to size the operational impact. The dominant cost is the parent shadow row, which mirrors the live row at roughly 1–10 KB per dashboard / chart / dataset depending on
json_metadata/position_json/paramssize; child shadows (TableColumn,SqlMetric) anddashboard_slices_versionare ~150–300 bytes each;version_changesrecords average ~200 bytes (one record per atomic field change).The last row is the storage budget operators should think about. A single dashboard saved 50 times/day by 10 collaborators against 200 charts is not unusual in larger installs; the storage is bounded by
SUPERSET_VERSION_HISTORY_RETENTION_DAYS. Operators who want capture but not retained history can set the value low (e.g.7) and accept a shorter rollback window. Setting it to0keeps every row ever captured — useful for compliance but unbounded growth, paired with operator monitoring is recommended.New configuration keys
SUPERSET_VERSION_HISTORY_RETENTION_DAYS30version_history.prune_old_versionsCelery beat task.0disables pruning (the task still runs daily — it logs and exits without deleting). Read from environment variable of the same name; can be overridden insuperset_config.py.New or Changed Public Interfaces
New endpoints (same shape across the three entity types)
GET/api/v1/{resource}/<uuid>/versions/GET/api/v1/{resource}/<uuid>/versions/<version_uuid>/columns/metricsfor datasets)POST/api/v1/{resource}/<uuid>/versions/<version_uuid>/restore{resource}∈{chart, dashboard, dataset}.Endpoints are UUID-keyed end-to-end.
version_uuidis a deterministic v5 derivation from(entity_uuid, transaction_id), stable across requests, so clients can bookmark a specific version.All three endpoints carry the standard Superset decorator stack:
@protect()(FAB model-levelcan_writecheck),@safe,@statsd_metrics, and@event_logger.log_this_with_contextso each call appears in FAB'saction_logalongside other audited API operations.Authorization layering:
All three endpoints enforce both model-level access (
@protect()— FAB'scan_writecheck) and row-level access (security_manager.raise_for_ownership(entity)inside each handler), for parity. A user withcan_write Dashboardwho lacks per-dashboard role access on a specific dashboard can neither read its version history nor restore it. Workspace admins bypass via the existing FAB mechanism.(An earlier draft loosened this on the read endpoints; we tightened to match restore so the read and write surfaces have the same authorization model. Cheap to implement, no net cost, avoids a class of "I can read but not act" surprises.)
GET /versions/andGET /versions/<version_uuid>/responses include achangesarray on each entry, shape[{kind, path, from_value, to_value}]. Foroperation_type=0(baseline / first-create) transactions the array is empty by design.Concurrency tokens via
ETagTo enable optimistic locking on saves (and to give the future UI SIP a stable handle for "you're editing version N" affordances), V1 emits an
ETagresponse header carrying the currentversion_uuidon:GET /api/v1/chart/<pk>/<uuid>— single chart fetch (drives the Explore editor)GET /api/v1/dashboard/<pk>/<uuid>— single dashboard fetch (drives the dashboard editor)GET /api/v1/dataset/<pk>/<uuid>— single dataset fetch (drives the dataset editor)PUT /api/v1/{resource}/<pk>save responses — carry the newversion_uuidproduced by the saveGET /versions/,GET /versions/<version_uuid>/) — carry the version's ownversion_uuidso clients can directly cache itThe header value is the strong-form ETag
"<version_uuid>"(RFC 7232 quoted), whereversion_uuidis the same deterministic v5 derivation from(entity_uuid, transaction_id)documented above. Cross-origin clients reading the API (notably future write-enabled embedded surfaces, third-party integrations, and the OpenAPI-generated SDK) will see the header viaAccess-Control-Expose-Headers: ETagset on the affected responses.V1 ships the data backbone only. The server emits the ETag everywhere a client would need it to track entity versions, but does NOT enforce
If-Matchon writes — saves go through whether or not the request includes a precondition header. The locking semantics (412 Precondition Failed on staleIf-Match, the UI conflict-resolution flow, whether to block or warn) are deferred to the follow-up UI SIP. Clients adopting V1 can already cache the ETag from every response and use it in their own client-side conflict detection if they want; the server-side gate lands later.ETag was chosen over a body field (
json.result.version_uuid) because (a) headers don't pollute the response body schema, (b)ETag/If-Matchis the standard HTTP optimistic-locking pattern that every client library already understands, and (c) embedded dashboards (read-only via guest tokens; the surface most sensitive to body-shape changes) are unaffected by adding response headers — the header is simply ignored by clients that don't read it.Model changes
Dashboard,Slice, andSqlaTablegain a__versioned__class attribute.TableColumnandSqlMetricalso gain__versioned__(with the four audit fields excluded).dashboard_slicesis reshaped by PR #39859 to a composite PK on(dashboard_id, slice_id)— no other columns or relationships are modified.New tables
version_transactiontransaction)dashboards_version,slices_version,tables_versiontable_columns_version,sql_metrics_versiondashboard_slices_versionversion_changesNaming note: shadows follow Continuum's
<entity>_versionconvention; the bookkeeping/diff tables (version_transaction,version_changes) use aversion_*prefix.New Dependencies
sqlalchemy-continuum >= 1.6.0, < 2.0.0— automatic shadow table versioning for SQLAlchemy models. No other runtime dependencies are added; the diff engine, listeners, and restore logic are pure stdlib + SQLAlchemy.dashboard_slicestable to a composite PK on(dashboard_id, slice_id)so Continuum's M2M tracker produces shadow inserts whose shape matches the auto-generateddashboard_slices_version. Addresses SQLAlchemy-Continuum issue #129 (M2M restore against junction tables with surrogate PKs).Maintenance considerations for
sqlalchemy-continuum. The library is single-maintainer but actively maintained on a 2–3-releases/year cadence against SQLAlchemy 1.4 and 2.x in parallel. SQLAlchemy 2.0 support shipped in Continuum 1.4.0 (2023-07-12), well ahead of Superset's own 2.x migration. The 1.5 series (2025) added Python 3.13/3.14 support and an SQLAlchemy 2.x cleanup pass; 1.6.0 (2026-01-22) is the current release and the one this SIP pins to (>= 1.6.0, < 2.0.0).Integrating Continuum productively meant learning a few non-obvious usage patterns — none of them Continuum bugs:
dashboard_slicestable had a surrogateid; PR refactor(db): composite PK on M2M association tables #39859 reshapes it to a composite PK on(dashboard_id, slice_id)— the conventional SQL shape that Continuum (and most ORM tooling) expects.Revertercalls need an explicitno_autoflushscope. SQLAlchemy's autoflush — triggered by the lazy-load query between iterating one relation and the next — transitions marked-for-deletion children tostate.deleted=Truemid-iteration. The fix is using SQLAlchemy correctly (one flush per logical operation, expressed assingle_flush_scope), not patching Continuum.slices_version. Dashboards that pre-date versioning need synthesised baseline shadow rows for their attached slices so a "restore to baseline" brings back the right slice set. This is application logic specific to "adopting versioning on a populated DB," not a Continuum gap.The integration is well-understood and we don't depend on undocumented surface area. Single-maintainer dependencies always carry tail risk regardless of how healthy they are today; the realistic contingency if upstream stalls in the future is a Superset-side vendored fork limited to the surface we use (
Reverter,version_class,make_versioned,is_modified, the M2M tracker, and thevaliditystrategy) — small enough to maintain as a few hundred lines undersuperset/_vendor/. Trigger conditions would be either (a) a security issue in Continuum with no upstream response within a release cycle, or (b) a SQLAlchemy upgrade Superset needs to take that Continuum hasn't shipped support for within the same window.Migration Plan and Compatibility
Three Alembic migrations land in order:
add_entity_version_history_tables— createsversion_transactionand the three parent shadow tables (dashboards_version,slices_version,tables_version).add_version_changes_table— createsversion_changes.add_child_continuum_shadow_tables— createstable_columns_version,sql_metrics_version, anddashboard_slices_version.All three migrations are reversible.
downgrade()drops every table they create. Round-trip tested on SQLite, PostgreSQL, and MySQL.The migrations are backwards-compatible — adding new tables does not affect existing rows or behaviour. Rolling back loses captured version history but leaves the live entity tables untouched.
Deploy footprint. Each migration is a
CREATE TABLEagainst a new name plus FK and index DDL — noALTERon existing tables, no row-level reads or writes, no data backfill. On managed Postgres / MySQL the wall-clock cost is bounded by DDL latency (typically seconds, not minutes) regardless of dataset size. Installs already running the composite-PK migration #39859 have their largest schema-rewrite cost in that PR; this SIP is additive on top.Kill plan if the feature regresses post-merge. Because this SIP ships without a feature flag (see Alternative #8), the rollback path is the three Alembic downgrades, in reverse order:
add_child_continuum_shadow_tables→add_version_changes_table→add_entity_version_history_tables. After downgrade, the listener chain becomes a no-op (each listener narrow-catchesOperationalErrorfor "no such table"), so the rest of the application continues working. Captured version history is lost; the live entity tables are untouched. Operators can disable the Celery beat task by removing the entry fromCELERYBEAT_SCHEDULEif they want to keep the schema but stop pruning during incident response.Import/export: the v1 import pipeline (
ImportDashboardsCommand,ImportChartsCommand,ImportDatasetsCommand) uses ORMadd()/merge()/setattrand per-entitysetattrfor property updates, so all overwrites trigger the SQLAlchemy event chain and produce version rows attributed to the importing user. Two bulk-DML edge cases are rewritten to keep Continuum's flush hooks engaged:DatasetDAO.update_columns()'sbulk_insert_mappings/query().delete()path is replaced with individual ORMsession.delete()/session.add()calls so column changes fire the listener chain.dashboard_slicesassociation writes in bothcommands/importers/v1/assets.py(asset import) andcommands/dashboard/importers/v1/__init__.py(dashboard import) replace the previous Coredelete(...)+insert(...)pair with ORM-leveldashboard.slices = [...]reassignment followed by an explicitdb.session.flush(). Core DML ondashboard_sliceswould emit malformed shadow inserts intodashboard_slices_version(Continuum's M2M tracker can't see the composite-PK columns through the Core layer); and the explicit flush prevents an inner-flush event handler from resetting the relationship change before it's persisted (cf. theSAWarning: Attribute history events accumulated on 1 previously clean instances within inner-flush event handlers have been resetthat originally surfaced this).No additional special handling for imports is required beyond those rewrites.
Direct database access: the version-history tables (Continuum shadows +
version_transaction+version_changes) are written only by the listener chain. External tooling that writes directly to the main entity tables will not have version rows captured for those writes.Operational notes
Brief notes on how versioning interacts with adjacent surface areas. None of these are open design questions:
can_writeon the underlying entity model (@protect()decorator). Guest tokens grant read-only access scoped to a specific dashboard, so they cannot reach the version endpoints; the embedded surface is unaffected.superset import-dashboards <file>and the equivalent commands flow through the same v1 import pipeline as the API and produce version rows the same way: a baseline (if the entity had no prior versions) plus an update row per imported entity. A 100-dashboard bulk import grows each entity's history by at most one row; subsequent retention pruning ages those rows out bySUPERSET_VERSION_HISTORY_RETENTION_DAYS./api/v1/{resource}/<pk>endpoints keep their integer<pk>parameter; the new version endpoints take<uuid>to align with the broader Superset UUID migration. Clients fetch the entity's UUID from the standard list/get response and use it in subsequent version calls. The two paths coexist deliberately; nothing about the existing endpoints changes.Slice,Dashboard, orSqlaTableinherit__versioned__automatically — derived classes get versioned without explicit opt-in. Plugin authors adding columns to a subclass need to think about versioning impact at design time: any new column will be shadowed unless explicitly added to__versioned__.excludeon the subclass. Large blob columns added to subclasses (e.g. a plugin storing rendered HTML, cached query payloads, or large derived state) can dominate the shadow table andversion_changespayload sizes if not excluded. Subclasses themselves don't need their own__versioned__declaration to be captured, but they DO need one to opt columns out.is_managed_externallyentities. Entities flagged as externally-managed (Terraform, CI/CD, or repeated CLI imports as the source of truth) follow the same restore rules as any other entity —restore_versionworks, the new version row is attributed to the restoring user, and dependent assets behave per Cross-entity effects of restore. Operators should be aware that a local restore drifts from the external source of truth: the next sync from the upstream tool will overwrite the restored state with whatever it considers current. Restore on these entities is therefore best used to recover from a bad sync run before re-running it, not as a permanent edit. Documented in: anUPDATING.mdentry (the canonical operator-visible release note) plus a tooltip on the restore confirmation modal in the V2 UI SIP.version_transaction.user_idis an unkeyedINTEGERcolumn (no FK), deliberately, so that CLI / Celery / import / unauthenticated saves can write rows without an active Flask user. The trade-off is that when a user is later deleted fromab_user, theiruser_idreferences inversion_transactionbecome dangling —GET /versions/returnschanged_by: nullfor those rows rather than 500'ing. This is the right behavior for an audit history (the history shouldn't disappear with the user) but should be on operator radar: a workspace that frequently deletes-and-recreates user accounts will see "system" attributions on older versions.Alternatives Considered
Hybrid (Continuum for parent scalars, JSON snapshot tables for child collections) — Two purpose-built JSON-blob tables (
dataset_snapshots,dashboard_snapshots) keyed on(entity_id, transaction_id)could store child state ascolumns_json/metrics_json/slice_ids_jsoninstead of using Continuum's auto-generated child shadow tables. Restore would deserialize the JSON, expunge live children, and re-insert. Rejected because the storage cost of full-collection snapshots on every save (one row of N child rows per save) exceeds the row-per-changed-child cost of shadow tables for any non-trivial edit cadence, and because maintaining schema changes for a JSON blob is more challenging than maintaining table schemas.Custom JSON snapshot table for everything — A single
entity_versionstable with asnapshotJSONB column. Restore would be simpler (deserialize andsetattr), and Continuum's shadow tables would be avoided entirely. Rejected because Continuum captures scalar updates automatically across all three entity types without instrumenting every save path, and a fully-custom approach would need manual schema synchronisation as entity models evolve.SQLAlchemy-History — A fork of Continuum. Inactive, smaller community, no meaningful feature advantage.
Database-level temporal tables / system versioning — Native or extension-based row-history tracking via SQL semantics (
FOR SYSTEM_TIME AS OF …). Would shift capture into the engine and eliminate the Python library dependency. Rejected for two reasons. (a) Cross-database support is uneven: MariaDB 10.3+ has nativeWITH SYSTEM VERSIONING; PostgreSQL has no native system versioning (the third-partytemporal_tablesextension exists but isn't in core); MySQL has no native system versioning either (commonly confused with MariaDB); SQLite has none and would need triggers that sacrifice atomicity. Supporting all four would require four backend-specific paths plus a fallback. (b) Application-level concerns still cost code: temporal tables capture what changed but not who (user attribution would still need triggers or an application-level transaction table — re-inventing Continuum'sversion_transaction); historical queries are read-only, so restore is still application code; theversion_changesfield-level diff records need a diff engine regardless; and the cross-table identity problems (override_columns,dashboard_slices) that drove R-017/R-018 are independent of where snapshots live. With Continuum the Python implementation is identical across all four backends.Split-revert pattern (one relation per call, with
flush + expirebetween calls) — was the original chosen workaround for the Reverter / autoflush / cascade-add interaction described in Restore above. Initially shipped that way; rejected once the change-records listener was wired up. Per-relation flushes spread one logical restore across multiple Continuum transactions, the listener's tx-dedup guard (necessary to prevent UNIQUE-constraint conflicts onversion_changesfrom autoflush re-firings) skipped the second pass, and child-addition records were silently dropped fromversion_changes— the dropdown rendered the restore as an empty "Baseline" entry. Replaced with theno_autoflushwrap, which closes the autoflush window without splitting the restore. Seetest_restore_emits_full_child_diff_in_one_transaction.Synchronous (per-save) retention pruning — A second
after_commitlistener could prune at the moment a new version row is committed (e.g., delete the oldest row whenever a per-entity count threshold is exceeded). Rejected because the prune work falls onto the user's save path, adding latency proportional to retention churn; installations have no way to defer it to off-peak hours; and the model couples pruning cadence to save cadence (a rarely-edited entity never gets pruned). The Celery beat approach decouples retention from the request lifecycle.Hand-rolled diff engine vs. external library (
deepdiff,dictdiffer,jsonpatch) — Considered and rejected. The on-diskpathshape and kind classification (filtervs.metricvs.fieldetc.) are co-located with diff walking; external libraries return JSON-Pointer or string paths that would need translation. Child-collection identity uses natural keys (column_name,metric_name, sliceuuid), not list indices, which is also non-default. The hand-rolled diff is ~500 LOC of pure functions with comprehensive unit-test coverage.Default-off feature flag (e.g.
VERSION_HISTORY_ENABLED) — Considered and not added. The implementation is engineered to no-op cleanly when its tables don't exist (each listener narrow-catchesOperationalErroron the SQLite "no such table" startup race; broader DB errors propagate so operators can fix migrations rather than silently lose capture). A flag adds a configuration surface that can drift from the migration state — deployments could disable the flag while leaving the tables populated, then re-enable months later with stale data. The cleaner kill switch, if needed, is to skip the three migrations entirely. If post-launch evidence shows operators want a runtime off-switch, a flag can be added in a follow-up without breaking compatibility.Reuse the import/export YAML pipeline for storage (the approach proposed by SIP-192 #36145) — Store each version as a YAML bundle produced by
ExportDashboardsCommand/ExportChartsCommand/ExportDatasetsCommand; restore by feeding the bundle back through the correspondingImport*Command. Pragmatic upside: reuses well-tested serialization that already handles UUID-based identity, audit-field omission, and JSON normalization. Rejected because (a) the format is optimized for cross-system entity transfer, not same-system temporal storage — full snapshots per save (even when one field changed) inflate storage relative to Continuum's per-column delta, and field coverage is bound by each model'sexport_fieldsrather than the full column set; (b) diff records would have to be computed at read time by parsing the YAML, which both increases list-endpoint latency and pushes the diff engine onto the read path instead of the write path; (c) no native validity intervals —end_transaction_id-style linking would have to be reinvented to answer "what was live at time T"; (d) child collections (TableColumn / SqlMetric / dashboard_slices) are embedded inside parent YAML, so restore becomes a delete-and-reinsert on every related table rather than a targeted shadow-row revert, and atomic capture of child-only edits requires re-exporting the parent on each child save. The shared primitives (UUID identity, audit-field exclusion) are layered into this SIP's design directly; the storage shape is purpose-built for the temporal access pattern. (SIP-192's analysis of cross-entity restore effects — dataset restore may break dependent charts, dashboard restore doesn't snapshot-through to chart content — was excellent and is incorporated here; see Cross-entity effects of restore above.) The export-pipeline approach is, however, a good shape for an extension: an installation that wants Git-backed version history — every save committed to a repo as a YAML bundle, with restore done by replaying a bundle through the import command — could implement that on top of this SIP's listener hooks (or independently) without any change to V1. We see the two as complementary rather than competing: this SIP gives every install the inline safety net by default; an export-to-Git extension layers a durable, externally-auditable record on top for installs that want it.Manual / labeled versions only (also from SIP-192 #36145) — Versions created only when a user explicitly clicks "save a new version" and supplies a description. Conceptually simpler (no listener chain, no retention task, no no-op suppression) and arguably more aligned with how users think about "milestones." Rejected as the V1 default because most users won't remember to checkpoint before a destructive edit — the safety-net use case that motivates this SIP only works if capture is automatic. Auto-capture with optional user-supplied labels is a strictly larger feature (labels can be layered on top of automatic versions in a follow-up); manual-only forecloses the safety-net path. Labels-as-an-enhancement are flagged in Open Questions below.
Future Work
Items intentionally deferred out of V1 with the phase they belong to:
Deeper structural diff inside layout components — V2 (UI SIP).
diff_dashboard_layoutcurrently emits one record per logical action on a top-level component (chart added/removed/moved/edited, row added, etc.). Edits inside a chart'smeta(resized, restyled) surface as a singleeditrecord carrying the full meta dict. Per-property records would be more useful for a UI, but the right granularity is UX-driven; we wait for V2 to validate before committing to a shape.Multi-entity transaction display — V2 (UI SIP). A single save (e.g. a dashboard import) can produce versions across multiple entities sharing one
version_transaction.id. The backend records them correctly keyed on(transaction_id, entity_kind, entity_id)and the list endpoint filters per-entity. How V2's UI surfaces a transaction that touches multiple entities (a single combined "this save changed X, Y, and Z" entry vs. per-entity entries that cross-reference each other) is a UX decision deferred to that SIP.If-Matchenforcement on writes — V3 (locking SIP). V1 emits theETagheader carryingversion_uuidon the save and editor-fetch endpoints listed above, so the data backbone for optimistic locking is in place. V1 does NOT enforceIf-Matchon writes — saves succeed regardless of whether the client sent a precondition header. When and how to turn enforcement on (412 response semantics, banner / blocking modal / merge view) is UX-driven and belongs in V3.User-supplied labels on versions — V2 (UI SIP). Every V1 version is identified by
version_uuid,version_number, andissued_at. SIP-192 #36145 proposed a user-facing flow where the user types a description ("before Q4 numbers") when saving a meaningful checkpoint. Auto-capture and labels aren't mutually exclusive — alabel(nullable text) column onversion_transactionplus aPUT /api/v1/{resource}/<uuid>/versions/<version_uuid>/labelendpoint can be layered on top in V2 once the UI side has the entry point. Labelling without a UI to enter labels in is low-value, so V1 doesn't add the data model.Runtime versioning toggles — none in V1. Two distinct toggles were considered and deferred:
RETENTION = -1/NULLsentinel, or a separateVERSION_HISTORY_ENABLEDflag): an env-var off switch that doesn't require rolling back migrations. Deferred because (a) it adds a config-vs-migration-state drift surface — capture could be disabled while shadow tables stay populated, producing a half-filled history if it's re-enabled later; (b) the migration-skip path (see Migration Plan and Compatibility — Kill plan) already provides a drift-free off switch; (c)SUPERSET_VERSION_HISTORY_RETENTION_DAYS = 1keeps only one day of history — operationally close to "off" for installs that want near-zero on-disk capture. See Alternatives Considered Preventing bad json from creating problems #8 for the broader feature-flag discussion.Either can be added post-launch without breaking compatibility if operator evidence supports it.