Skip to content

[SIP-210] Entity Version History for Dashboards, Charts, and Datasets #39492

Description

@mikebridge

[SIP] Entity Version History for Dashboards, Charts, and Datasets

Status Open for Voting
Authors Mike Bridge (@mikebridge)
Created 2026-05-08
Discussion To be added once posted to dev@superset.apache.org
Tracking issue apache/superset#39492
Related sqlalchemy-continuum#129; #39859 (composite-PK PR this SIP depends on); #39464 (soft-delete SIP)

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, roles, tags
Chart scalar fields (slice_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 state
Dataset scalar fields (table_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 (TableColumn rows — column_name, expression, type, verbose_name, is_dttm, groupby, filterable, etc.); metrics (SqlMetric rows — metric_name, expression, metric_type, verbose_name, d3format, currency, etc.) 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.

  1. 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.

  2. 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.

  3. 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:

make_versioned(
    user_cls=None,
    transaction_cls=VersionTransactionFactory(),  # custom name
    plugins=[VersioningFlaskPlugin()],            # JWT-aware user attribution
    options={"strategy": "validity"},
)

Two custom subclasses fix integration friction:

  • 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:

class Dashboard(...):
    __versioned__ = {"exclude": ["slices", "owners", "roles"]}
class Slice(...):
    __versioned__ = {"exclude": ["query_context", "owners", "dashboards"]}
class SqlaTable(...):
    __versioned__ = {"exclude": ["owners", "row_level_security_filters"]}

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:

  1. 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.

  2. 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:

Shadow table Mirrors Notes
dashboards_version, slices_version, tables_version 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:

  1. 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.
  2. 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.
  3. 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)
kind VARCHAR(32) field, filter, metric, time_range, color_palette, dimension, column, chart
path JSON 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:

  1. Continuum's own before_flush (registered by make_versioned() at app init) — allocates version_transaction.id and writes shadow rows.
  2. 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.
  3. 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:

  1. 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.
  2. Deletes shadow rows in the parent tables (dashboards_version, slices_version, tables_version) for the surviving candidate transactions.
  3. Deletes shadow rows in the child tables (table_columns_version, sql_metrics_version, dashboard_slices_version) for the same transactions.
  4. 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).

Workspace shape Saves/day across all entities Daily growth 30-day retention
Small (100 entities, ~3 saves/day total) ~3 ~30 KB ~1 MB
Medium (1k entities, ~50 saves/day total) ~50 ~500 KB ~15 MB
Heavily edited (1k entities, ~500 saves/day) ~500 ~5 MB ~150 MB
Pathological (collaborative editing, ~5k saves/day) ~5k ~50 MB ~1.5 GB

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)

Method Path Description
GET /api/v1/{resource}/<uuid>/versions/ List the entity's version history
GET /api/v1/{resource}/<uuid>/versions/<version_uuid>/ Get a single version snapshot (scalar fields plus restored columns / metrics for datasets)
POST /api/v1/{resource}/<uuid>/versions/<version_uuid>/restore Restore the entity to that version

{resource}{chart, dashboard, dataset}.

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 new version_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)
dashboards_version, slices_version, tables_version Continuum parent shadow tables
table_columns_version, sql_metrics_version Continuum shadows for dataset children
dashboard_slices_version 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:

  1. add_entity_version_history_tables — creates version_transaction and the three parent shadow tables (dashboards_version, slices_version, tables_version).
  2. add_version_changes_table — creates version_changes.
  3. 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_tablesadd_version_changes_tableadd_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:

  1. 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.
  2. 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

  1. 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.

  2. 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.

  3. SQLAlchemy-History — A fork of Continuum. Inactive, smaller community, no meaningful feature advantage.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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 intervalsend_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.

  10. 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    design:proposalDesign proposalssipSuperset Improvement Proposal

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    [VOTE] Thread opened

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions