diff --git a/docs/postgresql-command-timeout.md b/docs/postgresql-command-timeout.md new file mode 100644 index 0000000000..c9c6467c5a --- /dev/null +++ b/docs/postgresql-command-timeout.md @@ -0,0 +1,80 @@ +# PostgreSQL Command Timeout Configuration + +This document describes how to configure command timeout for PostgreSQL data sources in Data API Builder. + +## Overview + +Data API Builder now supports configuring PostgreSQL command timeout through the `command-timeout` option in the data source configuration. This feature allows you to override the default command timeout for all PostgreSQL queries executed by Data API Builder. + +## Configuration + +Add the `command-timeout` option to your PostgreSQL data source configuration: + +```json +{ + "data-source": { + "database-type": "postgresql", + "connection-string": "Host=localhost;Database=mydb;Username=user;Password=pass;", + "options": { + "command-timeout": 60 + } + } +} +``` + +### Parameters + +- **command-timeout**: Integer value representing the timeout in seconds + - **Type**: `integer` + - **Minimum**: `0` + - **Default**: `30` (if not specified) + - **Description**: Sets the wait time (in seconds) before terminating the attempt to execute a command and generating an error + +### Behavior + +1. **Override**: The `command-timeout` value from the configuration will override any `CommandTimeout` parameter present in the connection string +2. **Precedence**: Configuration file setting takes priority over connection string setting +3. **Scope**: Applies to all PostgreSQL queries executed through Data API Builder + +### Example + +```json +{ + "$schema": "schemas/dab.draft.schema.json", + "data-source": { + "database-type": "postgresql", + "connection-string": "Host=localhost;Database=bookstore;Username=postgres;Password=password;", + "options": { + "command-timeout": 120 + } + }, + "runtime": { + "rest": { "enabled": true, "path": "/api" }, + "graphql": { "enabled": true, "path": "/graphql" } + }, + "entities": { + "Book": { + "source": { "object": "books", "type": "table" }, + "permissions": [ + { "role": "anonymous", "actions": [{ "action": "*" }] } + ] + } + } +} +``` + +In this example, all PostgreSQL queries will have a 120-second timeout, regardless of any `CommandTimeout` value in the connection string. + +## Implementation Details + +The feature is implemented by: + +1. **Schema Validation**: The JSON schema validates the `command-timeout` parameter +2. **Options Parsing**: The `PostgreSqlOptions` class parses the timeout value from various data types (integer, string, JsonElement) +3. **Connection String Processing**: The timeout is applied to the Npgsql connection string builder during connection string normalization +4. **Override Logic**: Configuration values take precedence over existing connection string parameters + +## Related + +- See `samples/postgresql-command-timeout-example.json` for a complete working example +- For other database types, command timeout can be configured directly in the connection string \ No newline at end of file diff --git a/samples/connection-string-timeout-example.json b/samples/connection-string-timeout-example.json new file mode 100644 index 0000000000..7d28f229b9 --- /dev/null +++ b/samples/connection-string-timeout-example.json @@ -0,0 +1,56 @@ +{ + "$schema": "../schemas/dab.draft.schema.json", + "data-source": { + "database-type": "postgresql", + "connection-string": "Host=localhost;Port=5432;Username=postgres;Password=password;Database=bookshelf;CommandTimeout=120" + }, + "runtime": { + "rest": { + "enabled": true, + "path": "/api" + }, + "graphql": { + "enabled": true, + "path": "/graphql", + "allow-introspection": true + }, + "host": { + "cors": { + "origins": ["*"], + "allow-credentials": false + }, + "authentication": { + "provider": "StaticWebApps" + }, + "mode": "development" + } + }, + "entities": { + "Book": { + "source": { + "object": "books", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Book", + "plural": "Books" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/samples/postgresql-command-timeout-example.json b/samples/postgresql-command-timeout-example.json new file mode 100644 index 0000000000..d64e3d642c --- /dev/null +++ b/samples/postgresql-command-timeout-example.json @@ -0,0 +1,59 @@ +{ + "$schema": "../schemas/dab.draft.schema.json", + "data-source": { + "database-type": "postgresql", + "connection-string": "Host=localhost;Database=bookstore;Username=postgres;Password=password;", + "options": { + "command-timeout": 60 + } + }, + "runtime": { + "rest": { + "enabled": true, + "path": "/api" + }, + "graphql": { + "enabled": true, + "path": "/graphql", + "allow-introspection": true + }, + "host": { + "cors": { + "origins": ["*"], + "allow-credentials": false + }, + "authentication": { + "provider": "StaticWebApps" + }, + "mode": "development" + } + }, + "entities": { + "Book": { + "source": { + "object": "books", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Book", + "plural": "Books" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index b348ac4a4f..63691daa34 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -115,6 +115,12 @@ "set-session-context": { "type": "boolean", "description": "Enable sending data to MsSql using session context" + }, + "command-timeout": { + "type": "integer", + "description": "Command timeout in seconds for database operations", + "minimum": 0, + "default": 30 } } } @@ -135,7 +141,14 @@ "options": { "type": "object", "additionalProperties": false, - "properties": {}, + "properties": { + "command-timeout": { + "type": "integer", + "description": "Command timeout in seconds for database operations", + "minimum": 0, + "default": 30 + } + }, "required": [] } } diff --git a/src/Config/ObjectModel/DataSource.cs b/src/Config/ObjectModel/DataSource.cs index d1a2456ef9..0cf9f303aa 100644 --- a/src/Config/ObjectModel/DataSource.cs +++ b/src/Config/ObjectModel/DataSource.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using System.Text.Json.Serialization; using Azure.DataApiBuilder.Config.HealthCheck; using Azure.DataApiBuilder.Config.NamingPolicies; @@ -69,6 +70,12 @@ public int DatasourceThresholdMs SetSessionContext: ReadBoolOption(namingPolicy.ConvertName(nameof(MsSqlOptions.SetSessionContext)))); } + if (typeof(TOptionType).IsAssignableFrom(typeof(PostgreSqlOptions))) + { + return (TOptionType)(object)new PostgreSqlOptions( + CommandTimeout: ReadIntOption(namingPolicy.ConvertName("command-timeout"))); + } + throw new NotSupportedException($"The type {typeof(TOptionType).FullName} is not a supported strongly typed options object"); } @@ -92,6 +99,52 @@ private bool ReadBoolOption(string option) return false; } + private int? ReadIntOption(string option) + { + if (Options is not null && Options.TryGetValue(option, out object? value)) + { + if (value is int intValue) + { + return intValue; + } + else if (value is string stringValue && int.TryParse(stringValue, out int parsedValue)) + { + return parsedValue; + } + else if (value is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out int jsonValue)) + { + return jsonValue; + } + } + + return null; + } + + /// + /// Gets the command timeout value from the options. + /// + /// The command timeout in seconds, or 30 (default) if not specified. + public int GetCommandTimeout() + { + if (Options is not null && Options.TryGetValue("command-timeout", out object? value)) + { + if (value is int intValue) + { + return intValue; + } + else if (value is long longValue && longValue <= int.MaxValue && longValue >= int.MinValue) + { + return (int)longValue; + } + else if (value is string stringValue && int.TryParse(stringValue, out int parsedValue)) + { + return parsedValue; + } + } + + return 30; // default command timeout + } + [JsonIgnore] public string DatabaseTypeNotSupportedMessage => $"The provided database-type value: {DatabaseType} is currently not supported. Please check the configuration file."; } @@ -111,3 +164,8 @@ public record CosmosDbNoSQLDataSourceOptions(string? Database, string? Container /// Options for MsSql database. /// public record MsSqlOptions(bool SetSessionContext = true) : IDataSourceOptions; + +/// +/// Options for PostgreSQL database. +/// +public record PostgreSqlOptions(int? CommandTimeout = null) : IDataSourceOptions; diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index f78c32ebc1..1935d77262 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -186,7 +186,7 @@ public static bool TryParseConfig(string json, } else if (ds.DatabaseType is DatabaseType.PostgreSQL && replaceEnvVar) { - updatedConnection = GetPgSqlConnectionStringWithApplicationName(connectionValue); + updatedConnection = GetPgSqlConnectionStringWithApplicationName(connectionValue, ds); } ds = ds with { ConnectionString = updatedConnection }; @@ -331,8 +331,9 @@ internal static string GetConnectionStringWithApplicationName(string connectionS /// else add the Application Name property with DataApiBuilder Application Name based on hosted/oss platform. /// /// Connection string for connecting to database. - /// Updated connection string with `Application Name` property. - internal static string GetPgSqlConnectionStringWithApplicationName(string connectionString) + /// The data source configuration, used to get command timeout override. + /// Updated connection string with `Application Name` property and command timeout. + internal static string GetPgSqlConnectionStringWithApplicationName(string connectionString, DataSource? dataSource = null) { // If the connection string is null, empty, or whitespace, return it as is. if (string.IsNullOrWhiteSpace(connectionString)) @@ -369,6 +370,16 @@ internal static string GetPgSqlConnectionStringWithApplicationName(string connec connectionStringBuilder.ApplicationName += $",{applicationName}"; } + // Apply command timeout from data source configuration if specified (overrides connection string value) + if (dataSource?.Options is not null) + { + PostgreSqlOptions? pgOptions = dataSource.GetTypedOptions(); + if (pgOptions?.CommandTimeout is not null) + { + connectionStringBuilder.CommandTimeout = pgOptions.CommandTimeout.Value; + } + } + // Return the updated connection string. return connectionStringBuilder.ConnectionString; } diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 0be24fa886..7baea82872 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -5462,6 +5462,46 @@ public static EntityPermission GetMinimalPermissionConfig(string roleName) ); } + /// + /// Test that command timeout is properly read from data source options and applied to connection string. + /// + [TestMethod] + public void TestCommandTimeoutFromDataSourceOptions() + { + // Test SQL Server + Dictionary mssqlOptions = new() + { + { "command-timeout", 60 }, + { "set-session-context", true } + }; + DataSource mssqlDataSource = new(DatabaseType.MSSQL, "Server=localhost;Database=test;", mssqlOptions); + Assert.AreEqual(60, mssqlDataSource.GetCommandTimeout()); + + // Test PostgreSQL + Dictionary pgOptions = new() + { + { "command-timeout", 120 } + }; + DataSource pgDataSource = new(DatabaseType.PostgreSQL, "Host=localhost;Database=test;", pgOptions); + Assert.AreEqual(120, pgDataSource.GetCommandTimeout()); + + // Test MySQL + Dictionary mysqlOptions = new() + { + { "command-timeout", 45 } + }; + DataSource mysqlDataSource = new(DatabaseType.MySQL, "Server=localhost;Database=test;", mysqlOptions); + Assert.AreEqual(45, mysqlDataSource.GetCommandTimeout()); + + // Test default value when not specified + DataSource defaultDataSource = new(DatabaseType.MSSQL, "Server=localhost;Database=test;", new()); + Assert.AreEqual(30, defaultDataSource.GetCommandTimeout()); + + // Test null options + DataSource nullOptionsDataSource = new(DatabaseType.MSSQL, "Server=localhost;Database=test;", null); + Assert.AreEqual(30, nullOptionsDataSource.GetCommandTimeout()); + } + /// /// Reads configuration file for defined environment to acquire the connection string. /// CI/CD Pipelines and local environments may not have connection string set as environment variable.