New Catalog Configuration via CREATE SERVER#242
Conversation
| extern PGDLLEXPORT bool HasRestCatalogTableOption(List *options); | ||
| extern PGDLLEXPORT bool HasObjectStoreCatalogTableOption(List *options); | ||
| extern PGDLLEXPORT bool HasReadOnlyOption(List *options); | ||
| extern PGDLLEXPORT bool IsServerBasedRestCatalog(List *options); |
There was a problem hiding this comment.
that sounds a bit weird. maybe IsRestCatalogOwnedByExtension and IsRestCatalogOwnedByUsers
There was a problem hiding this comment.
First I renamed to IsRestCatalogOwnedByUsers but then I realized we don't really need the distinction so I changed it to IsRestCatalog. Details in the last commit of this PR
| if (pg_strncasecmp(catalog, REST_CATALOG_NAME, strlen(REST_CATALOG_NAME)) == 0 || | ||
| pg_strncasecmp(catalog, OBJECT_STORE_CATALOG_NAME, strlen(OBJECT_STORE_CATALOG_NAME)) == 0 || | ||
| pg_strncasecmp(catalog, POSTGRES_CATALOG_NAME, strlen(POSTGRES_CATALOG_NAME)) == 0) |
There was a problem hiding this comment.
maybe IsCatalogOwnedByExtension, that catches all our catalogs
| if (server->servertype != NULL && *server->servertype != '\0') | ||
| return pg_strncasecmp(server->servertype, "rest", strlen("rest")) == 0; | ||
|
|
||
| /* No TYPE specified, assume rest */ |
There was a problem hiding this comment.
should we disallow that?
There was a problem hiding this comment.
we can allow since 'rest' is the only type that is allowed for users for now
There was a problem hiding this comment.
+1 for disallowing that, perhaps on create server, and we can assert here that TYPE cannot be NULL or empty.
| static char *RestCatalogAccessToken = NULL; | ||
| static TimestampTz RestCatalogAccessTokenExpiry = 0; | ||
| * Per-server token cache. Keyed by server name (for server-based catalogs) | ||
| * or "GUC" (for GUC-based backward-compatible catalog='rest'). |
There was a problem hiding this comment.
instead of GUC, cant we still have server name for our catalogs?
| conn->host = defGetString(def); | ||
| else if (strcmp(def->defname, "client_id") == 0) | ||
| conn->clientId = defGetString(def); | ||
| else if (strcmp(def->defname, "client_secret") == 0) |
There was a problem hiding this comment.
is this user mapping or server option?
There was a problem hiding this comment.
User mapping. Here only for the sake of separate server infrastructure.
| if (pg_strncasecmp(catalogOptionValue, REST_CATALOG_NAME, strlen(catalogOptionValue)) == 0) | ||
| conn = GetRestCatalogConnectionFromGUCs(); | ||
| else | ||
| conn = GetRestCatalogConnectionFromServer(catalogOptionValue); |
There was a problem hiding this comment.
a wrapper seems useful
There was a problem hiding this comment.
Since both extension-owned and user-created REST type catalog servers fallback to GUC, there is no need for a distinction here with if-else, as I changed in my last commit.
|
|
||
| metadataLocation = | ||
| GetMetadataLocationFromRestCatalog(catalogName, catalogNamespace, catalogTableName); | ||
| GetMetadataLocationFromRestCatalog(conn, catalogName, catalogNamespace, catalogTableName); |
There was a problem hiding this comment.
we might fetch conn inside GetMetadataLocationFromRestCatalog and do not change its signature maybe.
There was a problem hiding this comment.
We would either need the relation ID or the server name to fetch conn from the inside, so signature change is needed.
| { | ||
| if (!requestPerTable->isValid) | ||
| { | ||
| /* |
There was a problem hiding this comment.
I guess this comment should stay.
| requestPerTable->dropTableRequest != NULL) | ||
| { | ||
| /* | ||
| * table is created and dropped in the same transaction, nothing |
There was a problem hiding this comment.
same, comment should stay
| HttpResult httpResult = | ||
| SendRequestToRestCatalog(HTTP_POST, requestPerTable->tableRestUrl, | ||
| createTableRequest->body, PostHeadersWithAuth()); | ||
| createTableRequest->body, PostHeadersWithAuth(requestPerTable->conn)); |
There was a problem hiding this comment.
we need to add tests where we modify multiple rest tables inside the same transaction to cover the changes.
14b402b to
ca9adb0
Compare
ca9adb0 to
c613b0f
Compare
sfc-gh-abozkurt
left a comment
There was a problem hiding this comment.
Good job! It seems very close to merge to me.
| extern PGDLLEXPORT RestCatalogConnectionInfo * GetRestCatalogConnectionForRelation(Oid relationId); | ||
|
|
||
| /* FDW name for iceberg_catalog servers */ | ||
| #define ICEBERG_CATALOG_FDW_NAME "iceberg_catalog" |
There was a problem hiding this comment.
nit: this can be a more generic place
| ereport(ERROR, | ||
| (errcode(ERRCODE_INVALID_PARAMETER_VALUE), | ||
| errmsg("invalid rest_auth_type option: \"%s\"", authType), | ||
| errhint("Valid values are \"default\" and \"horizon\"."))); |
There was a problem hiding this comment.
nit: does it makes sense to name default (oauth2 or such)?
| * - ALTER ... RENAME on 'postgres', 'object_store', or 'rest' is blocked. | ||
| */ | ||
| bool | ||
| ProtectExtensionCatalogServersHandler(ProcessUtilityParams *processUtilityParams, |
There was a problem hiding this comment.
maybe BlockDdlOnExtensionCatalogs
| } | ||
| else if (IsA(parsetree, AlterForeignServerStmt)) | ||
| { | ||
| AlterForeignServerStmt *stmt = (AlterForeignServerStmt *) parsetree; |
There was a problem hiding this comment.
Can you add a test tho show ALTER SERVER OWNER TO is also disabled?
There was a problem hiding this comment.
Good point, I may need to intercept AlterOwnerStmt for that
| ForeignServer *server = GetForeignServerByName(serverName, false); | ||
| ForeignDataWrapper *fdw = GetForeignDataWrapper(server->fdwid); | ||
|
|
||
| if (strcmp(fdw->fdwname, ICEBERG_CATALOG_FDW_NAME) != 0) |
There was a problem hiding this comment.
should we prevent that at CREATE FOREIGN SERVER time by hooking into it? Here we can have assertion.
There was a problem hiding this comment.
Good catch - actually by this point the server is guaranteed to be iceberg_catalog, so just an Assert is sufficient.
| if (!RestCatalogClientId || !*RestCatalogClientId) | ||
| ereport(ERROR, (errmsg("pg_lake_iceberg.rest_catalog_client_id should be set"))); | ||
| if (!conn->clientId || !*conn->clientId) | ||
| ereport(ERROR, (errmsg("REST catalog client_id is not configured"))); |
There was a problem hiding this comment.
hint could be alter server option or GUC, same for others for which it applies
| * Invalidate all cached tokens so that the next request will fetch a | ||
| * fresh one. We clear the entire cache because the retry callback | ||
| * does not have access to a specific connection's info. This is safe | ||
| * because token expiry is rare and other connections will simply | ||
| * re-authenticate on their next request. |
There was a problem hiding this comment.
shouldnt we invalidate only one token that belongs to the catalog which we send request to? i.e. HASH_REMOVE
There was a problem hiding this comment.
Good point, we could pass the servername to SendRequestToRestCatalog
|
|
||
| AvroInit(); | ||
|
|
||
| RegisterUtilityStatementHandler(ProtectExtensionCatalogServersHandler, NULL); |
There was a problem hiding this comment.
hooks should exist in pg_lake_table extension. pg_lake_iceberg is only used for iceberg spec and catalog implementations and iceberg specific internal tables or views.
| */ | ||
| while (list_length(tablesWithModifications) > 0) | ||
| { | ||
| RestCatalogRequestPerTable *firstTable = |
There was a problem hiding this comment.
Just asking. Cant we still send a single request? At the first sight, I see polaris open api seems to not allow it. @sfc-gh-okalaci
There was a problem hiding this comment.
You can't send a single HTTP request for multiple hosts. The code currently creates batches based on host.
But, now that you mention it, what if we have two different catalogs, from the same host? This could be a bug. Maybe we should batch on (host, catalog_name) and not simply on (host)
| assert result[0]["srvtype"] == "postgres" | ||
|
|
||
|
|
||
| def test_precreated_object_store_server(pg_conn, extension): |
|
I think we should disable table creation with iceberg_catalog fdw as it has no handler via hooks. (we already have create table hooks) |
da7a92a to
6802bf9
Compare
baf1541 to
2c7435f
Compare
| { | ||
| HttpResult httpResult = | ||
| SendRequestToRestCatalog(HTTP_POST, requestPerTable->tableRestUrl, | ||
| createTableRequest->body, PostHeadersWithAuth()); |
There was a problem hiding this comment.
I think we should undo most of the changes here. It is extremely hard to support multiple rest catalogs involved in a tx, especially with modifications involved.
We currently do not even have a mechanism to handle failures if the request for a single REST catalog fails.
When we have multiple REST catalogs involved, the failure handling / recovery becomes extremely hard to handle. What happens if request to server A succeeds and request to server B fails?
I think if we merge this PR as-is, we'd make it much harder to handle the recovery scenarios, which we have not even handled even for a single end-point.
So, let's just fail the transaction pre-commit XACT_EVENT_PRE_COMMIT, perhaps in RecordRestCatalogRequestInTx where we throw error if you ever try to record changes to more than one rest catalog.
In general, many query engines do not even support multi-table transactions yet along multi-server :) So, we are not contradicting with any user expectations.
There was a problem hiding this comment.
Good point, I overlooked this part.
| { | ||
| for (int i = 0; iceberg_catalog_server_options[i] != NULL; i++) | ||
| { | ||
| if (strcmp(keyword, iceberg_catalog_server_options[i]) == 0) |
There was a problem hiding this comment.
should this be case insensitive? It is more common in pg_lake and in Postgres to allow case insensitive keywords.
Make sure it works in other places as well if we change it, for example some tests should use different cases
| char *authType = defGetString(def); | ||
|
|
||
| if (strcmp(authType, "oauth2") != 0 && | ||
| strcmp(authType, "default") != 0 && |
There was a problem hiding this comment.
similarly: can we allow case insensitive values? make sure if we allow it here, we check in other places similarly
| URLEncodePath(catalogName)); | ||
|
|
||
| HttpResult httpResult = SendRequestToRestCatalog(HTTP_POST, postUrl, body.data, PostHeadersWithAuth()); | ||
| HttpResult httpResult = SendRequestToRestCatalog(HTTP_POST, postUrl, body.data, |
There was a problem hiding this comment.
nit: I think we have some indentation issues in the PR, such as this one. Can you maybe go over once more regarding that? Do you use make reindent? That seems to fix some of these on my local
| const int MAX_HTTP_RETRY_FOR_REST_CATALOG = 3; | ||
|
|
||
| return SendHttpRequestWithRetry(method, url, body, headers, ShouldRetryRequestToRestCatalog, MAX_HTTP_RETRY_FOR_REST_CATALOG); | ||
| CurrentRetryServerName = serverName; |
There was a problem hiding this comment.
static variables are always painful to maintain, what if SendHttpRequestWithRetry throws an error? We are left with the old server name. I'm pretty sure that cause hard-to-understand issues/consequences.
I think what we are trying here is to pass ShouldRetryRequestToRestCatalog the server name.
Can't we change HttpRetryFn signature to pass the server name?
| VALIDATOR lake_iceberg.iceberg_catalog_validator; | ||
|
|
||
| /* Pre-created catalog servers for backward compatibility */ | ||
| CREATE SERVER postgres |
There was a problem hiding this comment.
uf, the name postgres might be used by the users, say for an postgres_fdw server.
I wonder if we should pick something harder to hit? Can you think of anything prefix/postfix for the pre-created servers?
|
|
||
| GetRestCatalogAccessToken(forceRefreshToken); | ||
| strlcpy(cacheKey, CurrentRetryServerName, TOKEN_CACHE_KEY_LEN); | ||
| hash_search(RestCatalogTokenCache, cacheKey, HASH_REMOVE, NULL); |
There was a problem hiding this comment.
hm, we remove the token from the cache, and return true, so the caller would simply re-use the existing headers to retry, which means will use the old token.
I think we should re-generate token and add here? Especially given we return true, we should do something like that.
There was a problem hiding this comment.
Good point, we can use GetRestCatalogAccessToken like the previous code was using. We wouldn't even need hash_search(..., HASH_REMOVE, ...) since there is existing forceRefreshToken logic.
Note that Claude is suggesting we had a previous bug of using the same pre-built headers after GetRestCatalogAccessToken(forceRefreshToken); So taking care of that as well.
| "rest_auth_type", | ||
| "oauth_endpoint", | ||
| "enable_vended_credentials", | ||
| "location_prefix", |
There was a problem hiding this comment.
we dont seem to be using location_prefix ? I mean, even if I provide that, we still use GetIcebergDefaultLocationPrefix when needed in rest_catalog.c?
I think if we support this, we should have tests such that we set GetIcebergDefaultLocationPrefix to return a location that's not working, create/modify/vacuum/ddl tables such that make sure we don't accidentally use it.
There was a problem hiding this comment.
Sorry about that, huge miss from my part.
There was a problem hiding this comment.
nit: I am reusing the "remove trailing slash" code - think I'll open a separate PR about that since we are also using it for stage_location and other things.
EDIT: opened and merged separate PR on this #275
| { | ||
| char *newCatalog = *newvalue; | ||
|
|
||
| if (pg_strncasecmp(newCatalog, POSTGRES_CATALOG_NAME, strlen(newCatalog)) == 0 || |
There was a problem hiding this comment.
aren't we supposed to allow use-defined catalogs?
I thought one of the most important pieces of this PR is to let users easily use/switch between the servers, and this seems like a good place to be able to do that?
Needs tests to show we allow user created rest server, but not allow say postgres_fdw servers or unrelated/non-existing ones
There was a problem hiding this comment.
Good catch! I forgot about this part.
| if (!IsIcebergCatalogServer(stmt->servername)) | ||
| return false; | ||
|
|
||
| if (pg_strcasecmp(stmt->servername, POSTGRES_CATALOG_NAME) == 0 || |
There was a problem hiding this comment.
I think postgres server name is case sensitive? https://github.com/postgres/postgres/blob/4f433025f666fa4a6209f0e847715767fb1c7ace/src/backend/foreign/foreign.c#L794
I think for server names we should use strcmp ?
ps: good idea to test two servers: CREATE SERVER "test" and CREATE SERVER "TEST" in the same db, with different options and make sure they both work fine?
There was a problem hiding this comment.
Could do that.
In that case, we would allow names like "Object_Store", so functions like HasObjectStoreCatalogTableOption should also be case sensitive.
There was a problem hiding this comment.
on second thought, I think we should keep case insensitive comparisons against POSTGRES_CATALOG_NAME, OBJECT_STORE_CATALOG_NAME and REST_CATALOG_NAME. We can't have "rest" and "REST". But we can have "test" and "TEST".
|
|
||
| /* | ||
| * Gets an access token from rest catalog. Caches the token per server | ||
| * (keyed by host + clientId) until it is about to expire. |
There was a problem hiding this comment.
I think this comment keyed by host + clientId) is stale?
There was a problem hiding this comment.
true, it's keyed by server name
| requestPerTable = (RestCatalogRequestPerTable *) lfirst(lc); | ||
|
|
||
| if (!lastRequest) | ||
| if (strcmp(requestPerTable->conn->host, batchHost) != 0 || |
There was a problem hiding this comment.
we'll remove this code, but anyway this is not safe, we can have multiple REST catalogs in the same host? So we cannot really compare the host?
If we need this kind of grouping in any other place in the code, we should do with the OID of the server instead?
There was a problem hiding this comment.
It is actually checking both host and catalog name which is unique.
if (strcmp(requestPerTable->conn->host, batchHost) != 0 ||
strcmp(requestPerTable->catalogName, catalogName) != 0)Server name/oid would also work (better since it would be a single check).
|
|
||
| MarkGUCPrefixReserved(PG_LAKE_TABLE); | ||
|
|
||
| RegisterUtilityStatementHandler(BlockDDLOnExtensionCatalogs, NULL); |
There was a problem hiding this comment.
should we register this in pg_lake_icebeg? Any reason to do it in pg_lake_table?
There was a problem hiding this comment.
5e2d2a1 to
70891ec
Compare
70891ec to
67bd64c
Compare
…pport Introduce the iceberg_catalog foreign data wrapper as a configuration framework for Iceberg catalogs. This allows defining named catalog servers via CREATE SERVER, removing the limitation of a single global REST catalog configured through GUCs. Key changes: - Add iceberg_catalog_validator for server option validation - Add RestCatalogConnectionInfo struct to unify connection parameters - Add GetRestCatalogConnectionFromGUCs/FromServer resolution functions - Refactor all REST catalog functions to accept RestCatalogConnectionInfo - Refactor token cache from single global to per-server hash table - Add extension upgrade SQL (3.2--3.3) with FDW, validator, and pre-created postgres/object_store servers Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Update IsServerBasedRestCatalog and HasRestCatalogTableOption to check whether a catalog option value refers to an iceberg_catalog foreign server. Servers without an explicit TYPE default to rest. Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Update pg_lake_iceberg_validator to accept server names as valid catalog option values (in addition to the existing postgres/object_store/rest literals). Update ProcessCreateIcebergTableFromForeignTableStmt to resolve REST catalog connection info from the named server when the catalog option refers to an iceberg_catalog server. Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Refactor transaction-level REST catalog request tracking to resolve and store a RestCatalogConnectionInfo per table. Batch commit requests are now grouped by REST catalog host, so tables using different catalog servers within the same transaction are committed to the correct endpoints independently. Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Comprehensive test suite covering: - FDW existence and pre-created postgres/object_store servers - CREATE SERVER with valid and invalid options - Foreign table creation on handler-less FDW (query-time error) - ALTER/DROP SERVER operations - Server-based catalog references in CREATE TABLE - Backward compatibility with catalog='rest'/'postgres'/'object_store' Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
1 - Add extension owned rest catalog of type 'rest'
2 - Disallow creation of further 'postgres' or 'object_store'
type catalogs unless we are creating_extension
3 - Disallow renaming/dropping extension owned catalogs.
4 - Disallow altering the options of extension-owned postgres
and object_store catalogs.
Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
- Add helper functions:
- IsRestCatalogOwnedByExtension ('rest' name)
- IsCatalogOwnedByExtension ('postgres', 'object_store', 'rest')
- Rename IsServerBasedRestCatalog to IsRestCatalogOwnedByUsers
- Use servername per server token cache
- Add tests where we modify tables from different catalogs(rest_endpoints)
in the same transaction.
- Keep some comments I accidentally removed.
Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
This means any type 'rest' server (extension or user-owned) will use GUC options as default, and change any option to the ones set on the server. This eliminates the need of the function IsRestCatalogOwnedByUsers Instead we just have IsRestCatalog function. This also eliminates the need of GetRestCatalogConnectionFromGUCs Instead we just have GetRestCatalogConnectionFromServer - this function uses GUC values as defaults for all type 'rest' servers. Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
- Block CREATE FOREIGN TABLE on iceberg_catalog servers. The iceberg_catalog FDW has no handler, so foreign tables created on it would fail at query time with "has no handler". The check is added to ErrorUnsupportedCreatePgLakeTableHandler in pg_lake_table, which already runs first for all CREATE FOREIGN TABLE statements. - Block ALTER SERVER ... OWNER TO on extension-owned catalog servers (postgres, object_store, rest). - Move ICEBERG_CATALOG_FDW_NAME from rest_catalog.h to catalog_type.h alongside the other catalog name constants, since it is referenced by both pg_lake_iceberg and pg_lake_table. - Rename rest_auth_type value "default" to "oauth2" to better describe the standard OAuth2 client_credentials grant with Basic auth. "default" value is also kept. - Rename ProtectExtensionCatalogServersHandler to BlockDDLOnExtensionCatalogs for clarity. - Move BlockDDLOnExtensionCatalogs registration from pg_lake_iceberg init to pg_lake_table init, where all other ProcessUtility hooks are registered. Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Token cache improvements: - Allocate the token cache hash table and token strings in a dedicated RestTokenCacheCtx memory context (under TopMemoryContext) instead of directly in TopMemoryContext, keeping the cache memory isolated. - On a 419 (token expired) retry, invalidate only the affected server cached token instead of clearing the entire cache. The server name is passed through SendRequestToRestCatalog and stored in a file-scoped static so the retry callback can target the right entry. Transaction commit batching fix: - Group batches by (host, catalogName) instead of host alone. The transaction commit URL includes the catalog prefix, so two servers pointing to the same host but with different catalog_name values need separate commits. Previously, all tables on the same host were batched together using only the first table catalogName. Other improvements: - Replace the ereport(ERROR) FDW name check in GetRestCatalogConnectionFromServer with an Assert, since the catalog option validator already ensures only iceberg_catalog servers are accepted. - Add errhint to credential/host error messages in FetchRestCatalogAccessToken, pointing users to both the server option and the corresponding GUC. - Add test_precreated_rest_server test. Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Block CREATE SERVER with a name that case-insensitively matches the extension-owned catalog names (postgres, object_store, rest) on the iceberg_catalog FDW. Also block ALTER SERVER ... RENAME TO a reserved name. Fix a latent bug across multiple files where pg_strncasecmp was used with a partial length, causing prefix-only matching: e.g. "rest_1" would match "rest". Replaced all instances with pg_strcasecmp for exact case-insensitive comparison. Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
REST catalog options and retry mechanism: - Rename RestCatalogConnectionInfo to RestCatalogOptions throughout - Eliminate CurrentRetryServerName static; pass opts directly through HttpRetryFn callback (void *context + List *headers) so the 419 token-expired handler can force-refresh and patch the Authorization header in-place - Fix double-free in GetRestCatalogAccessToken: null out entry fields before calling FetchRestCatalogAccessToken Transaction-scoped state: - Reject transactions that touch tables from different REST catalog servers - Replace per-table rest catalog opts deep-copy with a single PgLakeXactRestCatalogOpts static, deep-copied into TopTransactionContext on first use - This avoids syscache lookups at XACT_EVENT_COMMIT time, which are forbidden (AssertCouldGetRelation fires during TRANS_COMMIT state) Server configuration enforcement: - Require TYPE 'rest' on CREATE SERVER ... FOREIGN DATA WRAPPER iceberg_catalog (reject NULL or non-rest types) - Make FDW option names and auth type values case-insensitive (pg_strcasecmp), while keeping server names case-sensitive - Make reserved catalog name checks (postgres, object_store, rest) case-insensitive via IsCatalogOwnedByExtension - Support location_prefix server option, overriding the GUC default - Accept user-created iceberg_catalog servers in default_catalog GUC Tests: - Add test_reject_modify_different_rest_catalogs_in_single_transaction - Add test_server_location_prefix_overrides_guc - Add tests for TYPE enforcement, case-sensitive server names, and default_catalog GUC with user-created servers - Remove obsolete no-TYPE-defaults-to-rest tests Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Don't create foreign servers for the reserved catalog names in the
extension install script. These names ('postgres', 'object_store',
'rest') could already be in use in the target database (especially
'postgres'), causing the extension script to fail.
Instead, treat them as special built-in identifiers whose configuration
comes entirely from GUC settings. Only user-created catalogs (via
CREATE SERVER ... FOREIGN DATA WRAPPER iceberg_catalog) result in
actual foreign server objects.
Key changes:
- Remove CREATE SERVER postgres/object_store/rest from the upgrade SQL.
- GetRestCatalogOptionsFromCatalog now returns GUC-only opts for the
built-in 'rest' name without looking up a foreign server.
- Simplify BlockDDLOnExtensionCatalogs: only guard CREATE SERVER
(reserved names, TYPE postgres/object_store) and RENAME TO reserved
names. ALTER/DROP/OWNER on built-in names fail naturally since no
server object exists.
- Remove IsIcebergCatalogServer (no longer needed).
- Rename RestCatalogOptions.serverName to .catalog and
GetRestCatalogOptionsFromServer to GetRestCatalogOptionsFromCatalog
to reflect the new semantics.
- Update tests
Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
The catalog_name option was accepted on iceberg_catalog servers but never consumed. Now GetRestCatalogOptionsFromCatalog reads it and stores it in RestCatalogOptions.catalogName. GetRestCatalogName uses a three-level fallback for the REST API catalog prefix: table option > server option > database name. This lets a server define a default catalog_name for all its tables while still allowing per-table overrides. - Add catalogName field to RestCatalogOptions. - Populate it from the server catalog_name option in GetRestCatalogOptionsFromCatalog. - Rewrite GetRestCatalogName to check table option first, then opts->catalogName. - Writable REST catalog tables cannot use a server with catalog_name set, since for now they inherit from the database name, schema name, and table name - Add tests Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Consolidate the three per-transaction REST catalog statics (RestCatalogRequestsHash, PgLakeXactCommitContext, PgLakeXactRestCatalogOpts) into a single RestCatalogXactState struct to reduce global state and make lifetime management clearer. Merge BlockDDLOnExtensionCatalogs and RequireRestTypeForIcebergCatalogServer into a single ValidateIcebergCatalogServerDDL hook, eliminating duplicate hook guard logic for CREATE SERVER validation. Grant USAGE on iceberg_catalog FDW to lake_write so non-superusers with write permissions can create catalog servers. Additional cleanups: - Convert unreachable host check in FetchRestCatalogAccessToken to Assert - Rename FDW validator parameter from 'catalog' to 'catalogRelId' - Replace two separate override tests with a single parameterized test_server_option_overrides_guc covering rest_endpoint, client_id, client_secret, location_prefix, and catalog_name - Add tests for no-option server creation and lake_write permissions Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Harden catalog server security, configuration, and cross-catalog safety Refactor GetRestCatalogOptionsFromCatalog into a clean dispatcher (ResolveRestCatalogOptions) with two explicit builders: BuildRestCatalogOptionsFromGUCs for the built-in 'rest' path and BuildRestCatalogOptionsFromServer for user-created servers, plus shared helpers ApplyGUCDefaults, ApplyServerOptionOverrides, and ValidateRestCatalogOptions. Add CopyRestCatalogOptions for safe deep-copy into a target memory context. Unify the three separate server option lists (whitelist array, errhint string, applier chain) into a single IcebergCatalogOptionDesc descriptor table with type, offsetof, and validation flags. Adding or removing an option is now a one-line change. Add DDL-time value validation: reject empty strings for client_id, client_secret, scope, catalog_name; require a URI scheme for rest_endpoint, oauth_endpoint, location_prefix. Require ACL_USAGE on user-created iceberg_catalog servers at CREATE TABLE time, matching core's CREATE FOREIGN TABLE semantics. Record a DEPENDENCY_NORMAL from iceberg tables to their catalog server in pg_depend so DROP SERVER is blocked (and CASCADE drops them). Block ALTER SERVER RENAME for iceberg_catalog servers since dependent tables store the server name as a string in ftoptions. Block ALTER SERVER SET rest_endpoint when dependent writable tables exist to prevent silently redirecting them to a different REST catalog. Make GetRestCatalogName always return get_database_name(MyDatabaseId) for writable tables so ALTER SERVER ADD catalog_name cannot re-route an existing table to a different namespace. Fix token cache hash key regression: zero the key buffer with MemSet before strlcpy in BuildTokenCacheKey. Add syscache invalidation callback on FOREIGNSERVEROID to reset the token cache on ALTER/DROP SERVER, using CacheMemoryContext as parent. Add NULL guard on opts in GetRestCatalogAccessToken. Fix default_catalog GUC check hook to accept values outside a transaction (ALTER SYSTEM + pg_reload_conf path), mirroring how PostgreSQL handles check_default_tablespace. Introduce ValidateXactRestCatalog as a fail-fast guard that checks cross-catalog DML at statement time rather than at XACT_EVENT_PRE_COMMIT. Planted in postgresBeginForeignModify and AddQueryResultToTable. The existing pre-commit check is retained as a belt-and-suspenders fallback. Parametrize test_writable_rest_iceberg_table over built-in 'rest' and user-created server paths. Add tests for USAGE enforcement, dependency tracking, server rename blocking, rest_endpoint blocking, catalog_name re-routing, token cache invalidation, ALTER SYSTEM deferred validation, option value validation, multi-table same-server transactions, and cross-catalog rejection cleanup. Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
…ursor's hands Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Fail fast at statement time on cross-catalog DML. Rename ValidateXactRestCatalog to BindRelationToXactRestCatalog and call it from both FDW write paths (postgresBeginForeignModify and AddQueryResultToTable). The function now binds the transaction to the relation's REST catalog on the first DML and rejects any subsequent statement targeting a different catalog, so the second INSERT errors out before any Parquet is written. The pre-commit hook is kept as a belt-and-suspenders fallback for DDL paths that reach the tracker without going through the new guard. The regression test asserts the second INSERT (not COMMIT) raises. Move ValidateIcebergCatalogServerDDL registration from pg_lake_table to pg_lake_iceberg, where the handler is actually defined. Architecturally this puts ownership of catalog-server DDL validation in the extension that owns the catalog server abstraction. Fix latent dangling-pointer in GetValidCatalogOptionsHint. The static hint cache was allocated via initStringInfo in whatever short-lived context the validator happened to be running in (typically MessageContext), so the second invalid-option failure in the same backend returned freed memory to errhint. Allocate in TopMemoryContext so the cache survives transaction boundaries. The strengthened test_reject_unknown_server_option now issues two failing CREATE SERVER statements on the same connection and asserts the full option list appears in the hint on both attempts; the previous version would not have caught this bug. Also covers earlier review-driven hardening on the same branch: token cache invalidation on ALTER SERVER credentials, ALTER SERVER rest_endpoint blocking for all dependent REST iceberg tables (writable and read-only), and DROP OWNED BY / concurrent DROP SERVER dependency tests. Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
The three reserved short catalog names ('postgres', 'object_store',
'rest') previously had no backing pg_foreign_server row, which split
the resolution path in two (BuildRestCatalogOptionsFromGUCs for the
built-in REST, BuildRestCatalogOptionsFromServer for user-created
ones) and prevented us from recording pg_depend edges for tables that
use the short names.
Pre-create three iceberg_catalog servers at extension upgrade time:
pg_lake_postgres_catalog, pg_lake_object_store_catalog, and
pg_lake_rest_catalog. The prefixed long names cannot collide with
names users may already have, notably the very common
'CREATE SERVER postgres FOREIGN DATA WRAPPER postgres_fdw'.
User-facing DDL stays identical: users keep writing
WITH (catalog='rest' / 'postgres' / 'object_store'). A new
ResolveCatalogServerName helper maps short -> long at server lookup
time, and opts->catalog continues to carry the short user-facing name
so error messages, the cross-catalog DML guard, and the token cache
key never expose the long names.
The built-in servers are pure structural anchors: ALTER OPTIONS,
ALTER OWNER, RENAME, and DROP are all blocked. Configuration for
the built-in REST catalog continues to live in GUCs. The resolution
path collapses to a single BuildRestCatalogOptionsFromServer that
applies GUC defaults first and then any server options (always empty
for the built-ins), so the built-in REST and user-created REST go
through one code path.
The long names are rejected as user-facing catalog= values by
IsRestCatalog, the default_catalog GUC check hook, and create_table.c
with a clear hint pointing to the short form.
Tables created with the short reserved names now record a pg_depend
edge against the corresponding built-in server, so DROP EXTENSION
CASCADE cleans them up via the standard dependency walker.
ValidateIcebergCatalogServerDDL now also rejects:
- CREATE SERVER with one of the internal long names
- ALTER SERVER ... RENAME TO one of the internal long names
- ALTER SERVER OPTIONS on any built-in server (immutable anchors)
- ALTER SERVER ... OWNER TO on any built-in server
Two small static helpers (RejectIfBuiltinCatalogServerName and
RejectIfModifyingBuiltinCatalogServer) collapse the repeating
"is built-in" + ereport patterns into single calls.
Upgrade safety: the --3.3--3.4 script does a pre-flight check that
errors with a clear hint if any of the three long names already
exists in the target database, so ALTER EXTENSION UPDATE fails
loudly rather than partway through.
Tests: 23 new cases in test_iceberg_catalog_server.py covering the
three servers exist and are extension-owned, CREATE / RENAME-TO /
ALTER OPTIONS / ALTER OWNER / DROP on the long names all blocked,
catalog= with a long name rejected at CREATE TABLE, the pg_depend
edge for the built-in postgres catalog, and -- the original collision
concern -- a pre-existing postgres_fdw server literally named
'postgres' coexists with pg_lake_postgres_catalog.
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
Now that every REST catalog -- built-in and user-created -- is backed by a real iceberg_catalog foreign server, the server's OID is a stable, unambiguous identifier. This commit pivots the in-memory machinery off case-insensitive name comparison and onto OID equality. * RestCatalogOptions gains a leading `Oid serverOid` field, populated in BuildRestCatalogOptionsFromServer from server->serverid and propagated by CopyRestCatalogOptions. The struct comment is updated to call out that `catalog` is now purely a user-facing label kept around for error messages, and that `serverOid` is the canonical identity. * The per-catalog OAuth token cache is rekeyed from a NAMEDATALEN char buffer to sizeof(Oid). BuildTokenCacheKey is removed; the lookup site now passes &opts->serverOid directly, with a defensive Assert(OidIsValid(opts->serverOid)) so a future caller that forgets to resolve options cannot silently funnel every catalog into the same cache slot. InvalidateRestTokenCache keeps its existing full-flush behavior on any pg_foreign_server change; targeted invalidation is not worth the per-entry bookkeeping at the rate ALTER SERVER actually happens. * The cross-transaction "same REST catalog throughout" guard in BindRelationToXactRestCatalog and the belt-and-suspenders branch of RecordRestCatalogRequestInTx now compare serverOid directly instead of pg_strcasecmp'ing catalog names. The two user-facing names are still surfaced in errdetail so the message remains in the terms the user typed. This also closes the corner case where the same physical server is referenced via different casings in successive statements: they now collapse to the same OID and are treated as one catalog. Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: sfc-gh-npuka <naisila.puka@snowflake.com>
a0e5ae4 to
87dbbc1
Compare
can this be configurable? We'd likely want to write into a specific catalog name. |
Note to reviewer: Let's assume we can save client secrets as server options for now. Easier to review commit by commit as each commit has its own description. The credentials are part of USER MAPPING in this PR #255
How users can create REST catalogs
A server with no options is also valid — all settings fall back to GUCs. This allows creating a named handle for organizational purposes while relying entirely on the global GUC configuration.
Extension Upgrade Script
The upgrade script creates:
iceberg_catalogFDW — a handler-less FDW with a validator function (iceberg_catalog_validator) that accepts server-level options.GRANT USAGE ON FOREIGN DATA WRAPPER iceberg_catalog TO lake_write— so non-superusers withlake_writecan create catalog servers.The reserved catalog names
postgres,object_store, andrestare not created as foreign servers. They are built-in identifiers whose configuration comes entirely from GUC settings. Only user-created catalogs (viaCREATE SERVER ... FOREIGN DATA WRAPPER iceberg_catalog) result in actual foreign server objects.Connection Resolution
ResolveRestCatalogOptions(catalog)is the single entry point for building aRestCatalogOptions.GetRestCatalogOptionsForRelation(relationId)reads the table'scatalogoption and delegates to it.The resolution path is split into two builders plus a dispatcher:
ResolveRestCatalogOptions(catalog)— ifcatalogmatches the built-inrestname (case-insensitive viapg_strcasecmp), callBuildRestCatalogOptionsFromGUCs(); otherwise callBuildRestCatalogOptionsFromServer(catalog).BuildRestCatalogOptionsFromGUCs()— materializes options entirely from GUCs. Setsopts->catalogto the canonicalREST_CATALOG_NAMEconstant ("rest").BuildRestCatalogOptionsFromServer(serverName)— looks up the server inpg_foreign_server, initializes all fields from GUC defaults viaApplyGUCDefaults, then overrides with any options explicitly set on the server viaApplyServerOptionOverrides(driven byiceberg_catalog_option_descs[]).ValidateRestCatalogOptions— errors ifrest_endpointis still unset after both sources.catalog_nameon the server is stored inopts->catalogNamewhen present, but it is not defaulted toget_database_name()inside resolution. For read-only tables, the database-name default is applied at CREATE TABLE time: if the table does not specifycatalog_name,create_table.cinherits the server'scatalog_nameoption (if set) or defaults toget_database_name(MyDatabaseId), then bakes the result into the table'sftoptions. Writable tables ignore servercatalog_nameat runtime (see below).Per-Catalog Token Cache
The old code used a single global token (
RestCatalogAccessToken). With multiple catalogs potentially using different credentials, the token cache is now a hash table (RestCatalogTokenCache) keyed by catalog name. Each catalog gets its own cached OAuth token with independent expiry tracking.The hash table and token strings are allocated in a dedicated
RestTokenCacheCtxmemory context (underCacheMemoryContext), keeping the cache memory isolated.BuildTokenCacheKeyusesopts->catalogas the cache key (zeroed toTOKEN_CACHE_KEY_LENto avoid hash collisions on short names).Invalidation on server DDL: a syscache invalidation callback (
InvalidateRestTokenCache) is registered once per backend viaCacheRegisterSyscacheCallback(FOREIGNSERVEROID, ...). AnyALTER SERVERorDROP SERVERresets the entire token cache (MemoryContextReset(RestTokenCacheCtx)) so stale credentials are never reused in open sessions. The cache is rebuilt lazily on the next token lookup.TokenCacheCallbackRegisteredensures the callback is registered exactly once (the syscache callback array is fixed-size).SendRequestToRestCatalogimplements its own retry loop (up to 3 retries) with aClassifyRestCatalogRequestRetryclassifier that determines whether to back off, refresh auth, or stop. When a 419 (token expired) response is received, the retry logic force-refreshes the token viaGetRestCatalogAccessToken(opts, true)and patches theAuthorizationheader in-place viaUpdateAuthorizationHeader. Theoptsparameter is passed directly toSendRequestToRestCatalog, avoiding any global state. Passopts = NULLfor the token-fetch request itself to avoid recursion.Single-Catalog Transaction Constraint
Modifying tables from different REST catalogs in the same transaction is rejected at statement time by
BindRelationToXactRestCatalog(relationId), called from both FDW write entry points:postgresBeginForeignModify()— row-by-row DMLAddQueryResultToTable()— INSERT...SELECT and COPY...FROM pushdownAll per-transaction REST catalog state is grouped in a single
PgLakeXactRestCatalogContextstruct (staticPgLakeXactRestCatalog), which contains:requestsHash— hash table of per-table REST catalog requestscommitContext— pre-allocated 1MB memory context for XACT_COMMITcatalogOpts— deep-copiedRestCatalogOptionsfor the single catalogOn the first REST-backed write of the transaction,
BindRelationToXactRestCatalogpre-resolves the relation's fullRestCatalogOptionsand deep-copies them intoTopTransactionContext(PgLakeXactRestCatalog->catalogOpts). This happens before any Parquet data is written to S3. Subsequent writes in the same transaction must target the same catalog (compared withpg_strcasecmp); otherwiseERRCODE_FEATURE_NOT_SUPPORTEDis raised immediately.DDL paths (CREATE TABLE / DROP TABLE) reach the same protection indirectly via
RecordRestCatalogRequestInTx(), which is invoked synchronously at statement time from the utility hook. That function retains a belt-and-suspenders cross-catalog check for any code path that forgets to bind at statement time.At
XACT_EVENT_COMMITtime,PostAllRestCatalogRequestsuses the pre-resolvedPgLakeXactRestCatalog->catalogOptsto build URLs and authentication headers. This avoids syscache lookups during commit, which are forbidden (AssertCouldGetRelationfires duringTRANS_COMMITstate).When an iceberg table is created on a user-defined server,
RecordIcebergCatalogServerDependencyrecords aDEPENDENCY_NORMALedge from the table to the server inpg_depend. This ensuresDROP SERVERis blocked while dependent tables exist (andDROP SERVER CASCADEdrops them). Built-in catalog names (rest,postgres,object_store) do not get a dependency entry because they are not backed by a user-managedpg_foreign_serverrow.DDL Protection (
ValidateIcebergCatalogServerDDL)A single
ProcessUtilityhook validates all DDL oniceberg_catalogservers. The handler is registered inpg_lake_iceberg/src/init.c(where the function is defined), not inpg_lake_table.CREATE SERVERwith reserved name (postgres,object_store,rest, case-insensitive)CREATE SERVERwith TYPE'postgres'or'object_store'CREATE SERVERwithout TYPE'rest'(NULL or non-rest)CREATE SERVERwith TYPE'rest'ALTER SERVER ... RENAME TOany reserved nameALTER SERVER ... RENAME TOany non-reserved name on aniceberg_catalogservercatalog='<name>'inftoptions)ALTER SERVER OPTIONS (SET/ADD rest_endpoint ...)when dependent REST iceberg tables existServerHasDependentRestIcebergTable)ALTER/DROP/OWNERon reserved namesAll protection checks are skipped during
CREATE EXTENSION/ALTER EXTENSION.CREATE FOREIGN TABLEon anyiceberg_catalogserver is also blocked since the FDW has no handler. The error hints users to useCREATE TABLE ... USING iceberginstead.Catalog Type Helpers (
pg_lake_engine/src/utils/catalog_type.c)IsRestCatalog(catalog)— unified check that returns true for the literal'rest'(case-insensitive) or anyiceberg_catalogserver whose TYPE is'rest'. Asserts that the server type is non-null and non-empty when a server object exists. Used by the GUC check hook fordefault_catalogto accept user-created server names.IsCatalogOwnedByExtension(catalog)— returns true for the literals'rest','object_store', or'postgres'(all case-insensitive viapg_strcasecmp). Used by the DDL protection handler and dependency recording.Option Validation
Server options are defined once in
iceberg_catalog_option_descs[]— the single source of truth for the whitelist (FindCatalogOptionDesc), the user-facing hint (GetValidCatalogOptionsHint), and the option-to-struct applier (ApplyCatalogOptionValue). Adding or removing an option requires changing only this table.FDW option names and auth type values are compared case-insensitively (
pg_strcasecmp). Server names remain case-sensitive (PostgreSQL'sGetForeignServerByNameis case-sensitive), but the reserved built-in names are checked case-insensitively.At CREATE/ALTER SERVER time,
ValidateCatalogOptionValueenforces:rest_endpoint,oauth_endpoint,location_prefix: non-empty and must contain://(URI scheme required)catalog_name,client_id,client_secret,scope: non-empty when presentrest_auth_type: must be'oauth2','default'(alias for oauth2), or'horizon'enable_vended_credentials: valid booleanThe validator hint string is built lazily from
iceberg_catalog_option_descs[]and cached inTopMemoryContextso it survives transaction aborts on the error path (a per-statement context would leave a dangling pointer on the second invalid-option failure in the same backend).catalog_nameServer OptionThe
catalog_nameoption on aniceberg_catalogserver specifies the catalog prefix used in REST API URLs (opts->catalogName).At CREATE TABLE time (read-only tables): if the table does not specify
catalog_name, it inherits the server'scatalog_nameoption (viaResolveRestCatalogOptions) or defaults toget_database_name(MyDatabaseId). The resolved value is baked into the table'sftoptions.At runtime (
GetRestCatalogName):get_database_name(MyDatabaseId). Server and tablecatalog_nameoptions are ignored. This prevents a subsequentALTER SERVER ... ADD/SET catalog_namefrom silently re-routing an existing writable table.catalog_namefromftoptions. If missing, error (should never happen after CREATE TABLE inheritance).Writable tables cannot be created on a server with
catalog_nameset — enforced at CREATE TABLE time.Accepted Server Options
The
iceberg_catalog_validatoraccepts the following server-level options (all defined iniceberg_catalog_option_descs[]):rest_endpointrest_auth_type'oauth2'(default),'default'(alias for oauth2), or'horizon'oauth_endpointscopePRINCIPAL_ROLE:ALL)enable_vended_credentialstruelocation_prefixcatalog_nameclient_idclient_secretCredentials are currently stored as server options. USER MAPPING support is planned in a follow-up PR.
Backward Compatibility
CREATE TABLE ... WITH (catalog = 'rest')continues to work. The catalog option value'rest'resolves to the built-in configuration sourced entirely from GUCs (case-insensitive:'REST','rEst', etc. all work).postgresorobject_storecatalog behavior.Fixes part of #230
Checklist