Compare commits
4 Commits
python-sec
...
boot-time-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a632a4c206 | ||
|
|
248a2bc0cd | ||
|
|
4ba59c3b8b | ||
|
|
7a9bc3286c |
@@ -465,6 +465,7 @@
|
||||
metabase.actions.test-util/with-actions clojure.core/let
|
||||
metabase.transforms.test-util/with-transform-cleanup! clojure.core/let
|
||||
metabase.api.common/let-404 clojure.core/let
|
||||
metabase.app-db.connection/with-unshared-connection clojure.core/fn
|
||||
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn
|
||||
metabase.app-db.custom-migrations/define-migration clj-kondo.lint-as/def-catch-all
|
||||
metabase.app-db.custom-migrations/define-reversible-migration clj-kondo.lint-as/def-catch-all
|
||||
|
||||
3
.github/scripts/setup-python-runner.sh
vendored
3
.github/scripts/setup-python-runner.sh
vendored
@@ -19,8 +19,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
echo "Cloning python-runner repository..."
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git --branch seccomp python-runner
|
||||
|
||||
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
|
||||
|
||||
if [ "$SKIP_START" = false ]; then
|
||||
echo "Starting python-runner..."
|
||||
|
||||
@@ -207,16 +207,42 @@
|
||||
(str/join ", " bad-ids))
|
||||
{:invalid-ids (vec bad-ids)}))))))
|
||||
|
||||
(defn require-primary-key-exists-has-table-name
|
||||
"Ensures that all primaryKeyExists preconditions specify a tableName."
|
||||
(def ^:private table-scoped-preconditions
|
||||
"Existence preconditions mapped to the key that scopes them to a single table. Without that key,
|
||||
Liquibase answers the check by snapshotting the whole schema — ~2 seconds per precondition on a
|
||||
large postgres appdb, which is how 27 name-only preconditions once added ~50s to every full
|
||||
migration."
|
||||
{:primaryKeyExists :tableName
|
||||
:indexExists :tableName
|
||||
:uniqueConstraintExists :tableName
|
||||
:foreignKeyConstraintExists :foreignKeyTableName})
|
||||
|
||||
(defn- unscoped-preconditions
|
||||
"Walk a preConditions tree (maps nested under and/or/not, in either flat-map or list form) and
|
||||
return [precondition-key required-key] pairs for every table-scoped existence check that is
|
||||
missing its table key."
|
||||
[form]
|
||||
(cond
|
||||
(map? form) (concat
|
||||
(for [[precondition required] table-scoped-preconditions
|
||||
:let [check (get form precondition)]
|
||||
:when (and (map? check) (not (contains? check required)))]
|
||||
[precondition required])
|
||||
(mapcat unscoped-preconditions (vals form)))
|
||||
(sequential? form) (mapcat unscoped-preconditions form)
|
||||
:else nil))
|
||||
|
||||
(defn require-table-scoped-existence-preconditions
|
||||
"Ensures that primaryKeyExists/indexExists/uniqueConstraintExists preconditions specify a
|
||||
tableName, and foreignKeyConstraintExists a foreignKeyTableName."
|
||||
[change-log]
|
||||
(doseq [{:keys [changeSet]} change-log
|
||||
:when changeSet
|
||||
:let [s (pr-str (:preConditions changeSet))]
|
||||
:when (and (str/includes? s ":primaryKeyExists")
|
||||
(not (str/includes? s ":tableName")))]
|
||||
:let [[precondition required] (first (unscoped-preconditions (:preConditions changeSet)))]
|
||||
:when precondition]
|
||||
(throw (validation-error
|
||||
(format "Migration '%s' has primaryKeyExists precondition without tableName" (:id changeSet))
|
||||
(format "Migration '%s' has %s precondition without %s: without it Liquibase snapshots the entire schema to answer the check (~2s per precondition on a large postgres appdb)"
|
||||
(:id changeSet) (name precondition) (name required))
|
||||
{:id (:id changeSet)}))))
|
||||
|
||||
(s/def ::changeSet
|
||||
@@ -292,7 +318,7 @@
|
||||
(require-no-bare-blob-or-text-types change-log)
|
||||
(require-no-bare-boolean-types change-log file)
|
||||
(require-no-datetime-type change-log file)
|
||||
(require-primary-key-exists-has-table-name change-log)
|
||||
(require-table-scoped-existence-preconditions change-log)
|
||||
(let [{:keys [changeSet]
|
||||
:as all} (group-by only-key change-log)]
|
||||
(when-not (set/subset? (set (keys all)) #{:property :objectQuotingStrategy :changeSet :logicalFilePath})
|
||||
|
||||
@@ -398,3 +398,40 @@
|
||||
:remarks "none"
|
||||
:type "timestamp with time zone"}}]}}]
|
||||
:rollback nil))))))
|
||||
|
||||
(deftest ^:parallel require-table-scoped-existence-preconditions-test
|
||||
(testing "foreignKeyConstraintExists without foreignKeyTableName is rejected"
|
||||
(is-thrown-with-error-info?
|
||||
"Migration 'v49.2024-01-01T10:30:00' has foreignKeyConstraintExists precondition without foreignKeyTableName: without it Liquibase snapshots the entire schema to answer the check (~2s per precondition on a large postgres appdb)"
|
||||
{:id "v49.2024-01-01T10:30:00"}
|
||||
(validate
|
||||
(mock-change-set
|
||||
:preConditions [{:onFail "MARK_RAN"}
|
||||
{:not [{:foreignKeyConstraintExists {:foreignKeyName "fk_my_table_other_id"}}]}]))))
|
||||
(testing "foreignKeyConstraintExists with foreignKeyTableName passes"
|
||||
(is (= :ok
|
||||
(validate
|
||||
(mock-change-set
|
||||
:preConditions [{:onFail "MARK_RAN"}
|
||||
{:not [{:foreignKeyConstraintExists {:foreignKeyName "fk_my_table_other_id"
|
||||
:foreignKeyTableName "my_table"}}]}])))))
|
||||
(testing "indexExists without tableName is rejected, including in flat-map not form"
|
||||
(is-thrown-with-error-info?
|
||||
"Migration 'v49.2024-01-01T10:30:00' has indexExists precondition without tableName: without it Liquibase snapshots the entire schema to answer the check (~2s per precondition on a large postgres appdb)"
|
||||
{:id "v49.2024-01-01T10:30:00"}
|
||||
(validate
|
||||
(mock-change-set
|
||||
:preConditions [{:not {:indexExists {:indexName "idx_my_table_other_id"}}}]))))
|
||||
(testing "indexExists with tableName passes"
|
||||
(is (= :ok
|
||||
(validate
|
||||
(mock-change-set
|
||||
:preConditions [{:not {:indexExists {:indexName "idx_my_table_other_id"
|
||||
:tableName "my_table"}}}])))))
|
||||
(testing "primaryKeyExists without tableName is still rejected (previous rule subsumed)"
|
||||
(is-thrown-with-error-info?
|
||||
"Migration 'v49.2024-01-01T10:30:00' has primaryKeyExists precondition without tableName: without it Liquibase snapshots the entire schema to answer the check (~2s per precondition on a large postgres appdb)"
|
||||
{:id "v49.2024-01-01T10:30:00"}
|
||||
(validate
|
||||
(mock-change-set
|
||||
:preConditions [{:primaryKeyExists {:primaryKeyName "pk_my_table"}}])))))
|
||||
|
||||
2
deps.edn
2
deps.edn
@@ -80,7 +80,7 @@
|
||||
hiccup/hiccup {:mvn/version "2.0.0"} ; HTML templating
|
||||
inflections/inflections {:mvn/version "0.15.0"} ; Clojure/Script library used for prularizing words
|
||||
instaparse/instaparse {:mvn/version "1.5.0"} ; Make your own parser
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.571"}
|
||||
io.github.camsaul/toucan2 {:mvn/version "1.0.572"}
|
||||
io.github.eerohele/pp {#_#_:git/tag "2024-11-13.77" ; super fast pretty-printing library
|
||||
:git/sha "ffc4edc482c057cd9412c62f018a41e9140c618b"
|
||||
:git/url "https://github.com/eerohele/pp"}
|
||||
|
||||
82
docs/api.json
generated
82
docs/api.json
generated
@@ -5033,13 +5033,7 @@
|
||||
}
|
||||
},
|
||||
"required" : [ "channel_type", "details" ]
|
||||
}, {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"channel_type" : { }
|
||||
},
|
||||
"required" : [ "channel_type" ]
|
||||
} ]
|
||||
}, { } ]
|
||||
},
|
||||
"metabase.channel.models.channel.ChannelTemplateEmailDetails" : {
|
||||
"oneOf" : [ {
|
||||
@@ -5108,13 +5102,7 @@
|
||||
}
|
||||
},
|
||||
"required" : [ "channel_type", "details" ]
|
||||
}, {
|
||||
"type" : "object",
|
||||
"properties" : {
|
||||
"channel_type" : { }
|
||||
},
|
||||
"required" : [ "channel_type" ]
|
||||
} ]
|
||||
}, { } ]
|
||||
},
|
||||
"metabase.collections-rest.api.DashboardQuestionCandidate" : {
|
||||
"type" : "object",
|
||||
@@ -37226,7 +37214,7 @@
|
||||
"post" : {
|
||||
"operationId" : "post-api-measure",
|
||||
"summary" : "POST /api/measure",
|
||||
"description" : "Create a new `Measure`. The Measure's table is derived from its `definition`.",
|
||||
"description" : "Create a new `Measure`.",
|
||||
"parameters" : [ ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
@@ -37267,9 +37255,14 @@
|
||||
"name" : {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
},
|
||||
"table_id" : {
|
||||
"description" : "value must be an integer greater than zero.",
|
||||
"type" : "integer",
|
||||
"minimum" : 1
|
||||
}
|
||||
},
|
||||
"required" : [ "name", "definition" ]
|
||||
"required" : [ "name", "table_id", "definition" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44363,7 +44356,7 @@
|
||||
"get" : {
|
||||
"operationId" : "get-api-search",
|
||||
"summary" : "GET /api/search",
|
||||
"description" : "Search for items in Metabase.\n For the list of supported models, check [[metabase.search.config/all-models]].\n\n `context` identifies the surface issuing the search; it selects the ranking weights and filter defaults.\n It defaults to `api`, the value for programmatic callers.\n\n Filters:\n - `archived`: set to true to search archived items only, default is false\n - `table_db_id`: search for tables, cards, and models of a certain DB\n - `models`: only search for items of specific models. If not provided, search for all models\n - `filter_items_in_personal_collection`: only search for items in personal collections\n - `created_at`: search for items created at a specific timestamp\n - `created_by`: search for items created by a specific user\n - `last_edited_at`: search for items last edited at a specific timestamp\n - `last_edited_by`: search for items last edited by a specific user\n - `search_native_query`: set to true to search the content of native queries\n - `vector_search_strategy`: for the semantic engine: `hnsw` (approximate index search, default),\n `brute-force` (exact filter-first search), or `hnsw-iterative-relaxed` / `hnsw-iterative-strict`\n (index-backed iterative scans with inline filters); ignored by other engines\n - `vector_search_ef_search`: override pgvector's `hnsw.ef_search` (1-1000) for the iterative strategies; (admin only)\n - `vector_search_max_scan_tuples`: override pgvector's `hnsw.max_scan_tuples` for the iterative strategies; (admin only)\n - `vector_search_explain`: set to true to record vector-scan instrumentation for this search (expensive); (admin only)\n - `verified`: set to true to search for verified items only (requires Content Management or Official Collections premium feature)\n - `ids`: search for items with those ids, works iff single value passed to `models`\n - `display_type`: search for cards/models with specific display types\n\n Note that not all item types support all filters, and the results will include only models that support the provided\n filters. For example:\n\n - The `created-by` filter supports dashboards, models, actions, and cards.\n - The `verified` filter supports models and cards.\n\n A search query that has both filters applied will only return models and cards.",
|
||||
"description" : "Search for items in Metabase.\n For the list of supported models, check [[metabase.search.config/all-models]].\n\n `context` identifies the surface issuing the search; it selects the ranking weights and filter defaults.\n It defaults to `api`, the value for programmatic callers.\n\n Filters:\n - `archived`: set to true to search archived items only, default is false\n - `table_db_id`: search for tables, cards, and models of a certain DB\n - `models`: only search for items of specific models. If not provided, search for all models\n - `filter_items_in_personal_collection`: only search for items in personal collections\n - `created_at`: search for items created at a specific timestamp\n - `created_by`: search for items created by a specific user\n - `last_edited_at`: search for items last edited at a specific timestamp\n - `last_edited_by`: search for items last edited by a specific user\n - `search_native_query`: set to true to search the content of native queries\n - `vector_search_strategy`: for the semantic engine: `hnsw` (approximate index search, default),\n `brute-force` (exact filter-first search), or `hnsw-iterative-relaxed` / `hnsw-iterative-strict`\n (index-backed iterative scans with inline filters); ignored by other engines\n - `vector_search_ef_search`: override pgvector's `hnsw.ef_search` (1-1000) for the iterative strategies; (admin only)\n - `vector_search_max_scan_tuples`: override pgvector's `hnsw.max_scan_tuples` for the iterative strategies; (admin only)\n - `vector_search_explain`: set to true to record vector-scan instrumentation for this search (expensive); (admin only)\n - `verified`: set to true to search for verified items only (requires Content Management or Official Collections premium feature)\n - `ids`: search for items with those ids, works iff single value passed to `models`\n - `display_type`: search for cards/models with specific display types\n - `non_temporal_dim_ids`: search for cards/metrics/datasets with this exact set of non temporal dimension field IDs (requires appdb engine)\n - `has_temporal_dim`: set to true for cards/metrics/datasets with 1 or more temporal dimensions (requires appdb engine)\n\n Note that not all item types support all filters, and the results will include only models that support the provided\n filters. For example:\n\n - The `created-by` filter supports dashboards, models, actions, and cards.\n - The `verified` filter supports models and cards.\n\n A search query that has both filters applied will only return models and cards.",
|
||||
"parameters" : [ {
|
||||
"in" : "query",
|
||||
"name" : "q",
|
||||
@@ -44665,6 +44658,29 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "non_temporal_dim_ids",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "has_temporal_dim",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "boolean"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
} ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
@@ -44986,6 +45002,29 @@
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "non_temporal_dim_ids",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "has_temporal_dim",
|
||||
"required" : false,
|
||||
"schema" : {
|
||||
"oneOf" : [ {
|
||||
"type" : "boolean"
|
||||
}, {
|
||||
"type" : "null"
|
||||
} ]
|
||||
}
|
||||
}, {
|
||||
"in" : "query",
|
||||
"name" : "expected_result_type",
|
||||
@@ -45134,7 +45173,7 @@
|
||||
"post" : {
|
||||
"operationId" : "post-api-segment",
|
||||
"summary" : "POST /api/segment",
|
||||
"description" : "Create a new `Segment`. The Segment's table is derived from its `definition`.",
|
||||
"description" : "Create a new `Segment`.",
|
||||
"parameters" : [ ],
|
||||
"responses" : {
|
||||
"2XX" : {
|
||||
@@ -45168,9 +45207,14 @@
|
||||
"name" : {
|
||||
"type" : "string",
|
||||
"minLength" : 1
|
||||
},
|
||||
"table_id" : {
|
||||
"description" : "value must be an integer greater than zero.",
|
||||
"type" : "integer",
|
||||
"minimum" : 1
|
||||
}
|
||||
},
|
||||
"required" : [ "name", "definition" ]
|
||||
"required" : [ "name", "table_id", "definition" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +197,6 @@ See [Modular embedding parameters](./parameters.md#pass-parameter-values-to-embe
|
||||
|
||||
Locked parameters let you filter data without exposing the filter to the end-user. Locking parameters is useful for restricting data based on who's viewing the embed (for example, showing each customer only their own data).
|
||||
|
||||

|
||||
|
||||
To use locked parameters, you need to:
|
||||
|
||||
1. Set the parameter to **Locked** in the embed settings.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB |
@@ -308,8 +308,6 @@ For an overview of how data permissions work in Metabase, see [Data permissions]
|
||||
|
||||
If every tenant has their data in a separate database, then instead of using permissions for data access control, you can use [**database routing**](../permissions/database-routing.md) to route queries to appropriate databases directly.
|
||||
|
||||
For a comparison of data isolation methods, see [Choosing a data isolation method](../permissions/data-isolation-methods.md).
|
||||
|
||||
- **Create queries** controls whether tenant users can create queries on the data they see. If you want to give your tenant users the ability to drill through (e.g., through `drills` parameter in [modular embedding](../embedding/modular-embedding.md)), you need to give them "Create queries" permissions, because a drill through is a new query.
|
||||
|
||||
- **Download results** controls, unsurprisingly, whether people can download results of queries. You need to set download permissions if you want to give your users the option to download their data as a spreadsheet (for example, through `with-downloads` parameter in [modular embedding](../embedding/modular-embedding.md)).
|
||||
@@ -439,4 +437,3 @@ If you disable multi-tenant strategy, _all your tenant users will be deactivated
|
||||
- [Embedding overview](./start.md)
|
||||
- [JWT authentication](../people-and-groups/authenticating-with-jwt.md)
|
||||
- [Permissions overview](../permissions/start.md)
|
||||
- [Data isolation methods](../permissions/data-isolation-methods.md)
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
title: Data isolation methods
|
||||
summary: Compare the ways Metabase can restrict data access for internal analytics or multi-tenant setups, including row and column security, impersonation, database routing, and locked parameters.
|
||||
---
|
||||
|
||||
# Data isolation methods
|
||||
|
||||
> Row and column security, impersonation, and database routing are only available on Metabase [Pro](https://www.metabase.com/product/pro) and [Enterprise](https://www.metabase.com/product/enterprise) plans.
|
||||
|
||||
Metabase offers several ways to ensure people only see the data they're supposed to see, whether you're restricting data by team for internal analytics or isolating customer data in a multi-tenant setup. The best method depends on how you store your data and where you want to enforce access rules:
|
||||
|
||||
- If all data lives in the same tables, use [row and column security](#row-and-column-security) to filter data in Metabase, or [impersonation](#impersonation) to enforce access rules at the database level.
|
||||
- If each customer or team has their own database, use [database routing](#database-routing).
|
||||
- If you're embedding dashboards for people without Metabase accounts, use [locked parameters](#locked-parameters).
|
||||
|
||||
You can combine these methods. For example, you can use locked parameters for a guest embed while using row and column security for signed-in users.
|
||||
|
||||
## Row and column security
|
||||
|
||||
[Row and column security](./row-and-column-security.md) lets you control which rows and columns each group can see. You can define rules that filter data based on [user attributes](../people-and-groups/managing.md#adding-a-user-attribute). Metabase applies your rules to questions you build in the query builder.
|
||||
|
||||

|
||||
|
||||
Use row and column security when:
|
||||
|
||||
- You need granular control over which rows and columns each group or customer can see.
|
||||
- You want to manage access rules in the Metabase UI instead of in your database.
|
||||
|
||||
Don't use row and column security when:
|
||||
|
||||
- People need to write native SQL queries. Row and column security doesn't apply to the SQL editor.
|
||||
- You need column-level security on MongoDB or Druid, which only support row-level security.
|
||||
|
||||
## Impersonation
|
||||
|
||||
[Impersonation](./impersonation.md) lets your database control what each person can see. You define roles and row-level security policies in your database. Metabase connects using the role specified by each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute), and your database enforces what that role can see.
|
||||
|
||||

|
||||
|
||||
Use impersonation when:
|
||||
|
||||
- You want to centralize access logic in your database instead of in Metabase.
|
||||
- People need to write native SQL queries. Since your database enforces the rules, the rules apply in the SQL editor too.
|
||||
|
||||
Don't use impersonation when:
|
||||
|
||||
- Your database [doesn't support it](./impersonation.md#databases-that-support-impersonation).
|
||||
- You don't want to set up and maintain roles and policies in your database.
|
||||
- You rely heavily on caching. You can't share cached results between roles.
|
||||
|
||||
## Database routing
|
||||
|
||||
[Database routing](./database-routing.md) sends each person's queries to their own database. You build questions and dashboards once against a primary database, and Metabase routes queries to the right destination database based on each person's [user attribute](../people-and-groups/managing.md#adding-a-user-attribute). Database routing requires that each group's data lives in a separate database, and that all destination databases have identical schemas.
|
||||
|
||||

|
||||
|
||||
Use database routing when:
|
||||
|
||||
- You want the strongest isolation. Each group's data lives in a separate database.
|
||||
- You want to avoid one group's queries affecting the performance of another group's queries.
|
||||
|
||||
Don't use database routing when:
|
||||
|
||||
- Groups share tables. Use [row and column security](#row-and-column-security) or [impersonation](#impersonation) instead.
|
||||
- You want to manage access rules in the Metabase UI instead of maintaining a separate database for each group.
|
||||
|
||||
## Locked parameters
|
||||
|
||||
[Locked parameters](../embedding/guest-embedding.md#locked-parameters) filter an embedded dashboard or question before Metabase displays it. Your app passes the filter value in a signed JWT. The filter is invisible to the person viewing the embed, and they can't change it.
|
||||
|
||||

|
||||
|
||||
Use locked parameters when:
|
||||
|
||||
- You want to restrict data without setting up Metabase accounts, user attributes, or permissions.
|
||||
- You want to filter data without exposing the filter or its values to the people viewing the embed.
|
||||
|
||||
Don't use locked parameters when:
|
||||
|
||||
- You want account-level or role-based access control across your Metabase.
|
||||
- You're working with sensitive data. Use a method that enforces access at the permissions or database level instead, like [row and column security](#row-and-column-security), [impersonation](#impersonation), or [database routing](#database-routing).
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Permissions overview](./start.md)
|
||||
- [Tenants](../embedding/tenants.md)
|
||||
- [Row and column security](./row-and-column-security.md)
|
||||
- [Impersonation](./impersonation.md)
|
||||
- [Database routing](./database-routing.md)
|
||||
- [Locked parameters for guest embeds](../embedding/guest-embedding.md#locked-parameters)
|
||||
@@ -9,8 +9,6 @@ summary: Route queries to different databases based on who's viewing them. Great
|
||||
|
||||
With database routing, an admin can build a question once using one database, and the question will run its query against a different database with the same schema depending on who is viewing the question.
|
||||
|
||||

|
||||
|
||||
Database routing is useful for:
|
||||
|
||||
- Managing modular and full app embedding setups where each customer has their own database with identical schemas.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB |
@@ -10,8 +10,6 @@ This page covers the [View data](./data.md#view-data-permissions) permission lev
|
||||
|
||||
**Impersonation access** allows admins to "outsource" View data permissions to roles in your database. Admins can associate user attributes with database-defined roles and their privileges. If someone is in a group with their View data permission set to Impersonation, the person will be able to view and query data based on the privileges granted to the role specified by their user attribute.
|
||||
|
||||

|
||||
|
||||
## Databases that support impersonation
|
||||
|
||||
For now, impersonation access is only available for the following databases:
|
||||
|
||||
@@ -14,8 +14,6 @@ Row and column security lets you give granular permissions for different groups
|
||||
|
||||
You can use row and column security to set up [self-service analytics](https://www.metabase.com/learn/metabase-basics/embedding/multi-tenant-self-service-analytics), so that each of your customers can only view the rows that match their customer ID. For example, if you have an Accounts table with information about your customers, you can add permissions to the table so that each customer only sees the data relevant to them.
|
||||
|
||||

|
||||
|
||||
> Row and column security was formerly called data sandboxing. It's the same feature, it just now has a more descriptive name.
|
||||
|
||||
## Row and column security examples
|
||||
|
||||
@@ -28,10 +28,6 @@ Granting group access to different Metabase features.
|
||||
|
||||
Setting up row and column security to restrict access to rows and columns in tables.
|
||||
|
||||
## [Choosing a data isolation method](./data-isolation-methods.md)
|
||||
|
||||
Compare row and column security, impersonation, database routing, and locked parameters for multi-tenant setups.
|
||||
|
||||
## [Snippet folder permissions](./snippets.md)
|
||||
|
||||
Organize snippets into folders that require permissions to view.
|
||||
|
||||
@@ -338,9 +338,7 @@
|
||||
- the `instance_analytics_views` SQL files have changed since the last successful sync,
|
||||
meaning a migration may have added a new view that isn't yet in `metabase_table`.
|
||||
The two triggers share one sync because they both want the same operation. Runs synchronously
|
||||
(not in a background future) so it stays inside the caller's cross-node cluster lock and
|
||||
transaction — a sync on another thread would escape the lock and, on a transactional appdb,
|
||||
deadlock against the caller's uncommitted `metabase_table` writes."
|
||||
(not in a background future) so it stays serialized under the caller's cross-node cluster lock."
|
||||
[audit-db engine-changed?]
|
||||
(let [current (views-checksum)
|
||||
views-stale? (and current (not= current (audit-app.settings/last-analytics-views-checksum)))]
|
||||
@@ -478,11 +476,19 @@
|
||||
;; serialize install+adjust+load+sync+reconcile across nodes so a rolling upgrade can't run an adjust or sync
|
||||
;; against a half-adjusted schema (`with-duplicate-ops-prevented` is per-process only). The install runs inside the
|
||||
;; lock too, so a node that acquires it after the installer committed sees the DB already exists and falls through
|
||||
;; to a no-op rather than colliding on the audit DB primary key. The sync runs synchronously inside the lock so it
|
||||
;; stays in the lock's transaction. If another node already holds the lock it is doing this same work, so we skip
|
||||
;; rather than fail the boot.
|
||||
;; to a no-op rather than colliding on the audit DB primary key. The sync runs synchronously so it stays serialized
|
||||
;; under the lock. If another node already holds the lock it is doing this same work, so we skip rather than fail
|
||||
;; the boot.
|
||||
;;
|
||||
;; The lock is :detached? so the pipeline does NOT run as one long transaction on the lock's connection: the serdes
|
||||
;; load's per-entity transactions commit incrementally (restoring #74412's per-entity isolation) instead of piling
|
||||
;; up thousands of savepoints/subtransaction XIDs and holding every row lock until one big commit at the end —
|
||||
;; which stalled first boot for many minutes and blocked concurrent work (#78238). Safe because this whole pipeline
|
||||
;; is idempotent and self-healing: the checksum only advances on completion and reconcile runs every boot, so a
|
||||
;; crash mid-pipeline is repaired by the next boot.
|
||||
(try
|
||||
(cluster-lock/with-cluster-lock {:lock audit-db-cluster-lock :timeout-seconds 5 :retry-config {:max-retries 2}}
|
||||
(cluster-lock/with-cluster-lock {:lock audit-db-cluster-lock :timeout-seconds 5 :retry-config {:max-retries 2}
|
||||
:detached? true}
|
||||
(u/prog1 (maybe-install-audit-db!)
|
||||
(when-let [audit-db (t2/select-one :model/Database :is_audit true)]
|
||||
((sync-util/with-duplicate-ops-prevented
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
[metabase.sso.core :as sso]
|
||||
[metabase.util.encryption :as encryption]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[metabase.util.password :as u.password]
|
||||
[throttle.core :as throttle]
|
||||
@@ -68,15 +67,9 @@
|
||||
(t2/select-one-fn :credentials :model/AuthIdentity :user_id user-id :provider "password")]
|
||||
(and password_hash (u.password/verify-password password password_salt password_hash)))
|
||||
(when (sso/ldap-enabled)
|
||||
;; an unreachable directory fails closed: re-auth is denied (the user retries when the
|
||||
;; directory is back; admin/remove needs no re-auth), never an unhandled 500
|
||||
(try
|
||||
(when-let [user-email (t2/select-one-fn :email :model/User user-id)]
|
||||
(when-let [user-info (sso/find-user user-email)]
|
||||
(sso/verify-password user-info password)))
|
||||
(catch Exception e
|
||||
(log/warn e "LDAP re-auth failed because the directory is unreachable")
|
||||
false)))))))
|
||||
(when-let [user-email (t2/select-one-fn :email :model/User user-id)]
|
||||
(when-let [user-info (sso/find-user user-email)]
|
||||
(sso/verify-password user-info password))))))))
|
||||
|
||||
;; Notification emails here are fire-and-log by construction: the messages/send-mfa-*-email!
|
||||
;; senders route through email/send-message!, which catches and logs delivery failures — so an
|
||||
@@ -171,23 +164,14 @@
|
||||
;;; -------------------------------------------------- Admin --------------------------------------------------
|
||||
|
||||
(api.macros/defendpoint :post "/admin/remove" :- nil
|
||||
"Admin: remove *another* user's two-factor enrollment entirely — the lockout escape hatch for a
|
||||
lost authenticator with no recovery codes. Never feature-gated (a lapsed license must not make
|
||||
lockouts permanent). The affected user is notified by email. They re-enroll from scratch — there
|
||||
is nothing to \"reset\", the secret lives on their device.
|
||||
|
||||
Removing your *own* enrollment is deliberately refused here: this is a user-management endpoint
|
||||
that takes no second factor (the target can't produce one — that's why an admin is removing it),
|
||||
and the admin UI never collects a code. Self-removal goes through the normal Security page, which
|
||||
re-auths with a fresh factor via `/disable`. Without this guard a hijacked admin session could
|
||||
strip its own 2FA with only a cookie, turning transient access into a permanent password bypass."
|
||||
"Admin: remove a user's two-factor enrollment entirely — the lockout escape hatch for a lost
|
||||
authenticator with no recovery codes. Never feature-gated (a lapsed license must not make
|
||||
lockouts permanent). The affected user is notified by email. They re-enroll from scratch —
|
||||
there is nothing to \"reset\", the secret lives on their device."
|
||||
[_route-params
|
||||
_query-params
|
||||
{user-id :user_id} :- [:map [:user_id ms/PositiveInt]]]
|
||||
(api/check-superuser)
|
||||
(when (= user-id api/*current-user-id*)
|
||||
(throw (ex-info (tru "You cannot administratively remove your own two-factor authentication. Please use the normal removal method in your account settings.")
|
||||
{:status-code 400})))
|
||||
(when (enrollment/disable! user-id)
|
||||
(let [user (t2/select-one :model/User :id user-id)]
|
||||
(messages/send-mfa-removed-by-admin-email! (:email user))
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
[metabase-enterprise.mfa.enrollment :as enrollment]
|
||||
[metabase-enterprise.mfa.totp :as totp]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
[metabase.test.fixtures :as fixtures]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db :web-server :test-users))
|
||||
|
||||
@@ -23,23 +22,6 @@
|
||||
(mt/user-http-request :crowberto :post 204 "ee/mfa/admin/remove" {:user_id user-id})
|
||||
(is (nil? (enrollment/enrolled-method user-id)))))))
|
||||
|
||||
(deftest admin-cannot-self-remove-test
|
||||
;; admin/remove is "manage OTHER users" — self-removal is refused so a hijacked admin cookie
|
||||
;; can't strip its own 2FA (this endpoint takes no factor; a stolen session has none to give).
|
||||
;; Removing your own enrollment goes through /disable, which re-auths with a fresh code.
|
||||
(mt/with-temp [:model/AuthIdentity _ {:user_id (mt/user->id :crowberto)
|
||||
:provider "totp"
|
||||
:confirmed_at (t/instant)
|
||||
:credentials {:secret (totp/generate-secret)}}]
|
||||
(try
|
||||
(testing "removing your own id is refused, enrollment intact"
|
||||
(is (re-find #"cannot administratively remove your own"
|
||||
(mt/user-http-request :crowberto :post 400 "ee/mfa/admin/remove"
|
||||
{:user_id (mt/user->id :crowberto)})))
|
||||
(is (= :totp (enrollment/enrolled-method (mt/user->id :crowberto)))))
|
||||
(finally
|
||||
(t2/delete! :model/AuthIdentity :user_id (mt/user->id :crowberto) :provider "totp")))))
|
||||
|
||||
(deftest admin-remove-is-audited-test
|
||||
;; audit rows require the :audit-app feature at event time (premium-features/log-enabled?)
|
||||
(mt/with-premium-features #{:audit-app}
|
||||
|
||||
@@ -68,81 +68,6 @@
|
||||
(is (true? (:mfa/pending? gated)))
|
||||
(is (nil? (:mfa/first-factor gated)))))))))))
|
||||
|
||||
(deftest gate-sso-provider-matrix-test
|
||||
(testing "every SSO provider is exempt for an enrolled user — the IdP owns MFA there"
|
||||
(mt/with-premium-features #{:multi-factor-auth}
|
||||
(mt/with-temporary-setting-values [mfa-enforcement :optional]
|
||||
(with-enrolled-user!
|
||||
(fn [user-id]
|
||||
(let [result {:success? true :user {:id user-id}}]
|
||||
(doseq [provider [:provider/google
|
||||
:provider/saml
|
||||
:provider/jwt
|
||||
:provider/oidc
|
||||
:provider/custom-oidc
|
||||
:provider/slack-connect]]
|
||||
(testing provider
|
||||
(is (= result (auth-identity.provider/apply-mfa-gate provider result))))))))))))
|
||||
|
||||
(deftest gate-provider-registry-exhaustiveness-test
|
||||
;; The gate's fall-through branch is exempt, so a provider nobody classified silently bypasses
|
||||
;; MFA. This test forces the decision: every provider in the auth-identity hierarchy must appear
|
||||
;; below. If you added a provider and landed here, decide its MFA posture — challenged providers
|
||||
;; also go in `metabase-enterprise.mfa.gate/challenged-providers`, session-suppressed ones in
|
||||
;; `session-suppressed-providers`; exemption is the default and needs only this map.
|
||||
(let [classification {:provider/password :challenged
|
||||
:provider/ldap :challenged
|
||||
:provider/emailed-secret-password-reset :session-suppressed
|
||||
;; IdP owns MFA for SSO logins
|
||||
:provider/google :exempt
|
||||
:provider/saml :exempt
|
||||
:provider/jwt :exempt
|
||||
:provider/oidc :exempt
|
||||
:provider/custom-oidc :exempt
|
||||
:provider/slack-connect :exempt
|
||||
;; admin-granted, time-boxed, audited; staff can't hold the second factor
|
||||
:provider/support-access-grant :exempt
|
||||
;; abstract parent (reset/verification/magic links) — a concrete login
|
||||
;; child must be classified here on its own when it ships
|
||||
:provider/emailed-secret :exempt
|
||||
;; the second factor itself, never a first factor
|
||||
:provider/totp :exempt}]
|
||||
(testing "every registered provider has a deliberate MFA classification"
|
||||
(is (= (set (keys classification))
|
||||
(set (descendants :metabase.auth-identity.provider/provider)))))
|
||||
(testing "the gate's behavior matches each classification for an enrolled user"
|
||||
(mt/with-premium-features #{:multi-factor-auth}
|
||||
(mt/with-temporary-setting-values [mfa-enforcement :optional]
|
||||
(with-enrolled-user!
|
||||
(fn [user-id]
|
||||
(let [result {:success? true :user {:id user-id}}]
|
||||
(doseq [[provider posture] classification]
|
||||
(testing provider
|
||||
(let [gated (auth-identity.provider/apply-mfa-gate provider result)]
|
||||
(case posture
|
||||
:challenged (is (= :mfa-required (:success? gated)))
|
||||
:session-suppressed (do (is (true? (:success? gated)))
|
||||
(is (true? (:mfa/pending? gated))))
|
||||
:exempt (is (= result gated))))))))))))))
|
||||
|
||||
(deftest gate-env-var-escape-hatch-test
|
||||
;; The last-admin lockout recovery: the only superuser loses authenticator + recovery codes, so
|
||||
;; nobody can log in to flip the setting. Restarting with MB_MFA_ENFORCEMENT=off must win over
|
||||
;; the :optional stored in the DB and let them in single-step.
|
||||
(mt/with-premium-features #{:multi-factor-auth}
|
||||
(mt/with-temporary-setting-values [mfa-enforcement :optional]
|
||||
(with-enrolled-user!
|
||||
(fn [user-id]
|
||||
(let [result {:success? true :user {:id user-id}}]
|
||||
(is (= :mfa-required
|
||||
(:success? (auth-identity.provider/apply-mfa-gate :provider/password result)))
|
||||
"sanity: challenged before the override")
|
||||
(mt/with-temp-env-var-value! [mb-mfa-enforcement "off"]
|
||||
(is (false? (mfa.settings/mfa-enabled?))
|
||||
"the env var beats the DB value")
|
||||
(is (= result (auth-identity.provider/apply-mfa-gate :provider/password result))
|
||||
"the gate no-ops, so the locked-out admin can log in"))))))))
|
||||
|
||||
(deftest mfa-enforcement-write-path-test
|
||||
(mt/with-temporary-setting-values [mfa-enforcement :off]
|
||||
(testing "enabling (:optional) requires the :multi-factor-auth feature (setup is gated; enforcement is not)"
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.mfa.enrollment :as enrollment]
|
||||
[metabase-enterprise.mfa.management :as mfa.management]
|
||||
[metabase-enterprise.mfa.totp :as totp]
|
||||
[metabase.session.api :as api.session]
|
||||
[metabase.sso.core :as sso]
|
||||
[metabase.sso.ldap-test-util :as ldap.test]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
@@ -75,35 +73,6 @@
|
||||
(testing "the enroll endpoint's schema also refuses a blank password"
|
||||
(mt/client session-key :post 400 "ee/mfa/enroll" {:password ""})))))))
|
||||
|
||||
(deftest ldap-directory-unreachable-fails-closed-test
|
||||
;; Lockout-corner: the directory goes down while a user is enrolled. Re-auth (disable) must fail
|
||||
;; closed as a denied verification — not a 500 — and must not touch the enrollment. The recovery
|
||||
;; path that needs no directory, admin/remove, keeps working.
|
||||
(mt/with-premium-features #{:multi-factor-auth}
|
||||
(mt/with-temporary-setting-values [mfa-enforcement :optional]
|
||||
(ldap.test/with-ldap-server!
|
||||
(let [{session-key :id} (mt/client :post 200 "session" {:username sally-email
|
||||
:password sally-directory-password})
|
||||
user-id (t2/select-one-fn :id :model/User :email sally-email)
|
||||
secret (totp/generate-secret)]
|
||||
(try
|
||||
(t2/insert! :model/AuthIdentity {:user_id user-id
|
||||
:provider "totp"
|
||||
:confirmed_at (t/instant)
|
||||
:credentials {:secret secret}})
|
||||
(with-redefs [sso/find-user (fn [& _] (throw (ex-info "connection refused" {})))]
|
||||
(testing "enroll re-auth with the directory down is a 400, not a 500"
|
||||
(is (=? {:errors {:password some?}}
|
||||
(mt/client session-key :post 400 "ee/mfa/enroll"
|
||||
{:password sally-directory-password}))))
|
||||
(testing "the enrollment is untouched"
|
||||
(is (= :totp (enrollment/enrolled-method user-id))))
|
||||
(testing "admin/remove needs no directory and still recovers the account"
|
||||
(mt/user-http-request :crowberto :post 204 "ee/mfa/admin/remove" {:user_id user-id})
|
||||
(is (nil? (enrollment/enrolled-method user-id)))))
|
||||
(finally
|
||||
(t2/delete! :model/AuthIdentity :user_id user-id :provider "totp"))))))))
|
||||
|
||||
(deftest ldap-only-user-enrolls-with-directory-password-test
|
||||
(mt/with-premium-features #{:multi-factor-auth}
|
||||
(mt/with-temporary-setting-values [mfa-enforcement :optional]
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.mfa.enrollment :as enrollment]
|
||||
[metabase-enterprise.mfa.recovery-codes :as recovery-codes]
|
||||
[metabase-enterprise.mfa.totp :as totp]
|
||||
[metabase-enterprise.mfa.verification :as verification]
|
||||
@@ -195,25 +194,3 @@
|
||||
(t2/select-one-fn :credentials :model/AuthIdentity
|
||||
:user_id user-id :provider "totp"))
|
||||
"sanity: the old key can no longer read the row"))))))
|
||||
|
||||
(deftest lost-encryption-key-fails-closed-test
|
||||
(testing "an instance started with the wrong key: verify errors (no bypass, no session), and
|
||||
admin removal — which never decrypts — still recovers the account"
|
||||
(let [secret (totp/generate-secret)
|
||||
k1 "0123456789abcdef-key-A"
|
||||
k2 "totally-different-key-B!"]
|
||||
(mt/with-temp [:model/User {user-id :id} {}]
|
||||
(encryption-test/with-secret-key k1
|
||||
(t2/insert! :model/AuthIdentity {:user_id user-id
|
||||
:provider "totp"
|
||||
:confirmed_at (t/instant)
|
||||
:credentials {:secret secret}}))
|
||||
(encryption-test/with-secret-key k2
|
||||
(testing "verification fails closed — an error, never a false-positive login"
|
||||
(is (thrown? Exception
|
||||
(verification/verify-attempt! user-id (totp/generate-code secret) (fresh-jti)))))
|
||||
(testing "the enrollment row survives the failed attempt"
|
||||
(is (= 1 (t2/count :auth_identity :user_id user-id :provider "totp"))))
|
||||
(testing "disable! deletes without decrypting, so the admin escape hatch works"
|
||||
(is (true? (enrollment/disable! user-id)))
|
||||
(is (zero? (t2/count :auth_identity :user_id user-id :provider "totp")))))))))
|
||||
|
||||
@@ -41,7 +41,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ItemLink:hover,
|
||||
.CollectionLink :hover {
|
||||
color: var(--mb-color-core-brand);
|
||||
.ItemLink {
|
||||
font-size: inherit !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-core-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.ItemLink,
|
||||
.CollectionLink {
|
||||
:hover {
|
||||
color: var(--mb-color-core-brand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo } from "react";
|
||||
import { getShortStrategyLabel } from "metabase/admin/performance/utils";
|
||||
import { EllipsifiedCollectionPath } from "metabase/common/components/EllipsifiedPath/EllipsifiedCollectionPath";
|
||||
import { Link } from "metabase/common/components/Link";
|
||||
import { MaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
|
||||
import { useGetIcon } from "metabase/hooks/use-icon";
|
||||
import { Box, Button, Ellipsified, FixedSizeIcon, Flex } from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
@@ -47,20 +48,6 @@ export const TableRowForCacheableItem = ({
|
||||
}
|
||||
};
|
||||
|
||||
const cellContent = (
|
||||
<Flex
|
||||
className={StrategyEditorForQuestionsAndDashboardsS.ItemLink}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
fw={700}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{iconName ? <FixedSizeIcon name={iconName} /> : <Box h="sm" w="md" />}
|
||||
<Ellipsified>{name}</Ellipsified>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={
|
||||
@@ -69,7 +56,26 @@ export const TableRowForCacheableItem = ({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td>{url ? <Link to={url}>{cellContent}</Link> : cellContent}</td>
|
||||
<td>
|
||||
<MaybeLink
|
||||
className={StrategyEditorForQuestionsAndDashboardsS.ItemLink}
|
||||
to={url}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{iconName ? (
|
||||
<FixedSizeIcon name={iconName} />
|
||||
) : (
|
||||
<Box h="sm" w="md" />
|
||||
)}
|
||||
<Ellipsified style={{ fontWeight: "bold" }}>{name}</Ellipsified>
|
||||
</Flex>
|
||||
</MaybeLink>
|
||||
</td>
|
||||
<td>
|
||||
{collection && (
|
||||
<Link
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SortableColumnHeader } from "metabase/common/components/ItemsTable/Base
|
||||
import {
|
||||
ColumnHeader,
|
||||
ItemNameCell,
|
||||
MaybeItemLink,
|
||||
TBody,
|
||||
Table,
|
||||
TableColumn,
|
||||
@@ -228,28 +229,36 @@ function preventDefault(event: MouseEvent) {
|
||||
|
||||
function NameCell({ metric }: { metric?: MetricResult }) {
|
||||
const headingId = `metric-${metric?.id ?? "dummy"}-heading`;
|
||||
|
||||
return (
|
||||
<ItemNameCell
|
||||
data-testid="metric-name"
|
||||
aria-labelledby={headingId}
|
||||
{...nameProps}
|
||||
>
|
||||
<Flex align="center" gap="0.5rem" ps="1.4rem" pe="0.5rem">
|
||||
<MaybeItemLink
|
||||
to={
|
||||
metric
|
||||
? Urls.metric({ id: metric.id, name: metric.name, type: "metric" })
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
// To align the icons with "Name" in the <th>
|
||||
paddingInlineStart: "1.4rem",
|
||||
paddingInlineEnd: ".5rem",
|
||||
}}
|
||||
onClick={preventDefault}
|
||||
>
|
||||
{metric ? (
|
||||
<Link
|
||||
to={Urls.metric({
|
||||
id: metric.id,
|
||||
name: metric.name,
|
||||
type: "metric",
|
||||
})}
|
||||
onClick={preventDefault}
|
||||
>
|
||||
<EntityItem.Name name={metric.name} variant="list" id={headingId} />
|
||||
</Link>
|
||||
<EntityItem.Name
|
||||
name={metric?.name || ""}
|
||||
variant="list"
|
||||
id={headingId}
|
||||
/>
|
||||
) : (
|
||||
<SkeletonText />
|
||||
)}
|
||||
</Flex>
|
||||
</MaybeItemLink>
|
||||
</ItemNameCell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EntityItem } from "metabase/common/components/EntityItem";
|
||||
import { SortableColumnHeader } from "metabase/common/components/ItemsTable/BaseItemsTable";
|
||||
import {
|
||||
ItemNameCell,
|
||||
MaybeItemLink,
|
||||
TBody,
|
||||
Table,
|
||||
TableColumn,
|
||||
@@ -209,28 +210,35 @@ function NameCell({ model }: { model?: ModelResult }) {
|
||||
const getIcon = useGetIcon();
|
||||
const headingId = `model-${model?.id || "dummy"}-heading`;
|
||||
const icon = getIcon(model ?? { model: "dataset" }) ?? { name: "folder" };
|
||||
const name = <EntityItem.Name name={model?.name || ""} variant="list" />;
|
||||
return (
|
||||
<ItemNameCell data-testid="model-name" aria-labelledby={headingId}>
|
||||
<Flex id={headingId} align="center" gap="0.5rem" ps="1.4rem" pe="0.5rem">
|
||||
<MaybeItemLink
|
||||
to={
|
||||
model
|
||||
? Urls.model({ id: model.id, name: model.name, type: "model" })
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
// To align the icons with "Name" in the <th>
|
||||
paddingInlineStart: "1.4rem",
|
||||
paddingInlineEnd: ".5rem",
|
||||
}}
|
||||
onClick={preventDefault}
|
||||
>
|
||||
<EntityIcon
|
||||
size="1rem"
|
||||
{...icon}
|
||||
color="icon-brand"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
{model ? (
|
||||
<Link
|
||||
to={Urls.model({ id: model.id, name: model.name, type: "model" })}
|
||||
onClick={preventDefault}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</Flex>
|
||||
{
|
||||
<EntityItem.Name
|
||||
name={model?.name || ""}
|
||||
variant="list"
|
||||
id={headingId}
|
||||
/>
|
||||
}
|
||||
</MaybeItemLink>
|
||||
</ItemNameCell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { css } from "@emotion/react";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import { MarkdownPreview } from "metabase/common/components/MarkdownPreview";
|
||||
import { RawMaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
|
||||
import { Box, type BoxProps } from "metabase/ui";
|
||||
|
||||
export const ItemLink = styled(RawMaybeLink)<{ to?: string }>`
|
||||
display: block;
|
||||
height: min-content;
|
||||
${(props) =>
|
||||
props.to
|
||||
? ""
|
||||
: css`
|
||||
${Body} {
|
||||
cursor: default;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
// Unjustified type cast. FIXME
|
||||
export const ActionsContainer = styled(Box)<BoxProps>`
|
||||
display: flex;
|
||||
|
||||
@@ -9,10 +9,9 @@ import type {
|
||||
} from "metabase/common/collections/types";
|
||||
import { EntityIcon } from "metabase/common/components/EntityIcon";
|
||||
import { EventSandbox } from "metabase/common/components/EventSandbox";
|
||||
import { Link } from "metabase/common/components/Link";
|
||||
import { useGetIcon } from "metabase/hooks/use-icon";
|
||||
import { PLUGIN_MODERATION } from "metabase/plugins";
|
||||
import { Box, Card, Flex, Skeleton, Tooltip } from "metabase/ui";
|
||||
import { Card, Flex, Skeleton, Tooltip } from "metabase/ui";
|
||||
import { modelToUrl } from "metabase/urls";
|
||||
import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type {
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
Body,
|
||||
Description,
|
||||
Header,
|
||||
ItemLink,
|
||||
Title,
|
||||
} from "./PinnedItemCard.styled";
|
||||
|
||||
@@ -119,74 +119,71 @@ function PinnedItemCard({
|
||||
isCollectionItem(item) &&
|
||||
(onCopy || onMove || createBookmark || deleteBookmark || collection);
|
||||
|
||||
const card = (
|
||||
<Card p={0} radius="md" shadow="none" withBorder>
|
||||
<Body style={item ? undefined : { cursor: "default" }}>
|
||||
<Header>
|
||||
<EntityIcon {...iconData} size="1.5rem" color="core-brand" />
|
||||
<ActionsContainer h={item ? undefined : "2rem"}>
|
||||
{hasActions && (
|
||||
// This component is used within a `<Link>` component,
|
||||
// so we must prevent events from triggering the activation of the link
|
||||
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
|
||||
<ActionMenu
|
||||
databases={databases}
|
||||
bookmarks={bookmarks}
|
||||
createBookmark={createBookmark}
|
||||
deleteBookmark={deleteBookmark}
|
||||
item={item}
|
||||
collection={collection}
|
||||
onCopy={onCopy}
|
||||
onMove={onMove}
|
||||
/>
|
||||
</EventSandbox>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
</Header>
|
||||
{item ? (
|
||||
<>
|
||||
<Tooltip
|
||||
label={item.name}
|
||||
position="bottom"
|
||||
disabled={!showTitleTooltip}
|
||||
maw={TOOLTIP_MAX_WIDTH}
|
||||
>
|
||||
<Title
|
||||
onMouseEnter={(e) => maybeEnableTooltip(e, setShowTitleTooltip)}
|
||||
>
|
||||
<Flex align="center" gap="0.5rem">
|
||||
{item.name}
|
||||
<PLUGIN_MODERATION.ModerationStatusIcon
|
||||
status={item.moderated_status}
|
||||
filled
|
||||
size={14}
|
||||
/>
|
||||
</Flex>
|
||||
</Title>
|
||||
</Tooltip>
|
||||
<Description tooltipMaxWidth={TOOLTIP_MAX_WIDTH}>
|
||||
{item.description || DEFAULT_DESCRIPTION[item.model] || ""}
|
||||
</Description>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton natural h="1.5rem" />
|
||||
<Skeleton natural mt="xs" mb="4px" h="1rem" />
|
||||
</>
|
||||
)}
|
||||
</Body>
|
||||
</Card>
|
||||
);
|
||||
const to = item ? (modelToUrl(item) ?? "/") : undefined;
|
||||
return (
|
||||
<Box
|
||||
display="block"
|
||||
h="min-content"
|
||||
<ItemLink
|
||||
className={className}
|
||||
to={item ? (modelToUrl(item) ?? "/") : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{to ? <Link to={to}>{card}</Link> : card}
|
||||
</Box>
|
||||
<Card p={0} radius="md" shadow="none" withBorder>
|
||||
<Body>
|
||||
<Header>
|
||||
<EntityIcon {...iconData} size="1.5rem" color="core-brand" />
|
||||
<ActionsContainer h={item ? undefined : "2rem"}>
|
||||
{hasActions && (
|
||||
// This component is used within a `<Link>` component,
|
||||
// so we must prevent events from triggering the activation of the link
|
||||
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
|
||||
<ActionMenu
|
||||
databases={databases}
|
||||
bookmarks={bookmarks}
|
||||
createBookmark={createBookmark}
|
||||
deleteBookmark={deleteBookmark}
|
||||
item={item}
|
||||
collection={collection}
|
||||
onCopy={onCopy}
|
||||
onMove={onMove}
|
||||
/>
|
||||
</EventSandbox>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
</Header>
|
||||
{item ? (
|
||||
<>
|
||||
<Tooltip
|
||||
label={item.name}
|
||||
position="bottom"
|
||||
disabled={!showTitleTooltip}
|
||||
maw={TOOLTIP_MAX_WIDTH}
|
||||
>
|
||||
<Title
|
||||
onMouseEnter={(e) =>
|
||||
maybeEnableTooltip(e, setShowTitleTooltip)
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap="0.5rem">
|
||||
{item.name}
|
||||
<PLUGIN_MODERATION.ModerationStatusIcon
|
||||
status={item.moderated_status}
|
||||
filled
|
||||
size={14}
|
||||
/>
|
||||
</Flex>
|
||||
</Title>
|
||||
</Tooltip>
|
||||
<Description tooltipMaxWidth={TOOLTIP_MAX_WIDTH}>
|
||||
{item.description || DEFAULT_DESCRIPTION[item.model] || ""}
|
||||
</Description>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Skeleton natural h="1.5rem" />
|
||||
<Skeleton natural mt="xs" mb="4px" h="1rem" />
|
||||
</>
|
||||
)}
|
||||
</Body>
|
||||
</Card>
|
||||
</ItemLink>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { EntityItem } from "metabase/common/components/EntityItem";
|
||||
import { IconButtonWrapper } from "metabase/common/components/IconButtonWrapper";
|
||||
import { Link } from "metabase/common/components/Link";
|
||||
import { RawMaybeLink } from "metabase/common/components/MaybeLink/MaybeLink.styled";
|
||||
import AdminS from "metabase/css/admin.module.css";
|
||||
import type { IconProps, TextProps } from "metabase/ui";
|
||||
import { FixedSizeIcon, Text } from "metabase/ui";
|
||||
@@ -111,15 +112,17 @@ export const ItemButton = styled(Text)<
|
||||
|
||||
export const ItemLink = styled(Link)(itemLinkStyle);
|
||||
|
||||
export const MaybeItemLink = styled(RawMaybeLink)(itemLinkStyle);
|
||||
|
||||
export const ItemNameCell = styled.td`
|
||||
padding: 0 !important;
|
||||
|
||||
& > * {
|
||||
${ItemLink}, ${MaybeItemLink}, ${ItemButton} {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
& > * {
|
||||
${ItemLink}, ${MaybeItemLink}, ${ItemButton} {
|
||||
color: var(--mb-color-core-brand);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { css } from "@emotion/react";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import styled from "@emotion/styled";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import type { LinkProps } from "metabase/common/components/Link";
|
||||
import { Link } from "metabase/common/components/Link";
|
||||
import { maybeColor } from "metabase/ui/utils/colors";
|
||||
|
||||
interface RawMaybeLinkProps {
|
||||
to?: string;
|
||||
activeColor?: string;
|
||||
inactiveColor?: string;
|
||||
isSingleLine?: boolean;
|
||||
}
|
||||
|
||||
export function RawMaybeLink({
|
||||
to,
|
||||
activeColor,
|
||||
inactiveColor,
|
||||
isSingleLine,
|
||||
...props
|
||||
}: RawMaybeLinkProps & (LinkProps | HTMLAttributes<HTMLSpanElement>)) {
|
||||
return to ? <Link to={to} {...props} /> : <span {...props} />;
|
||||
}
|
||||
|
||||
const hoverStyle = (props: RawMaybeLinkProps) => css`
|
||||
cursor: pointer;
|
||||
${props.activeColor ? `color: ${maybeColor(props.activeColor)};` : ""}
|
||||
`;
|
||||
|
||||
/**
|
||||
* @deprecated styled components are deprecated
|
||||
*/
|
||||
export const MaybeLink = styled(RawMaybeLink)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.875em;
|
||||
font-weight: bold;
|
||||
${(props) =>
|
||||
props.inactiveColor ? `color: ${maybeColor(props.inactiveColor)};` : ""}
|
||||
min-width: ${(props) => (props.isSingleLine ? 0 : "")};
|
||||
|
||||
:hover {
|
||||
${(props) => (props.to || props.onClick) && hoverStyle(props)}
|
||||
}
|
||||
`;
|
||||
@@ -1,10 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { DataStudioBreadcrumbs } from "metabase/common/data-studio/components/DataStudioBreadcrumbs";
|
||||
import { PageContainer } from "metabase/common/data-studio/components/PageContainer";
|
||||
import { Stack } from "metabase/ui";
|
||||
import {
|
||||
PaneHeader,
|
||||
PaneHeaderInput,
|
||||
} from "metabase/common/data-studio/components/PaneHeader";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import { Link } from "metabase/router";
|
||||
import { Group, Stack } from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
import type { ScheduleDisplayType, TransformTagId } from "metabase-types/api";
|
||||
|
||||
import { JobHeader } from "../JobHeader";
|
||||
import { NAME_MAX_LENGTH } from "../../constants";
|
||||
import { LockedTransformsHoverCard } from "../LockedTransformsHoverCard/LockedTransformsHoverCard";
|
||||
import { TransformBadge } from "../TransformBadge/TransformBadge";
|
||||
|
||||
import { ScheduleSection } from "./ScheduleSection";
|
||||
import { TagSection } from "./TagSection";
|
||||
@@ -18,7 +29,6 @@ type JobEditorProps = {
|
||||
tabs?: ReactNode;
|
||||
readOnly?: boolean;
|
||||
isCheckingPermissions?: boolean;
|
||||
showMetabotButton?: boolean;
|
||||
onNameChange: (name: string) => void;
|
||||
onScheduleChange: (
|
||||
schedule: string,
|
||||
@@ -34,21 +44,48 @@ export function JobEditor({
|
||||
tabs,
|
||||
readOnly,
|
||||
isCheckingPermissions,
|
||||
showMetabotButton,
|
||||
onNameChange,
|
||||
onScheduleChange,
|
||||
onTagListChange,
|
||||
}: JobEditorProps) {
|
||||
const isMeterLocked = useSetting("transforms-meter-locked");
|
||||
|
||||
return (
|
||||
<PageContainer data-testid="transforms-job-editor" gap="2.5rem">
|
||||
<JobHeader
|
||||
job={job}
|
||||
<PaneHeader
|
||||
title={
|
||||
<Group align="center" gap="sm" wrap="nowrap">
|
||||
<PaneHeaderInput
|
||||
initialValue={job.name}
|
||||
maxLength={NAME_MAX_LENGTH}
|
||||
onChange={onNameChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{isMeterLocked && (
|
||||
<LockedTransformsHoverCard>
|
||||
<TransformBadge bg="background_surface-warning-strong">{t`Disabled`}</TransformBadge>
|
||||
</LockedTransformsHoverCard>
|
||||
)}
|
||||
{!isMeterLocked && !job.active && (
|
||||
<TransformBadge bg="background_surface-warning-strong">
|
||||
{t`Disabled`}
|
||||
</TransformBadge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
py={0}
|
||||
breadcrumbs={
|
||||
<DataStudioBreadcrumbs>
|
||||
<Link key="transform-job-list" to={Urls.transformJobList()}>
|
||||
{t`Jobs`}
|
||||
</Link>
|
||||
{job.name}
|
||||
</DataStudioBreadcrumbs>
|
||||
}
|
||||
menu={menu}
|
||||
actions={actions}
|
||||
tabs={tabs}
|
||||
readOnly={readOnly}
|
||||
showMetabotButton={showMetabotButton}
|
||||
onNameChange={onNameChange}
|
||||
data-testid="jobs-header"
|
||||
/>
|
||||
<Stack gap="3.5rem">
|
||||
<ScheduleSection
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { DataStudioBreadcrumbs } from "metabase/common/data-studio/components/DataStudioBreadcrumbs";
|
||||
import {
|
||||
PaneHeader,
|
||||
PaneHeaderInput,
|
||||
} from "metabase/common/data-studio/components/PaneHeader";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import { Link } from "metabase/router";
|
||||
import { Group } from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
|
||||
import { NAME_MAX_LENGTH } from "../../constants";
|
||||
import type { TransformJobInfo } from "../JobEditor/types";
|
||||
import { LockedTransformsHoverCard } from "../LockedTransformsHoverCard/LockedTransformsHoverCard";
|
||||
import { TransformBadge } from "../TransformBadge/TransformBadge";
|
||||
|
||||
type JobHeaderProps = {
|
||||
job: TransformJobInfo;
|
||||
menu?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
tabs?: ReactNode;
|
||||
readOnly?: boolean;
|
||||
showMetabotButton?: boolean;
|
||||
onNameChange: (name: string) => void;
|
||||
};
|
||||
|
||||
export function JobHeader({
|
||||
job,
|
||||
menu,
|
||||
actions,
|
||||
tabs,
|
||||
readOnly,
|
||||
showMetabotButton,
|
||||
onNameChange,
|
||||
}: JobHeaderProps) {
|
||||
const isMeterLocked = useSetting("transforms-meter-locked");
|
||||
|
||||
return (
|
||||
<PaneHeader
|
||||
title={
|
||||
<Group align="center" gap="sm" wrap="nowrap">
|
||||
<PaneHeaderInput
|
||||
initialValue={job.name}
|
||||
maxLength={NAME_MAX_LENGTH}
|
||||
onChange={onNameChange}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{isMeterLocked && (
|
||||
<LockedTransformsHoverCard>
|
||||
<TransformBadge bg="background_surface-warning-strong">{t`Disabled`}</TransformBadge>
|
||||
</LockedTransformsHoverCard>
|
||||
)}
|
||||
{!isMeterLocked && !job.active && (
|
||||
<TransformBadge bg="background_surface-warning-strong">
|
||||
{t`Disabled`}
|
||||
</TransformBadge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
py={0}
|
||||
breadcrumbs={
|
||||
<DataStudioBreadcrumbs>
|
||||
<Link key="transform-job-list" to={Urls.transformJobList()}>
|
||||
{t`Jobs`}
|
||||
</Link>
|
||||
{job.name}
|
||||
</DataStudioBreadcrumbs>
|
||||
}
|
||||
menu={menu}
|
||||
actions={actions}
|
||||
tabs={tabs}
|
||||
showMetabotButton={showMetabotButton}
|
||||
data-testid="jobs-header"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { JobHeader } from "./JobHeader";
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
skipToken,
|
||||
useListTransformJobTransformsQuery,
|
||||
useUpdateTransformJobMutation,
|
||||
} from "metabase/api";
|
||||
import { useMetadataToasts } from "metabase/metadata/hooks";
|
||||
import type { TransformJobId } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
canEditTransform,
|
||||
useTransformPermissions,
|
||||
} from "./use-transform-permissions";
|
||||
|
||||
export function useJobHeaderState(jobId: TransformJobId | undefined) {
|
||||
const [updateJob] = useUpdateTransformJobMutation();
|
||||
const { sendErrorToast, sendSuccessToast } = useMetadataToasts();
|
||||
const { transformsDatabases } = useTransformPermissions();
|
||||
const { data: transforms, isLoading: isCheckingPermissions } =
|
||||
useListTransformJobTransformsQuery(jobId ?? skipToken);
|
||||
|
||||
const readOnly = useMemo(() => {
|
||||
if (!transformsDatabases || !transforms) {
|
||||
return true;
|
||||
}
|
||||
return !transforms.every((transform) =>
|
||||
canEditTransform(transform, transformsDatabases),
|
||||
);
|
||||
}, [transforms, transformsDatabases]);
|
||||
|
||||
const handleNameChange = async (name: string) => {
|
||||
if (jobId === undefined) {
|
||||
return;
|
||||
}
|
||||
const { error } = await updateJob({ id: jobId, name });
|
||||
if (error) {
|
||||
sendErrorToast(t`Failed to update job name`);
|
||||
} else {
|
||||
sendSuccessToast(t`Job name updated`);
|
||||
}
|
||||
};
|
||||
|
||||
return { readOnly, isCheckingPermissions, onNameChange: handleNameChange };
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
skipToken,
|
||||
useGetTransformJobQuery,
|
||||
useListTransformJobTransformsQuery,
|
||||
useUpdateTransformJobMutation,
|
||||
} from "metabase/api";
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import { useMetadataToasts } from "metabase/metadata/hooks";
|
||||
import { useJobHeaderState } from "metabase/transforms/hooks/use-job-header-state";
|
||||
import { useTransformPermissions } from "metabase/transforms/hooks/use-transform-permissions";
|
||||
import {
|
||||
canEditTransform,
|
||||
useTransformPermissions,
|
||||
} from "metabase/transforms/hooks/use-transform-permissions";
|
||||
import { Center } from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
import type {
|
||||
@@ -47,9 +50,16 @@ export function JobPage({ params }: JobPageProps) {
|
||||
pollingInterval: isPolling ? POLLING_INTERVAL : undefined,
|
||||
});
|
||||
|
||||
const { isLoadingDatabases, databasesError } = useTransformPermissions();
|
||||
const { readOnly, isCheckingPermissions, onNameChange } =
|
||||
useJobHeaderState(jobId);
|
||||
const { transformsDatabases, isLoadingDatabases, databasesError } =
|
||||
useTransformPermissions();
|
||||
const { data: transforms, isLoading: isLoadingTransforms } =
|
||||
useListTransformJobTransformsQuery(jobId || skipToken);
|
||||
const readOnly = useMemo(() => {
|
||||
if (!transformsDatabases || !transforms) {
|
||||
return true;
|
||||
}
|
||||
return !transforms.every((t) => canEditTransform(t, transformsDatabases));
|
||||
}, [transforms, transformsDatabases]);
|
||||
|
||||
if (isPolling !== isPollingNeeded(job)) {
|
||||
setIsPolling(isPollingNeeded(job));
|
||||
@@ -69,8 +79,7 @@ export function JobPage({ params }: JobPageProps) {
|
||||
<JobPageBody
|
||||
job={job}
|
||||
readOnly={readOnly}
|
||||
isCheckingPermissions={isCheckingPermissions}
|
||||
onNameChange={onNameChange}
|
||||
isCheckingPermissions={isLoadingTransforms}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -79,19 +88,30 @@ type JobPageBodyProps = {
|
||||
job: TransformJob;
|
||||
readOnly?: boolean;
|
||||
isCheckingPermissions?: boolean;
|
||||
onNameChange: (name: string) => void;
|
||||
};
|
||||
|
||||
function JobPageBody({
|
||||
job,
|
||||
readOnly,
|
||||
isCheckingPermissions,
|
||||
onNameChange,
|
||||
}: JobPageBodyProps) {
|
||||
const [updateJob] = useUpdateTransformJobMutation();
|
||||
const { sendErrorToast, sendSuccessToast, sendUndoToast } =
|
||||
useMetadataToasts();
|
||||
|
||||
const handleNameChange = async (name: string) => {
|
||||
const { error } = await updateJob({
|
||||
id: job.id,
|
||||
name,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
sendErrorToast(t`Failed to update job name`);
|
||||
} else {
|
||||
sendSuccessToast(t`Job name updated`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleChange = async (
|
||||
schedule: string,
|
||||
uiDisplayType: ScheduleDisplayType,
|
||||
@@ -141,8 +161,7 @@ function JobPageBody({
|
||||
tabs={<JobTabs jobId={job.id} />}
|
||||
readOnly={readOnly}
|
||||
isCheckingPermissions={isCheckingPermissions}
|
||||
showMetabotButton
|
||||
onNameChange={onNameChange}
|
||||
onNameChange={handleNameChange}
|
||||
onScheduleChange={handleScheduleChange}
|
||||
onTagListChange={handleTagListChange}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
setupDatabaseListEndpoint,
|
||||
setupUserMetabotPermissionsEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { setupDatabaseListEndpoint } from "__support__/server-mocks";
|
||||
import {
|
||||
setupGetTransformJobEndpoint,
|
||||
setupListTransformJobTransformsEndpoint,
|
||||
setupListTransformTagsEndpoint,
|
||||
} from "__support__/server-mocks/transform";
|
||||
import { act, renderWithProviders, screen, within } from "__support__/ui";
|
||||
@@ -32,8 +30,7 @@ const setup = ({
|
||||
setupGetTransformJobEndpoint(job);
|
||||
setupDatabaseListEndpoint([createMockDatabase()]);
|
||||
setupListTransformTagsEndpoint([]);
|
||||
setupUserMetabotPermissionsEndpoint();
|
||||
setupListTransformJobTransformsEndpoint(job.id, [], {
|
||||
fetchMock.get(`path:/api/transform-job/${job.id}/transforms`, [], {
|
||||
delay: transformsDelay,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,11 +11,15 @@ import {
|
||||
} from "metabase/api";
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import { PaginationControls } from "metabase/common/components/PaginationControls";
|
||||
import { DataStudioBreadcrumbs } from "metabase/common/data-studio/components/DataStudioBreadcrumbs";
|
||||
import {
|
||||
PaneHeader,
|
||||
PanelHeaderTitle,
|
||||
} from "metabase/common/data-studio/components/PaneHeader";
|
||||
import { usePageTitle } from "metabase/hooks/use-page-title";
|
||||
import { useDispatch } from "metabase/redux";
|
||||
import { replace } from "metabase/router";
|
||||
import { Link, replace } from "metabase/router";
|
||||
import { POLLING_INTERVAL } from "metabase/transforms/constants";
|
||||
import { useJobHeaderState } from "metabase/transforms/hooks/use-job-header-state";
|
||||
import { formatRunMethod, formatStatus } from "metabase/transforms/utils";
|
||||
import { Center, Flex, Group, Select, Stack } from "metabase/ui";
|
||||
import * as Urls from "metabase/urls";
|
||||
@@ -28,8 +32,6 @@ import {
|
||||
type TransformRunMethod,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { JobHeader } from "../../components/JobHeader";
|
||||
import { JobMoreMenu } from "../../components/JobMoreMenu";
|
||||
import { JobTabs } from "../../components/JobTabs";
|
||||
|
||||
import S from "./JobRunListPage.module.css";
|
||||
@@ -61,7 +63,6 @@ export function JobRunListPage({ params, location }: JobRunListPageProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: job } = useGetTransformJobQuery(jobId ?? skipToken);
|
||||
const { readOnly, onNameChange } = useJobHeaderState(jobId);
|
||||
|
||||
const { data, isLoading, error } = useListTransformJobRunsQuery(
|
||||
jobId != null
|
||||
@@ -163,16 +164,18 @@ export function JobRunListPage({ params, location }: JobRunListPageProps) {
|
||||
data-testid="job-run-list"
|
||||
>
|
||||
<Stack className={S.main} flex={1} px="3.5rem" pb="md" gap={0}>
|
||||
{job !== undefined && (
|
||||
<JobHeader
|
||||
job={job}
|
||||
menu={!readOnly && <JobMoreMenu job={job} />}
|
||||
tabs={<JobTabs jobId={job.id} />}
|
||||
readOnly={readOnly}
|
||||
showMetabotButton
|
||||
onNameChange={onNameChange}
|
||||
/>
|
||||
)}
|
||||
<PaneHeader
|
||||
title={job != null && <PanelHeaderTitle>{job.name}</PanelHeaderTitle>}
|
||||
tabs={jobId != null && <JobTabs jobId={jobId} />}
|
||||
breadcrumbs={
|
||||
<DataStudioBreadcrumbs>
|
||||
<Link to={Urls.transformJobList()}>{t`Jobs`}</Link>
|
||||
{job?.name ?? t`Run history`}
|
||||
</DataStudioBreadcrumbs>
|
||||
}
|
||||
py={0}
|
||||
showMetabotButton
|
||||
/>
|
||||
{isLoading || error != null ? (
|
||||
<Center h="100%">
|
||||
<LoadingAndErrorWrapper loading={isLoading} error={error} />
|
||||
|
||||
@@ -2,11 +2,9 @@ import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {
|
||||
setupDatabaseListEndpoint,
|
||||
setupGetTransformJobEndpoint,
|
||||
setupListJobRunTransformRunsEndpoint,
|
||||
setupListTransformJobRunsEndpoint,
|
||||
setupListTransformJobTransformsEndpoint,
|
||||
setupUserMetabotPermissionsEndpoint,
|
||||
} from "__support__/server-mocks";
|
||||
import {
|
||||
@@ -24,7 +22,6 @@ import type {
|
||||
TransformRunForJobRun,
|
||||
} from "metabase-types/api";
|
||||
import {
|
||||
createMockDatabase,
|
||||
createMockListTransformJobRunsResponse,
|
||||
createMockTransformJob,
|
||||
createMockTransformJobRun,
|
||||
@@ -51,8 +48,6 @@ function setup({
|
||||
|
||||
setupUserMetabotPermissionsEndpoint();
|
||||
setupGetTransformJobEndpoint(job);
|
||||
setupDatabaseListEndpoint([createMockDatabase()]);
|
||||
setupListTransformJobTransformsEndpoint(JOB_ID, []);
|
||||
setupListTransformJobRunsEndpoint(JOB_ID, () =>
|
||||
createMockListTransformJobRunsResponse({
|
||||
data: currentRuns,
|
||||
|
||||
@@ -43,13 +43,8 @@ export function setupListTransformJobsEndpoint(jobs: TransformJob[]) {
|
||||
export function setupListTransformJobTransformsEndpoint(
|
||||
jobId: TransformJobId,
|
||||
transforms: Transform[],
|
||||
options?: { delay?: number },
|
||||
) {
|
||||
fetchMock.get(
|
||||
`path:/api/transform-job/${jobId}/transforms`,
|
||||
transforms,
|
||||
options,
|
||||
);
|
||||
fetchMock.get(`path:/api/transform-job/${jobId}/transforms`, transforms);
|
||||
}
|
||||
|
||||
export function setupListTransformJobTransformsEndpointWithError(
|
||||
|
||||
@@ -11733,6 +11733,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- indexExists:
|
||||
indexName: idx_security_advisory_acknowledged_by
|
||||
tableName: security_advisory
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: security_advisory
|
||||
@@ -11750,6 +11751,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_security_advisory_acknowledged_by
|
||||
foreignKeyTableName: security_advisory
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: security_advisory
|
||||
@@ -11768,6 +11770,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- indexExists:
|
||||
indexName: idx_security_advisory_match_status
|
||||
tableName: security_advisory
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: security_advisory
|
||||
|
||||
@@ -2240,6 +2240,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_document_made_public_by_id
|
||||
foreignKeyTableName: document
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: document
|
||||
|
||||
@@ -118,6 +118,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_transform_collection_id
|
||||
foreignKeyTableName: transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: transform
|
||||
@@ -1526,6 +1527,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_collection_workspace_id
|
||||
foreignKeyTableName: collection
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: collection
|
||||
@@ -1629,6 +1631,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_log_workspace_id
|
||||
foreignKeyTableName: workspace_log
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_log
|
||||
@@ -1864,6 +1867,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_transform_workspace_id
|
||||
foreignKeyTableName: workspace_transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_transform
|
||||
@@ -1882,6 +1886,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_transform_global_id
|
||||
foreignKeyTableName: workspace_transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_transform
|
||||
@@ -1900,6 +1905,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_transform_collection_id
|
||||
foreignKeyTableName: workspace_transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_transform
|
||||
@@ -1918,6 +1924,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_transform_creator_id
|
||||
foreignKeyTableName: workspace_transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_transform
|
||||
@@ -2119,6 +2126,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_input_workspace_id
|
||||
foreignKeyTableName: workspace_input
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_input
|
||||
@@ -2137,6 +2145,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_input_table_id
|
||||
foreignKeyTableName: workspace_input
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_input
|
||||
@@ -2307,6 +2316,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_output_workspace_id
|
||||
foreignKeyTableName: workspace_output
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_output
|
||||
@@ -2325,6 +2335,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_output_ref_id
|
||||
foreignKeyTableName: workspace_output
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_output
|
||||
@@ -2415,6 +2426,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_merge_workspace_id
|
||||
foreignKeyTableName: workspace_merge
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_merge
|
||||
@@ -2433,6 +2445,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_merge_creator_id
|
||||
foreignKeyTableName: workspace_merge
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_merge
|
||||
@@ -2555,6 +2568,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_merge_transform_workspace_merge_id
|
||||
foreignKeyTableName: workspace_merge_transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_merge_transform
|
||||
@@ -2573,6 +2587,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_merge_transform_workspace_id
|
||||
foreignKeyTableName: workspace_merge_transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_merge_transform
|
||||
@@ -2591,6 +2606,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_merge_transform_transform_id
|
||||
foreignKeyTableName: workspace_merge_transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_merge_transform
|
||||
@@ -2609,6 +2625,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_merge_transform_merging_user_id
|
||||
foreignKeyTableName: workspace_merge_transform
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_merge_transform
|
||||
@@ -2837,6 +2854,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_output_external_workspace_id
|
||||
foreignKeyTableName: workspace_output_external
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_output_external
|
||||
@@ -2855,6 +2873,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_output_external_transform_id
|
||||
foreignKeyTableName: workspace_output_external
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_output_external
|
||||
@@ -2989,6 +3008,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_input_external_workspace_id
|
||||
foreignKeyTableName: workspace_input_external
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_input_external
|
||||
@@ -3103,6 +3123,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_workspace_graph_workspace_id
|
||||
foreignKeyTableName: workspace_graph
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: workspace_graph
|
||||
|
||||
@@ -171,6 +171,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- foreignKeyConstraintExists:
|
||||
foreignKeyName: fk_metabot_source_feedback_message_id
|
||||
foreignKeyTableName: metabot_source_feedback
|
||||
changes:
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: metabot_source_feedback
|
||||
|
||||
@@ -40,6 +40,7 @@ databaseChangeLog:
|
||||
- not:
|
||||
- indexExists:
|
||||
indexName: idx_task_run_notification_id_started_at
|
||||
tableName: task_run
|
||||
changes:
|
||||
- createIndex:
|
||||
tableName: task_run
|
||||
|
||||
@@ -105,21 +105,73 @@
|
||||
result-set (.executeQuery stmt)]
|
||||
(when-not (.next result-set)
|
||||
;; this record will not be visible until the tx commits, so there's no need to lock it
|
||||
;; we instead rely on concurrent threads having constraint violation trying to insert their own record
|
||||
(t2/query-one {:insert-into [:metabase_cluster_lock]
|
||||
:columns [:lock_name]
|
||||
:values [[lock-name-str]]})))
|
||||
;; we instead rely on concurrent threads having constraint violation trying to insert their own record.
|
||||
;; the insert must go on `conn` explicitly: in detached mode there is no ambient transaction, and an
|
||||
;; insert on a pooled connection would commit immediately without us holding the row lock
|
||||
(t2/query-one conn {:insert-into [:metabase_cluster_lock]
|
||||
:columns [:lock_name]
|
||||
:values [[lock-name-str]]})))
|
||||
(log/debugf "Obtained cluster lock: %s (%s)" lock-name-str mode))
|
||||
|
||||
(def ^:private ^:dynamic *detached-locks-held*
|
||||
"Map of lock-name-str -> mode for detached cluster locks held by the current thread. Used to make
|
||||
re-acquisition of a held detached lock a no-op: a second acquisition would run on a second dedicated
|
||||
connection and deadlock against our own row lock."
|
||||
{})
|
||||
|
||||
(defn- held-mode-covers?
|
||||
"Whether an already-held detached lock in `held-mode` satisfies a request for `requested-mode`."
|
||||
[held-mode requested-mode]
|
||||
(or (= held-mode :exclusive)
|
||||
(= held-mode requested-mode)))
|
||||
|
||||
(defn- detached-held? [{:keys [lock-name-str mode]}]
|
||||
(when-let [held-mode (*detached-locks-held* lock-name-str)]
|
||||
(held-mode-covers? held-mode mode)))
|
||||
|
||||
(defn- do-with-cluster-locks*
|
||||
"Acquire all `locks` (each a `{:lock-name-str, :mode}` map) inside a single
|
||||
transaction, then run `thunk`."
|
||||
transaction, then run `thunk`. Locks this thread already holds detached are skipped -- acquiring them
|
||||
here would block against our own dedicated connection."
|
||||
[locks timeout-seconds thunk]
|
||||
(t2/with-transaction [conn]
|
||||
(doseq [{:keys [lock-name-str mode]} locks]
|
||||
(doseq [{:keys [lock-name-str mode] :as lock} locks
|
||||
:when (not (detached-held? lock))]
|
||||
(acquire-lock-row! conn lock-name-str timeout-seconds mode))
|
||||
(thunk)))
|
||||
|
||||
(defn- do-with-detached-cluster-locks*
|
||||
"Like [[do-with-cluster-locks*]], but holds the lock rows on a dedicated unshared connection
|
||||
(see [[metabase.app-db.connection/with-unshared-connection]]) while `thunk` runs on ordinary pooled
|
||||
connections: `thunk`'s toucan work commits incrementally in its own short transactions instead of
|
||||
riding one long transaction on the lock's connection. The locks are released when `thunk` completes
|
||||
(commit) or throws (the pool rolls the dedicated connection back on check-in) -- but `thunk`'s own
|
||||
already-committed work is NOT rolled back on a throw.
|
||||
|
||||
Requesting `:exclusive` while already holding the same lock detached in `:share` mode is not
|
||||
supported (upgrading would self-deadlock) and throws."
|
||||
[locks timeout-seconds thunk]
|
||||
(let [needed (remove (fn [{:keys [lock-name-str mode] :as lock}]
|
||||
(when (and (contains? *detached-locks-held* lock-name-str)
|
||||
(not (detached-held? lock)))
|
||||
(throw (ex-info "Cannot upgrade a held detached cluster lock from :share to :exclusive"
|
||||
{:lock-name lock-name-str :held (*detached-locks-held* lock-name-str) :requested mode})))
|
||||
(detached-held? lock))
|
||||
locks)]
|
||||
(if (empty? needed)
|
||||
(thunk)
|
||||
(mdb.connection/with-unshared-connection [conn]
|
||||
(.setAutoCommit ^Connection conn false)
|
||||
(doseq [{:keys [lock-name-str mode]} needed]
|
||||
(acquire-lock-row! conn lock-name-str timeout-seconds mode))
|
||||
(let [result (binding [*detached-locks-held* (into *detached-locks-held*
|
||||
(map (juxt :lock-name-str :mode))
|
||||
needed)]
|
||||
(thunk))]
|
||||
;; releases the row locks and persists any lock rows we inserted
|
||||
(.commit ^Connection conn)
|
||||
result)))))
|
||||
|
||||
;; ---------- h2 in-process rw locks ----------
|
||||
;;
|
||||
;; h2 does not respect query timeout when taking SELECT ... FOR UPDATE locks, and
|
||||
@@ -178,18 +230,24 @@
|
||||
{:locks [(normalize-lock-spec opts)]}
|
||||
|
||||
(map? opts)
|
||||
(let [{:keys [lock locks timeout-seconds retry-config retry-transient?]} opts]
|
||||
(let [{:keys [lock locks timeout-seconds retry-config retry-transient? detached?]} opts]
|
||||
(when (and lock locks)
|
||||
(throw (ex-info "Cluster-lock opts must specify exactly one of :lock or :locks"
|
||||
{:opts opts})))
|
||||
(when-not (or lock locks)
|
||||
(throw (ex-info "Cluster-lock opts must specify :lock or :locks" {:opts opts})))
|
||||
(when (and detached? retry-transient?)
|
||||
;; :retry-transient? re-runs the body assuming a rollback undid its work; detached bodies commit
|
||||
;; incrementally, so a re-run would double-apply everything before the failure
|
||||
(throw (ex-info "Cluster-lock opts :detached? and :retry-transient? are mutually exclusive"
|
||||
{:opts opts})))
|
||||
(cond-> {:locks (if lock
|
||||
[(normalize-lock-spec (if (map? lock)
|
||||
lock
|
||||
{:lock lock :mode (or (:mode opts) :exclusive)}))]
|
||||
(mapv normalize-lock-spec locks))
|
||||
:retry-transient? (boolean retry-transient?)}
|
||||
:retry-transient? (boolean retry-transient?)
|
||||
:detached? (boolean detached?)}
|
||||
timeout-seconds (assoc :timeout-seconds timeout-seconds)
|
||||
retry-config (assoc :retry-config retry-config)))
|
||||
|
||||
@@ -227,7 +285,16 @@
|
||||
replicated across nodes, so the lock can't serialize writers and the conflicting
|
||||
commit comes back as a deadlock. Only opt in when the body is safe to re-run from
|
||||
scratch — idempotent, with no side effects outside the appdb transaction (a
|
||||
rolled-back deadlock undoes only the db writes, not external calls)."
|
||||
rolled-back deadlock undoes only the db writes, not external calls).
|
||||
|
||||
`:detached?` (default false) holds the lock rows on a dedicated connection instead
|
||||
of the caller's transaction: the body's toucan work then runs on ordinary pooled
|
||||
connections in its own short transactions, committing incrementally, rather than
|
||||
riding one long transaction that holds the lock. Use for long-running locked work
|
||||
(e.g. the audit DB install/load/sync at boot) whose body is idempotent/self-healing —
|
||||
a failure mid-body does NOT roll back its already-committed work. Re-acquiring a
|
||||
lock this thread already holds detached is a no-op (in both detached and
|
||||
transactional form). Mutually exclusive with `:retry-transient?`."
|
||||
[opts :- [:or
|
||||
:keyword
|
||||
[:map
|
||||
@@ -240,22 +307,25 @@
|
||||
[:mode {:optional true} [:enum :exclusive :share]]
|
||||
[:timeout-seconds {:optional true} :int]
|
||||
[:retry-config {:optional true} [:ref ::retry/retry-overrides]]
|
||||
[:retry-transient? {:optional true} :boolean]]]
|
||||
[:retry-transient? {:optional true} :boolean]
|
||||
[:detached? {:optional true} :boolean]]]
|
||||
thunk :- ifn?]
|
||||
(let [{:keys [locks timeout-seconds retry-config retry-transient?]
|
||||
(let [{:keys [locks timeout-seconds retry-config retry-transient? detached?]
|
||||
:or {timeout-seconds cluster-lock-timeout-seconds}} (parse-opts opts)]
|
||||
(cond
|
||||
;; h2 does not respect the query timeout when taking the lock and is not cross-process,
|
||||
;; so we fall back to an in-process ReentrantReadWriteLock per lock name.
|
||||
;; so we fall back to an in-process ReentrantReadWriteLock per lock name. The h2 locks never
|
||||
;; hold a transaction open, so :detached? is already the behavior and needs no special casing.
|
||||
(= (mdb.connection/db-type) :h2)
|
||||
(do-with-h2-cluster-locks* locks thunk)
|
||||
|
||||
:else
|
||||
(let [config (assoc (merge default-retry-config retry-config)
|
||||
:retry-if (fn [_ e] (retry-if-error? retry-transient? e)))]
|
||||
:retry-if (fn [_ e] (retry-if-error? retry-transient? e)))
|
||||
impl (if detached? do-with-detached-cluster-locks* do-with-cluster-locks*)]
|
||||
(try
|
||||
(retry/with-retry config
|
||||
(do-with-cluster-locks* locks timeout-seconds thunk))
|
||||
(impl locks timeout-seconds thunk))
|
||||
(catch Throwable e
|
||||
;; only a genuine lock-acquisition failure gets the "Failed to obtain cluster lock" wrapper;
|
||||
;; an exhausted transient body error (e.g. deadlock) propagates raw so the message stays truthful.
|
||||
|
||||
@@ -300,6 +300,37 @@
|
||||
(run-after-commit-callbacks! callbacks))
|
||||
result)))
|
||||
|
||||
;;;; Unshared connections
|
||||
;;;;
|
||||
;;;; A connection that holds fragile long-lived state -- a streaming result set (postgres portal), a row
|
||||
;;;; lock held while other work runs -- must not be picked up by ambient connection reuse: a borrower that
|
||||
;;;; inherits it via [[toucan2.connection/*current-connectable*]] and commits destroys that state (see
|
||||
;;;; #78238 and the revert of #76645). [[toucan2.connection/unshared-connection!]] makes toucan use such a
|
||||
;;;; connection only when passed it explicitly, and never advertise it ambiently -- ambient work inside
|
||||
;;;; `body` resolves to the default connectable and runs on other pooled connections.
|
||||
|
||||
(defn do-with-unshared-connection
|
||||
"Impl for [[with-unshared-connection]]."
|
||||
[f]
|
||||
(with-open [conn (.getConnection (data-source))]
|
||||
(f (t2.conn/unshared-connection! conn))))
|
||||
|
||||
(defmacro with-unshared-connection
|
||||
"Execute `body` with `conn-binding` bound to a fresh app-db connection that ambient connection reuse can
|
||||
never pick up: toucan runs queries and transactions on it when `body` passes it explicitly, but never
|
||||
binds it as `*current-connectable*`, so toucan calls that resolve their connection ambiently -- including
|
||||
mid-reduction of a long-running query on this connection -- run (and commit) on other pooled connections.
|
||||
|
||||
In every other respect this is an ordinary JDBC connection, configured by `body`: e.g. call
|
||||
`.setAutoCommit false` for a streaming result set (postgres portal/cursor) or a held row lock. It is
|
||||
closed when `body` ends; the pool resets its state (rolling back any unresolved transaction) on check-in.
|
||||
|
||||
(mdb/with-unshared-connection [conn]
|
||||
(.setAutoCommit conn false)
|
||||
(reduce rf init (t2/reducible-query conn a-huge-query)))"
|
||||
[[conn-binding] & body]
|
||||
`(do-with-unshared-connection (fn [~conn-binding] ~@body)))
|
||||
|
||||
(methodical/defmethod t2.pipeline/transduce-query :before :default
|
||||
"Make sure application database calls are not done inside core.async dispatch pool threads. This is done relatively
|
||||
early in the pipeline so the stacktrace when this fails isn't super enormous."
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
in-transaction?
|
||||
quoting-style
|
||||
unique-identifier
|
||||
transaction-state]
|
||||
transaction-state
|
||||
with-unshared-connection]
|
||||
[mdb.connection-pool-setup
|
||||
recent-activity?]
|
||||
[mdb.data-source
|
||||
|
||||
@@ -201,8 +201,6 @@
|
||||
|
||||
(defmethod initialize! :default [_]) ; no-op
|
||||
|
||||
;; bump
|
||||
|
||||
(defmulti display-name
|
||||
"A nice name for the driver that we'll display to in the admin panel, e.g. \"PostgreSQL\" for `:postgres`. Default
|
||||
implementation capitializes the name of the driver, e.g. `:oracle` becomes \"Oracle\".
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
[metabase.app-db.cluster-lock :as sut]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.app-db.transient-error :as transient-error]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2])
|
||||
@@ -226,3 +227,61 @@
|
||||
(is (= [[:a :failed :timeout]
|
||||
[:b :done]]
|
||||
@results))))))
|
||||
|
||||
(deftest detached-lock-basic-test
|
||||
(testing "returns the body's value and works non-concurrently"
|
||||
(is (= :ok (sut/with-cluster-lock {:lock ::detached-basic :detached? true} :ok))))
|
||||
(testing ":detached? and :retry-transient? are mutually exclusive"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"mutually exclusive"
|
||||
(sut/with-cluster-lock {:lock ::detached-basic :detached? true :retry-transient? true}
|
||||
:nope)))))
|
||||
|
||||
(deftest detached-lock-body-commits-incrementally-test
|
||||
(let [email (mt/random-email)]
|
||||
(try
|
||||
(testing "body work commits as it goes and survives a later throw in the body"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"boom"
|
||||
(sut/with-cluster-lock {:lock ::detached-commit :detached? true}
|
||||
(t2/insert! :model/User (assoc (mt/with-temp-defaults :model/User) :email email))
|
||||
(throw (ex-info "boom" {})))))
|
||||
(is (t2/exists? :model/User :email email)))
|
||||
(finally
|
||||
(t2/delete! :model/User :email email)))))
|
||||
|
||||
(deftest detached-lock-reentrant-test
|
||||
(testing "re-acquiring a held detached lock is a no-op instead of a self-deadlock"
|
||||
(is (= :ok (sut/with-cluster-lock {:lock ::detached-reentrant :detached? true}
|
||||
(sut/with-cluster-lock {:lock ::detached-reentrant :detached? true
|
||||
:timeout-seconds 1 :retry-config {:max-retries 0}}
|
||||
:ok)))))
|
||||
(when (not= (mdb/db-type) :h2)
|
||||
(testing "a transactional acquisition of a lock this thread holds detached is also a no-op"
|
||||
(is (= :ok (sut/with-cluster-lock {:lock ::detached-reentrant :detached? true}
|
||||
(sut/with-cluster-lock {:lock ::detached-reentrant
|
||||
:timeout-seconds 1 :retry-config {:max-retries 0}}
|
||||
:ok)))))
|
||||
(testing "upgrading a held detached :share lock to :exclusive throws instead of self-deadlocking"
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Cannot upgrade"
|
||||
(sut/with-cluster-lock {:lock ::detached-reentrant :mode :share :detached? true}
|
||||
(sut/with-cluster-lock {:lock ::detached-reentrant :detached? true}
|
||||
:nope)))))))
|
||||
|
||||
(deftest detached-lock-mutual-exclusion-test
|
||||
;; h2 uses the in-process rw-lock path where :detached? is a no-op; real row-lock semantics only.
|
||||
(when (not= (mdb/db-type) :h2)
|
||||
;; warm up the row first so we're not racing on the initial INSERT
|
||||
(sut/with-cluster-lock {:lock ::detached-mutex :detached? true} :warm)
|
||||
(let [held (run-with-lock {:lock ::detached-mutex :detached? true})]
|
||||
(is (.await ^CountDownLatch (:entered held) 3 TimeUnit/SECONDS))
|
||||
(testing "a detached holder blocks another detached acquirer"
|
||||
(is (not (acquirable-within? {:lock ::detached-mutex :detached? true
|
||||
:timeout-seconds 1 :retry-config {:max-retries 0}}
|
||||
3000))))
|
||||
(testing "a detached holder blocks a transactional acquirer"
|
||||
(is (not (acquirable-within? {:lock ::detached-mutex
|
||||
:timeout-seconds 1 :retry-config {:max-retries 0}}
|
||||
3000))))
|
||||
(.countDown ^CountDownLatch (:release held))
|
||||
(is (= :ok (deref (:done held) 3000 :timeout)))
|
||||
(testing "released once the body completes"
|
||||
(is (acquirable-within? {:lock ::detached-mutex :detached? true} 3000))))))
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.connection :as t2.connection]
|
||||
[toucan2.core :as t2])
|
||||
[toucan2.core :as t2]
|
||||
[toucan2.jdbc.options :as t2.jdbc.options])
|
||||
(:import
|
||||
(java.sql Connection)
|
||||
(java.util.concurrent Semaphore)))
|
||||
@@ -339,3 +340,67 @@
|
||||
(throw (ex-info "force savepoint rollback" {})))))
|
||||
(is (= {:outer-key "outer-val"} @mdb.connection/*transaction-state*)
|
||||
"data stashed by a rolled-back nested transaction is discarded from transaction-state")))
|
||||
|
||||
(deftest unshared-connection-not-bound-during-use-test
|
||||
(mdb.connection/with-unshared-connection [conn]
|
||||
(testing "explicitly passing the unshared connection works, but it is never bound as *current-connectable*"
|
||||
(let [rows (reduce (fn [acc row]
|
||||
(is (not (identical? conn t2.connection/*current-connectable*))
|
||||
"the unshared connection must not be ambiently visible mid-reduction")
|
||||
(conj acc (into {} row)))
|
||||
[]
|
||||
(t2/reducible-query conn ["select 1 as x"]))]
|
||||
(is (= 1 (count rows)))))
|
||||
(testing "a transaction opened while the unshared connection is in use gets its own connection"
|
||||
(reduce (fn [_ _row]
|
||||
(t2/with-transaction [tx-conn]
|
||||
(is (not (identical? tx-conn conn)))))
|
||||
nil
|
||||
(t2/reducible-query conn ["select 1 as x"])))))
|
||||
|
||||
(deftest unshared-connection-borrower-work-commits-independently-test
|
||||
(let [email (mt/random-email)]
|
||||
(testing "toucan work while the unshared connection is in use commits on its own connection"
|
||||
(mdb.connection/with-unshared-connection [conn]
|
||||
;; open a transaction on the unshared connection that is never committed: if the insert below
|
||||
;; wrongly rode this connection, it would be rolled back at pool check-in and the user would not exist
|
||||
(.setAutoCommit ^Connection conn false)
|
||||
(reduce (fn [_ _row]
|
||||
(t2/insert! :model/User (assoc (mt/with-temp-defaults :model/User) :email email)))
|
||||
nil
|
||||
(t2/reducible-query conn ["select 1 as x"])))
|
||||
(is (t2/exists? :model/User :email email))
|
||||
(t2/delete! :model/User :email email))))
|
||||
|
||||
(deftest unshared-connection-postgres-portal-survival-test
|
||||
;; postgres streams a result set through a portal that dies if anything commits on the connection
|
||||
;; mid-iteration -- the failure mode that forced the revert of #76645. Other appdbs buffer, so there is
|
||||
;; nothing to kill.
|
||||
(when (= (mdb.connection/db-type) :postgres)
|
||||
(let [portal-death? (fn [e]
|
||||
(loop [e e]
|
||||
(cond
|
||||
(nil? e) false
|
||||
(re-find #"portal" (str (ex-message e))) true
|
||||
:else (recur (ex-cause e)))))
|
||||
stream! (fn [^Connection conn]
|
||||
;; fetch-size + autocommit off = pg streams through a portal
|
||||
(.setAutoCommit conn false)
|
||||
(binding [t2.jdbc.options/*options* {:fetch-size 50}]
|
||||
(reduce (fn [n _row]
|
||||
;; unrelated toucan work mid-stream, resolved ambiently, that commits
|
||||
(when (= n 30)
|
||||
(t2/with-transaction [_]
|
||||
(t2/query ["select 1"])))
|
||||
(inc n))
|
||||
0
|
||||
(t2/reducible-query conn ["select g from generate_series(1, 1000) g"]))))]
|
||||
(testing "control: an ordinary connection is ambiently visible mid-reduction, so the borrower commit kills the portal"
|
||||
(with-open [^Connection raw (.getConnection (mdb.connection/data-source))]
|
||||
(let [e (try (stream! raw) nil (catch Throwable e e))]
|
||||
(is (portal-death? e)))
|
||||
(.rollback raw)
|
||||
(.setAutoCommit raw true)))
|
||||
(testing "an unshared connection is invisible to the borrower; the portal survives"
|
||||
(mdb.connection/with-unshared-connection [conn]
|
||||
(is (= 1000 (stream! conn))))))))
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
"7" "rgba(255, 0, 0, 0.65)"
|
||||
"8" "rgba(255, 0, 0, 0.2)"
|
||||
"9" "rgba(0, 0, 255, 0.75)"
|
||||
"1.001,5" "rgba(0, 0, 255, 0.75)"
|
||||
"1,001.5" "rgba(0, 0, 255, 0.75)"}
|
||||
"1.001,5" "rgba(255, 0, 0, 0.2)"
|
||||
"1,001.5" "rgba(255, 0, 0, 0.2)"}
|
||||
(-> (select-keys query-results [:cols :rows])
|
||||
(#'table/render-table {:col-names ["a" "b" "c"]
|
||||
:cols-for-color-lookup ["a" "b" "c"]}
|
||||
|
||||
Reference in New Issue
Block a user