diff --git a/src/main/java/io/supertokens/storage/postgresql/LockFailure.java b/src/main/java/io/supertokens/storage/postgresql/LockFailure.java new file mode 100644 index 00000000..70cfcd5f --- /dev/null +++ b/src/main/java/io/supertokens/storage/postgresql/LockFailure.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2020, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.storage.postgresql; + +public class LockFailure extends Exception { + public LockFailure() { + super("Failed to acquire advisory lock"); + } + + public LockFailure(String message) { + super(message); + } +} diff --git a/src/main/java/io/supertokens/storage/postgresql/Start.java b/src/main/java/io/supertokens/storage/postgresql/Start.java index abf6cdce..9aa2519b 100644 --- a/src/main/java/io/supertokens/storage/postgresql/Start.java +++ b/src/main/java/io/supertokens/storage/postgresql/Start.java @@ -342,7 +342,7 @@ public void initStorage(boolean shouldWait, List tenantIdentif @Override public T startTransaction(TransactionLogic logic) throws StorageTransactionLogicException, StorageQueryException { - return startTransaction(logic, TransactionIsolationLevel.SERIALIZABLE); + return startTransaction(logic, TransactionIsolationLevel.READ_COMMITTED); } @Override @@ -384,6 +384,7 @@ public T startTransaction(TransactionLogic logic, TransactionIsolationLev // We could get here if the new logic hits a false negative, // e.g., in case someone renamed constraints/tables boolean isDeadlockException = actualException instanceof SQLTransactionRollbackException + || actualException instanceof LockFailure || exceptionMessage.toLowerCase().contains("concurrent update") || exceptionMessage.toLowerCase().contains("concurrent delete") || exceptionMessage.toLowerCase().contains("the transaction might succeed if retried") || diff --git a/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java b/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java index e289f766..5319e420 100644 --- a/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java +++ b/src/main/java/io/supertokens/storage/postgresql/config/PostgreSQLConfig.java @@ -506,6 +506,13 @@ public String getBulkImportUsersTable() { return addSchemaAndPrefixToTableName("bulk_import_users"); } + public String getRecipeUserTenantsTable() { + return addSchemaAndPrefixToTableName("recipe_user_tenants"); + } + + public String getPrimaryUserTenantsTable() { + return addSchemaAndPrefixToTableName("primary_user_tenants"); + } private String addSchemaAndPrefixToTableName(String tableName) { return addSchemaToTableName(postgresql_table_names_prefix + tableName); diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java index 679d0936..63d91196 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/GeneralQueries.java @@ -288,6 +288,57 @@ static String getQueryToCreateUserIdIndexForAppIdToUserIdTable(Start start) { + Config.getConfig(start).getAppIdToUserIdTable() + "(user_id, app_id);"; } + static String getQueryToCreateRecipeUserTenantsTable(Start start) { + String schema = Config.getConfig(start).getTableSchema(); + String tableName = Config.getConfig(start).getRecipeUserTenantsTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + tableName + " (" + + "app_id VARCHAR(64) NOT NULL," + + "recipe_user_id CHAR(36) NOT NULL," + + "tenant_id VARCHAR(64) NOT NULL," + + "recipe_id VARCHAR(128) NOT NULL," + + "account_info_type VARCHAR(8) NOT NULL," + + "account_info_value TEXT NOT NULL," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, null, "pkey") + + " PRIMARY KEY (app_id, recipe_user_id, tenant_id)," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, "tenant_id", "fkey") + + " FOREIGN KEY(app_id, tenant_id)" + + " REFERENCES " + Config.getConfig(start).getTenantsTable() + " (app_id, tenant_id) ON DELETE CASCADE" + + ");"; + // @formatter:on + } + + static String getQueryToCreatePrimaryUserTenantsTable(Start start) { + String schema = Config.getConfig(start).getTableSchema(); + String tableName = Config.getConfig(start).getPrimaryUserTenantsTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + tableName + " (" + + "app_id VARCHAR(64) NOT NULL," + + "tenant_id VARCHAR(64) NOT NULL," + + "account_info_type VARCHAR(8) NOT NULL," + + "account_info_value TEXT NOT NULL," + + "primary_user_id CHAR(36) NOT NULL," + + "CONSTRAINT " + Utils.getConstraintName(schema, tableName, null, "pkey") + + " PRIMARY KEY (app_id, tenant_id, account_info_type, account_info_value)" + + ");"; + // @formatter:on + } + + static String getQueryToCreateTenantIndexForRecipeUserTenantsTable(Start start) { + return "CREATE INDEX IF NOT EXISTS idx_recipe_user_tenants_tenant ON " + + Config.getConfig(start).getRecipeUserTenantsTable() + "(app_id, tenant_id);"; + } + + static String getQueryToCreateAccountInfoIndexForRecipeUserTenantsTable(Start start) { + return "CREATE INDEX IF NOT EXISTS idx_recipe_user_tenants_account_info ON " + + Config.getConfig(start).getRecipeUserTenantsTable() + "(app_id, tenant_id, account_info_type, account_info_value);"; + } + + static String getQueryToCreatePrimaryUserIndexForPrimaryUserTenantsTable(Start start) { + return "CREATE INDEX IF NOT EXISTS idx_primary_user_tenants_primary ON " + + Config.getConfig(start).getPrimaryUserTenantsTable() + "(app_id, primary_user_id);"; + } + public static void createTablesIfNotExists(Start start, Connection con) throws SQLException, StorageQueryException { int numberOfRetries = 0; boolean retry = true; @@ -724,6 +775,23 @@ public static void createTablesIfNotExists(Start start, Connection con) throws S update(con, SAMLQueries.getQueryToCreateSAMLClaimsExpiresAtIndex(start), NO_OP_SETTER); } + if (!doesTableExists(start, con, Config.getConfig(start).getRecipeUserTenantsTable())) { + getInstance(start).addState(CREATING_NEW_TABLE, null); + update(con, getQueryToCreateRecipeUserTenantsTable(start), NO_OP_SETTER); + + // indexes + update(con, getQueryToCreateTenantIndexForRecipeUserTenantsTable(start), NO_OP_SETTER); + update(con, getQueryToCreateAccountInfoIndexForRecipeUserTenantsTable(start), NO_OP_SETTER); + } + + if (!doesTableExists(start, con, Config.getConfig(start).getPrimaryUserTenantsTable())) { + getInstance(start).addState(CREATING_NEW_TABLE, null); + update(con, getQueryToCreatePrimaryUserTenantsTable(start), NO_OP_SETTER); + + // indexes + update(con, getQueryToCreatePrimaryUserIndexForPrimaryUserTenantsTable(start), NO_OP_SETTER); + } + } catch (Exception e) { if (e.getMessage().contains("schema") && e.getMessage().contains("does not exist") && numberOfRetries < 1) { @@ -875,8 +943,12 @@ public static KeyValueInfo getKeyValue(Start start, TenantIdentifier tenantIdent public static KeyValueInfo getKeyValue_Transaction(Start start, Connection con, TenantIdentifier tenantIdentifier, String key) throws SQLException, StorageQueryException { + + io.supertokens.storage.postgresql.queries.Utils.takeAdvisoryLock( + con, tenantIdentifier.getAppId() + "~" + tenantIdentifier.getTenantId() + "~" + key); + String QUERY = "SELECT value, created_at_time FROM " + getConfig(start).getKeyValueTable() - + " WHERE app_id = ? AND tenant_id = ? AND name = ? FOR UPDATE"; + + " WHERE app_id = ? AND tenant_id = ? AND name = ?"; return execute(con, QUERY, pst -> { pst.setString(1, tenantIdentifier.getAppId()); diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/MultitenancyQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/MultitenancyQueries.java index b0c7ffe1..f18eadd0 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/MultitenancyQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/MultitenancyQueries.java @@ -243,6 +243,10 @@ public static void overwriteTenantConfig(Start start, TenantConfig tenantConfig) Connection sqlCon = (Connection) con.getConnection(); { try { + { + io.supertokens.storage.postgresql.queries.Utils.takeAdvisoryLock( + sqlCon, tenantConfig.tenantIdentifier.getConnectionUriDomain() + "~" + tenantConfig.tenantIdentifier.getAppId() + "~" + tenantConfig.tenantIdentifier.getTenantId()); + } { String QUERY = "DELETE FROM " + getConfig(start).getTenantConfigsTable() + " WHERE connection_uri_domain = ? AND app_id = ? AND tenant_id = ?;"; diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java b/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java index b620d884..3900991e 100644 --- a/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java +++ b/src/main/java/io/supertokens/storage/postgresql/queries/UserMetadataQueries.java @@ -16,25 +16,27 @@ package io.supertokens.storage.postgresql.queries; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import com.google.gson.JsonObject; import com.google.gson.JsonParser; + import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.storage.postgresql.PreparedStatementValueSetter; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.executeBatch; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.update; import io.supertokens.storage.postgresql.Start; import io.supertokens.storage.postgresql.config.Config; -import io.supertokens.storage.postgresql.utils.Utils; - -import java.sql.Connection; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static io.supertokens.storage.postgresql.QueryExecutorTemplate.*; import static io.supertokens.storage.postgresql.config.Config.getConfig; +import io.supertokens.storage.postgresql.utils.Utils; public class UserMetadataQueries { @@ -121,6 +123,7 @@ public static void setMultipleUsersMetadatas_Transaction(Start start, Connection public static JsonObject getUserMetadata_Transaction(Start start, Connection con, AppIdentifier appIdentifier, String userId) throws SQLException, StorageQueryException { + io.supertokens.storage.postgresql.queries.Utils.takeAdvisoryLock(con, appIdentifier.getAppId() + "~" + userId); String QUERY = "SELECT user_metadata FROM " + getConfig(start).getUserMetadataTable() + " WHERE app_id = ? AND user_id = ? FOR UPDATE"; return execute(con, QUERY, pst -> { diff --git a/src/main/java/io/supertokens/storage/postgresql/queries/Utils.java b/src/main/java/io/supertokens/storage/postgresql/queries/Utils.java new file mode 100644 index 00000000..6c343a79 --- /dev/null +++ b/src/main/java/io/supertokens/storage/postgresql/queries/Utils.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2020, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.storage.postgresql.queries; + +import java.sql.Connection; +import java.sql.SQLException; + +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.storage.postgresql.LockFailure; +import static io.supertokens.storage.postgresql.QueryExecutorTemplate.execute; + +public class Utils { + + /** + * Acquires a PostgreSQL advisory lock using two string keys. + * Uses pg_try_advisory_xact_lock which is transaction-scoped (automatically released on commit/rollback). + * + * @param con The database connection (must be within a transaction) + * @param key Key for the lock (e.g., appId) + * @throws SQLException If a database error occurs + * @throws StorageQueryException If a query error occurs + * @throws LockFailure If the lock could not be acquired + */ + public static void takeAdvisoryLock(Connection con, String key) + throws SQLException, StorageQueryException { + String LOCK_QUERY = "SELECT pg_try_advisory_xact_lock(hashtext(?))"; + boolean lockAcquired = execute(con, LOCK_QUERY, pst -> { + pst.setString(1, key); + }, result -> { + if (result.next()) { + return result.getBoolean(1); + } + return false; + }); + if (!lockAcquired) { + throw new StorageQueryException(new LockFailure()); + } + } +}