Skip to content

Commit ca0bd2c

Browse files
authored
Merge pull request #2607 from Altinity/backports/unity_v2_catalog
Antalya 26.8: Backport of ClickHouse#115879 Unity catalog(v2)
2 parents 4cc5558 + cdbcf6d commit ca0bd2c

24 files changed

Lines changed: 2412 additions & 74 deletions

‎src/Databases/DataLake/DatabaseDataLake.cpp‎

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
#include <Databases/DatabaseFactory.h>
3131
#include <Databases/DataLake/UnityCatalog.h>
3232
#include <Databases/DataLake/RestCatalog.h>
33+
#include <Databases/DataLake/UnityV2Catalog.h>
3334
#include <Databases/DataLake/GlueCatalog.h>
3435
#include <Databases/DataLake/PaimonRestCatalog.h>
3536
#if USE_AWS_S3 && USE_SSL
@@ -71,6 +72,7 @@ namespace DatabaseDataLakeSetting
7172
extern const DatabaseDataLakeSettingsDatabaseDataLakeCatalogType catalog_type;
7273
extern const DatabaseDataLakeSettingsString warehouse;
7374
extern const DatabaseDataLakeSettingsString catalog_credential;
75+
extern const DatabaseDataLakeSettingsBool use_unity_catalog_v2;
7476
extern const DatabaseDataLakeSettingsString auth_header;
7577
extern const DatabaseDataLakeSettingsString auth_scope;
7678
extern const DatabaseDataLakeSettingsString storage_endpoint;
@@ -506,11 +508,28 @@ void DatabaseDataLake::initialize() const
506508
}
507509
case DB::DatabaseDataLakeCatalogType::UNITY:
508510
{
509-
catalog_impl = std::make_shared<DataLake::UnityCatalog>(
511+
if (!settings[DatabaseDataLakeSetting::use_unity_catalog_v2])
512+
{
513+
catalog_impl = std::make_shared<DataLake::UnityCatalog>(
514+
settings[DatabaseDataLakeSetting::warehouse].value,
515+
url,
516+
settings[DatabaseDataLakeSetting::catalog_credential].value,
517+
settings[DatabaseDataLakeSetting::namespaces].value,
518+
Context::getGlobalContextInstance());
519+
break;
520+
}
521+
522+
/// Databricks OIDC expects `all-apis`; the default `auth_scope` value targets Iceberg REST catalogs.
523+
const std::string unity_auth_scope = settings[DatabaseDataLakeSetting::auth_scope].changed
524+
? settings[DatabaseDataLakeSetting::auth_scope].value
525+
: "all-apis";
526+
catalog_impl = std::make_shared<DataLake::UnityV2Catalog>(
510527
settings[DatabaseDataLakeSetting::warehouse].value,
511528
url,
512529
settings[DatabaseDataLakeSetting::catalog_credential].value,
513-
settings[DatabaseDataLakeSetting::namespaces].value,
530+
unity_auth_scope,
531+
settings[DatabaseDataLakeSetting::auth_header].value,
532+
settings[DatabaseDataLakeSetting::oauth_server_uri].value,
514533
Context::getGlobalContextInstance());
515534
break;
516535
}
@@ -658,15 +677,23 @@ void DatabaseDataLake::resetCatalog(String reason) const
658677

659678
StorageObjectStorageConfigurationPtr DatabaseDataLake::getConfiguration(
660679
DatabaseDataLakeStorageType type,
661-
DataLakeStorageSettingsPtr storage_settings) const
680+
DataLakeStorageSettingsPtr storage_settings,
681+
DataLake::DataLakeTableFormat table_format) const
662682
{
663683
/// TODO: add tests for azure, local storage types.
664684

665685
const auto settings_version = database_settings.get();
666686
const DatabaseDataLakeSettings & settings = *settings_version;
667687

668688
auto catalog = getCatalog();
669-
switch (catalog->getCatalogType())
689+
auto catalog_type = catalog->getCatalogType();
690+
691+
/// `UnityV2Catalog` serves both formats. Its Iceberg tables need the same configuration as an
692+
/// Iceberg REST catalog, so route them to that arm; the legacy catalog never reports Iceberg.
693+
if (catalog_type == DatabaseDataLakeCatalogType::UNITY && table_format == DataLake::DataLakeTableFormat::ICEBERG)
694+
catalog_type = DatabaseDataLakeCatalogType::ICEBERG_REST;
695+
696+
switch (catalog_type)
670697
{
671698
case DatabaseDataLakeCatalogType::ICEBERG_ONELAKE:
672699
{
@@ -1011,7 +1038,7 @@ StoragePtr DatabaseDataLake::tryGetTableImpl(const String & name, ContextPtr con
10111038
}
10121039
}
10131040

1014-
const auto configuration = getConfiguration(storage_type, storage_settings);
1041+
const auto configuration = getConfiguration(storage_type, storage_settings, table_metadata.getTableFormat());
10151042

10161043
/// HACK: Hacky-hack to enable lazy load
10171044
ContextMutablePtr context_copy = Context::createCopy(context_);
@@ -1093,7 +1120,7 @@ StoragePtr DatabaseDataLake::tryGetTableImpl(const String & name, ContextPtr con
10931120
return std::nullopt;
10941121
if (!with_vended_credentials && !catalog_manages_provider_chain)
10951122
return std::nullopt;
1096-
return catalog->getCredentialsConfigurationCallback(storage_id, DataLake::getForwardedAuthToken(context_));
1123+
return catalog->getCredentialsConfigurationCallback(storage_id, table_metadata, DataLake::getForwardedAuthToken(context_));
10971124
};
10981125

10991126
const auto catalog_uuid = table_metadata.getTableUUID();
@@ -1421,7 +1448,7 @@ DatabaseTablesIteratorPtr DatabaseDataLake::getTablesIteratorWithHint(
14211448
DatabaseTablesIteratorPtr DatabaseDataLake::getTablesIteratorImpl(
14221449
ContextPtr context_,
14231450
const FilterByNameFunction & filter_by_table_name,
1424-
bool skip_not_loaded,
1451+
bool /*skip_not_loaded*/,
14251452
const TablesFilter & tables_filter,
14261453
bool keep_unresolved_tables) const
14271454
{
@@ -1470,13 +1497,15 @@ DatabaseTablesIteratorPtr DatabaseDataLake::getTablesIteratorImpl(
14701497
futures.emplace_back(promises.back()->get_future());
14711498

14721499
pool.scheduleOrThrow(
1473-
[this, table_name, skip_not_loaded, context_, keep_unresolved_tables, promise=promises.back()]() mutable
1500+
[this, table_name, context_, keep_unresolved_tables, promise=promises.back()]() mutable
14741501
{
14751502
StoragePtr storage = nullptr;
14761503
try
14771504
{
14781505
LOG_INFO(log, "Get table information for table {}", table_name);
1479-
storage = tryGetTableImpl(table_name, context_, false, skip_not_loaded);
1506+
/// Tables marked unreadable at metadata time are skipped, not an error;
1507+
/// direct access still reports the reason.
1508+
storage = tryGetTableImpl(table_name, context_, /*lightweight*/ false, /*ignore_if_not_iceberg*/ true);
14801509
}
14811510
catch (...)
14821511
{
@@ -1668,6 +1697,10 @@ void DatabaseDataLake::applySettingsChanges(const SettingsChanges & settings_cha
16681697
auto new_settings = std::make_unique<DatabaseDataLakeSettings>(*current_settings);
16691698
new_settings->applyChanges(settings_changes);
16701699

1700+
/// Switching the Unity implementation replaces the catalog object rather than altering it in place.
1701+
const bool implementation_changed = (*new_settings)[DatabaseDataLakeSetting::use_unity_catalog_v2].value
1702+
!= (*current_settings)[DatabaseDataLakeSetting::use_unity_catalog_v2].value;
1703+
16711704
ASTPtr new_engine_definition;
16721705
{
16731706
std::lock_guard lock(mutex);
@@ -1695,7 +1728,17 @@ void DatabaseDataLake::applySettingsChanges(const SettingsChanges & settings_cha
16951728
storage->set(storage->settings, storage_settings_ast);
16961729
}
16971730

1731+
/// `use_unity_catalog_v2` is consumed by the database, not by the catalog object, so it is
1732+
/// never handed to the catalog. Re-applying the current value is a metadata-only no-op.
1733+
SettingsChanges catalog_settings_changes;
1734+
for (const auto & change : settings_changes)
1735+
{
1736+
if (change.name != DatabaseDataLakeSettings::getSettingName(DatabaseDataLakeSetting::use_unity_catalog_v2))
1737+
catalog_settings_changes.push_back(change);
1738+
}
1739+
16981740
std::shared_ptr<DataLake::ICatalog> local_catalog_snapshot;
1741+
if (!implementation_changed)
16991742
{
17001743
std::lock_guard lock(catalog_mutex);
17011744
local_catalog_snapshot = catalog_impl;
@@ -1704,8 +1747,9 @@ void DatabaseDataLake::applySettingsChanges(const SettingsChanges & settings_cha
17041747
/// Prepare the new catalog state without publishing it: validation, the eager token
17051748
/// fetch and the config reload may throw, and then nothing has changed yet.
17061749
DataLake::ICatalog::PreparedSettingsChangesPtr prepared_catalog_changes;
1707-
if (local_catalog_snapshot)
1708-
prepared_catalog_changes = local_catalog_snapshot->prepareSettingsChanges(settings_changes, DataLake::getForwardedAuthToken(query_context));
1750+
const bool alter_catalog = local_catalog_snapshot && !catalog_settings_changes.empty();
1751+
if (alter_catalog)
1752+
prepared_catalog_changes = local_catalog_snapshot->prepareSettingsChanges(catalog_settings_changes, DataLake::getForwardedAuthToken(query_context));
17091753

17101754
/// Persist the new metadata before publishing anything: if the write fails, the live
17111755
/// state is untouched and matches the old metadata on disk. The create query is built
@@ -1717,7 +1761,7 @@ void DatabaseDataLake::applySettingsChanges(const SettingsChanges & settings_cha
17171761
DatabaseCatalog::instance().updateMetadataFile(getDatabaseName(), new_create_query);
17181762

17191763
/// Publish. Nothing below throws.
1720-
if (local_catalog_snapshot)
1764+
if (alter_catalog)
17211765
local_catalog_snapshot->commitSettingsChanges(std::move(prepared_catalog_changes));
17221766
database_settings.set(std::move(new_settings));
17231767
{
@@ -1726,9 +1770,9 @@ void DatabaseDataLake::applySettingsChanges(const SettingsChanges & settings_cha
17261770
}
17271771
if (!local_catalog_snapshot)
17281772
{
1729-
/// The catalog was not built when the ALTER started. If a concurrent query
1730-
/// built it meanwhile, it used the old settings: drop it so the next access
1731-
/// rebuilds it with the new ones. Also clear a recorded construction failure
1773+
/// The catalog was not built when the ALTER started, or the implementation was switched.
1774+
/// If a concurrent query built it meanwhile, it used the old settings: drop it so the next
1775+
/// access rebuilds it with the new ones. Also clear a recorded construction failure
17321776
/// (e.g. credentials lost on RESTORE) for the same reason.
17331777
std::lock_guard lock(catalog_mutex);
17341778
resetCatalog(/* reason */ "");
@@ -1813,6 +1857,10 @@ ASTPtr DatabaseDataLake::getCreateTableQueryImpl(
18131857
storage->engine->setKind(ASTFunction::Kind::TABLE_ENGINE);
18141858
if (!table_metadata.isDefaultReadableTable())
18151859
storage->engine->name = DataLake::FAKE_TABLE_ENGINE_NAME_FOR_UNREADABLE_TABLES;
1860+
else if (table_metadata.getTableFormat() != DataLake::DataLakeTableFormat::UNKNOWN)
1861+
/// Mixed-format catalogs (e.g. `UnityV2Catalog`) set the format per table; single-format
1862+
/// catalogs leave it UNKNOWN and keep the database-level table engine name.
1863+
storage->engine->name = String(catalog->getTableEngineName(table_metadata));
18161864

18171865
storage->settings = {};
18181866
if (partition_by)
@@ -2197,9 +2245,10 @@ The following settings are supported:
21972245
21982246
| Setting | Description |
21992247
|-------------------------|-----------------------------------------------------------------------------------------|
2200-
| `catalog_type` | Type of catalog: `glue`, `unity` (Delta), `rest` (Iceberg), `hive`, `onelake` (Iceberg), `delta_sharing` (Iceberg, flat namespaces), `horizon` (Snowflake Horizon Iceberg REST) |
2248+
| `catalog_type` | Type of catalog: `glue`, `unity` (Delta, or Delta and Iceberg with `use_unity_catalog_v2`), `rest` (Iceberg), `hive`, `onelake` (Iceberg), `delta_sharing` (Iceberg, flat namespaces), `horizon` (Snowflake Horizon Iceberg REST) |
22012249
| `warehouse` | The warehouse/database name to use in the catalog. |
22022250
| `catalog_credential` | Authentication credential for the catalog (e.g., API key or token) |
2251+
| `use_unity_catalog_v2` | For `catalog_type = 'unity'`: use the new implementation, which serves both Delta Lake and Iceberg tables. Default: `false`. Set it on `CREATE`, or change it for an existing database with `ALTER DATABASE ... MODIFY SETTING`. |
22032252
| `auth_header` | Custom HTTP header for authentication with the catalog service |
22042253
| `auth_scope` | OAuth2 scope for authentication (if using OAuth) |
22052254
| `storage_endpoint` | Endpoint URL for the underlying storage |
@@ -2325,6 +2374,22 @@ Some catalogs support only one of the two modes:
23252374
See below sections for examples of using the `DataLakeCatalog` engine:
23262375
23272376
* [Unity Catalog](/guides/use-cases/data-warehousing/unity-catalog)
2377+
* Unity Catalog with Delta Lake and Iceberg tables
2378+
With `use_unity_catalog_v2 = 1`, the `unity` catalog serves both Delta Lake and Iceberg tables,
2379+
detecting the format of each table.
2380+
```sql
2381+
CREATE DATABASE database_name
2382+
ENGINE = DataLakeCatalog('https://<workspace>.cloud.databricks.com/api/2.1/unity-catalog')
2383+
SETTINGS
2384+
catalog_type = 'unity',
2385+
use_unity_catalog_v2 = 1,
2386+
warehouse = 'my_catalog',
2387+
catalog_credential = '<client_id>:<client_secret>';
2388+
SHOW TABLES FROM database_name;
2389+
SELECT count() FROM database_name.`schema_name.table_name`;
2390+
```
2391+
`catalog_credential` accepts an OAuth service principal as `<client_id>:<client_secret>`,
2392+
a personal access token (`dapi...`), or a pre-obtained OAuth token.
23282393
* [Glue Catalog](/guides/use-cases/data-warehousing/glue-catalog)
23292394
* OneLake Catalog
23302395
Can be used by enabling `allow_experimental_database_iceberg` or `allow_database_iceberg`.

‎src/Databases/DataLake/DatabaseDataLake.h‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ class DatabaseDataLake final : public IDatabase, WithContext
147147

148148
StorageObjectStorageConfigurationPtr getConfiguration(
149149
DatabaseDataLakeStorageType type,
150-
DataLakeStorageSettingsPtr storage_settings) const;
150+
DataLakeStorageSettingsPtr storage_settings,
151+
DataLake::DataLakeTableFormat table_format) const;
151152

152153
std::string getStorageEndpointForTable(const DataLake::TableMetadata & table_metadata) const;
153154

‎src/Databases/DataLake/DatabaseDataLakeSettings.cpp‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ namespace ErrorCodes
2020
#define DATABASE_ICEBERG_RELATED_SETTINGS(DECLARE, ALIAS) \
2121
DECLARE(DatabaseDataLakeCatalogType, catalog_type, DatabaseDataLakeCatalogType::NONE, "Catalog type", 0) \
2222
DECLARE(String, catalog_credential, "", "", 0) \
23+
DECLARE(Bool, use_unity_catalog_v2, false, "Use the new Unity catalog implementation, which serves both Delta Lake and Iceberg tables. Only for catalog_type = 'unity'", 0) \
2324
DECLARE(Bool, vended_credentials, true, "Use vended credentials (storage credentials) from catalog", 0) \
2425
DECLARE(UInt64, vended_credentials_cache_ttl, 300, "Maximum cache entry lifetime (in seconds) for vended credentials. '0' disables caching.", 0) \
2526
DECLARE(String, auth_scope, "PRINCIPAL_ROLE:ALL", "Authorization scope for client credentials or token exchange", 0) \
@@ -121,13 +122,23 @@ SettingsChanges DatabaseDataLakeSettings::allChanged() const
121122
return changes;
122123
}
123124

124-
const String & DatabaseDataLakeSettings::getSettingName(DatabaseDataLakeSettingsString setting)
125+
static const String & getSettingNameByOffset(size_t offset)
125126
{
126127
const auto & accessor = DatabaseDataLakeSettingsTraits::Accessor::instance();
127-
const size_t index = accessor.findByOffset(setting.offset);
128+
const size_t index = accessor.findByOffset(offset);
128129
if (index == static_cast<size_t>(-1))
129130
throw Exception(ErrorCodes::LOGICAL_ERROR, "Unknown database DataLake setting");
130131
return accessor.getName(index);
131132
}
132133

134+
const String & DatabaseDataLakeSettings::getSettingName(DatabaseDataLakeSettingsString setting)
135+
{
136+
return getSettingNameByOffset(setting.offset);
137+
}
138+
139+
const String & DatabaseDataLakeSettings::getSettingName(DatabaseDataLakeSettingsBool setting)
140+
{
141+
return getSettingNameByOffset(setting.offset);
142+
}
143+
133144
}

‎src/Databases/DataLake/DatabaseDataLakeSettings.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ struct DatabaseDataLakeSettings
7575
/// Name of the setting referenced by its `DatabaseDataLakeSetting::*` index,
7676
/// so catalog code can match `SettingsChanges` entries without magic strings.
7777
static const String & getSettingName(DatabaseDataLakeSettingsString setting);
78+
static const String & getSettingName(DatabaseDataLakeSettingsBool setting);
7879

7980
private:
8081
std::unique_ptr<DatabaseDataLakeSettingsImpl> impl;

‎src/Databases/DataLake/GlueCatalog.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,7 @@ void GlueCatalog::setCredentials(const AuthenticatedClient & client, TableMetada
786786
}
787787

788788
ICatalog::CredentialsRefreshCallback GlueCatalog::getCredentialsConfigurationCallback(
789-
const DB::StorageID & storage_id, const DB::ForwardedAuthTokenPtr & auth_token)
789+
const DB::StorageID & storage_id, const TableMetadata & /*table_metadata*/, const DB::ForwardedAuthTokenPtr & auth_token)
790790
{
791791
/// The AWS SDK credentials provider chain (instance profile, STS assume-role,
792792
/// web-identity, etc.) refreshes its cached credentials internally before

‎src/Databases/DataLake/GlueCatalog.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ class GlueCatalog final : public ICatalog, private DB::WithContext
109109
/// fails with `ExpiredToken`, so that a long-running read can recover without
110110
/// the user having to restart the query.
111111
ICatalog::CredentialsRefreshCallback getCredentialsConfigurationCallback(
112-
const DB::StorageID & storage_id, const DB::ForwardedAuthTokenPtr & auth_token) override;
112+
const DB::StorageID & storage_id, const TableMetadata & table_metadata, const DB::ForwardedAuthTokenPtr & auth_token) override;
113113

114114
/// Resolves the precise Iceberg timestamp type for `column_name` by searching the current schema
115115
/// in the Iceberg `metadata_object`. Falls back to `"timestamp_ns"` when `glue_column_type` is

‎src/Databases/DataLake/HTTPBasedCatalogUtils.cpp‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
#include <Core/Types.h>
66
#include <Common/FailPoint.h>
77
#include <Common/HTTPHeaderFilter.h>
8+
#include <Common/RemoteHostFilter.h>
9+
#include <IO/ConnectionTimeouts.h>
10+
#include <Poco/StreamCopier.h>
11+
#include <Poco/Net/HTTPRequest.h>
812

913
namespace DB::ErrorCodes
1014
{
@@ -101,5 +105,54 @@ std::pair<Poco::Dynamic::Var, std::string> makeHTTPRequestAndReadJSON(
101105
}
102106
}
103107

108+
AccessToken requestOAuthToken(const DB::ContextPtr & context, const Poco::URI & url, const std::string & body)
109+
{
110+
context->getRemoteHostFilter().checkHostAndPort(url.getHost(), std::to_string(url.getPort()));
111+
auto timeouts = DB::ConnectionTimeouts::getHTTPTimeouts(context->getSettingsRef(), context->getServerSettings());
112+
auto session = makeHTTPSession(DB::HTTPConnectionGroupType::HTTP, url, timeouts, {});
113+
114+
Poco::Net::HTTPRequest request(Poco::Net::HTTPRequest::HTTP_POST, url.getPathAndQuery(), Poco::Net::HTTPMessage::HTTP_1_1);
115+
request.setContentType("application/x-www-form-urlencoded");
116+
request.setContentLength(body.size());
117+
request.set("Accept", "application/json");
118+
119+
session->sendRequest(request) << body;
120+
121+
Poco::Net::HTTPResponse response;
122+
std::istream & rs = session->receiveResponse(response);
123+
124+
std::string json_str;
125+
Poco::StreamCopier::copyToString(rs, json_str);
126+
127+
/// The body of a failed response is an OAuth error object, safe to show.
128+
if (response.getStatus() != Poco::Net::HTTPResponse::HTTP_OK)
129+
throw DB::Exception(
130+
DB::ErrorCodes::DATALAKE_DATABASE_ERROR,
131+
"OAuth token request failed with status {} ({}): {}",
132+
static_cast<int>(response.getStatus()), response.getReason(), json_str);
133+
134+
Poco::JSON::Parser parser;
135+
Poco::Dynamic::Var res_json = parser.parse(json_str);
136+
const Poco::JSON::Object::Ptr & object = res_json.extract<Poco::JSON::Object::Ptr>();
137+
138+
if (!object->has("access_token"))
139+
throw DB::Exception(
140+
DB::ErrorCodes::DATALAKE_DATABASE_ERROR,
141+
"OAuth token response has no `access_token` field: {}",
142+
json_str);
143+
144+
AccessToken token;
145+
token.token = object->getValue<String>("access_token");
146+
147+
if (object->has("expires_in"))
148+
{
149+
Int64 expires_in = object->getValue<Int64>("expires_in");
150+
/// Use 90% of the token lifetime as the validity window so that short-lived tokens
151+
/// (e.g. expires_in=300) still get a sensible buffer instead of going non-positive.
152+
token.expires_at = std::chrono::system_clock::now() + std::chrono::seconds(expires_in * 9 / 10);
153+
}
154+
155+
return token;
156+
}
104157

105158
}

0 commit comments

Comments
 (0)