Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ This section points to sources that explain why ServiceControl is designed the w
- [Handling unavailable runtime dependencies](handling-unavailable-runtime-dependencies.md) — how instances react when a dependency is unavailable
- [Telemetry](telemetry.md) — how to configure telemetry export and which metrics instances emit
- [Throughput collection](throughput-collection.md) — how usage data is collected and why the throughput queue has a fixed name
- [SQL persister schemas](sql-persister-schemas.md) — which schema each SQL persister uses by default and why a primary and an audit instance cannot share one
- [Usage report contents](usage-report.md): every key the usage report carries, every setting it leaves out, and why

## Decisions and rationale
Expand Down
16 changes: 16 additions & 0 deletions docs/sql-persister-schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# SQL persister schemas

The SQL Server and PostgreSQL persisters keep all of an instance's tables in one schema, set by `Database/Schema`. A blank or absent setting selects a default:

| Instance | Default schema |
|---|---|
| Primary (error) | None: the connection or provider default, such as `dbo` on SQL Server and `public` on PostgreSQL |
| Audit | `sc_audit` |

* During setup, ServiceControl attempts to create a missing schema, whether selected by default or configured through `Database/Schema`. If the configured database user lacks permission to create schemas, setup fails with a database permission error. In that case, create the schema and grant the user the required rights before running setup.
* A primary and an audit instance cannot share a schema within the same database. Setup checks the target schema in the connected database before applying migrations: the primary rejects `__AuditMigrationsHistory`, and the audit instance rejects `__EFMigrationsHistory`.
* The primary instance's usage report lists `Storage.Schema` as `Default` when no schema is configured and `Custom` for any configured schema, including `sc_primary`. See [Usage report contents](usage-report.md).

ServiceControl does not rename or move existing schemas or tables. An instance that already holds data in a named schema, such as `sc_primary` or a custom schema, must set `Database/Schema` to that schema. Otherwise, the instance uses the default schema instead of the schema containing its existing data.

The primary instance records applied migrations in the `__ErrorMigrationsHistory` table of its schema, as the audit instance does with `__AuditMigrationsHistory`.
4 changes: 2 additions & 2 deletions docs/usage-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Releases up to 6.21.2 report `SqlVersion` as the full text of `@@VERSION` on SQL
| `Storage.FullTextSearch` | `Enabled`, `Disabled` | `EnableFullTextSearchOnBodies`, RavenDB only. Always `Enabled` on SQL Server and PostgreSQL, which do not read the setting | 6.20.0 |
| `Storage.BodyStorage.Type` | `RavenAttachments`, `FileSystem`, `AzureBlob`, `S3` | the persister | 6.20.0 |
| `Limits.MaxBodySizeToStore` | bytes | `MaxBodySizeToStore`, SQL Server and PostgreSQL only | 6.20.0 |
| `Storage.Schema` | `Default`, `Custom` | `Database/Schema`, SQL Server and PostgreSQL only | Unreleased |
| `Storage.Schema` | `Default` when no schema is configured, `Custom` for any configured schema | `Database/Schema`, SQL Server and PostgreSQL only | Unreleased |
| `Storage.LogLevel` | `None`, `Information`, `Operations` | `RavenDBLogLevel`, RavenDB only | Unreleased |
| `Storage.CommandTimeoutSeconds` | `Default` or number | `Database/CommandTimeout`, SQL Server and PostgreSQL only | Unreleased |
| `Storage.QueryTimeoutSeconds` | `Default` or number | `QueryTimeoutInSeconds` | Unreleased |
Expand Down Expand Up @@ -237,7 +237,7 @@ The ingestion and health keys follow the rules in [Error ingestion health](#erro
| `Audit.SameMachine` | `True`, `False`, `Mixed`, `NotApplicable`, `Unknown` | the machine name hashes. `NotApplicable` when either instance runs in a container | Unreleased |
| `Audit.DatabaseSharing` | `SameSchema`, `SameDatabase`, `SameServer`, `SeparateServer`, `NotApplicable`, `Unknown` | the storage identity hashes. `NotApplicable` when the storage engines differ. Several instances report the most shared class | Unreleased |

`Audit.Storage.SizeGB` covers the whole schema. When the primary and an audit instance share a schema, both report the same size, and `Audit.DatabaseSharing` is `SameSchema`.
`Audit.Storage.SizeGB` covers the whole schema. A primary and an audit instance on SQL Server or PostgreSQL cannot share a schema within the same database, so `SameSchema` is not reported for that pairing. Using the same schema name in separate databases does not mean the instances share a schema; see [SQL persister schemas](sql-persister-schemas.md).

#### From the configuration response

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ public override bool IsDuplicateKeyException(DbUpdateException exception)
return false;
}

public override async Task<bool> SchemaExists(string schema, CancellationToken cancellationToken = default) =>
protected override async Task<bool> TableExists(string table, CancellationToken cancellationToken = default) =>
await Database
.SqlQueryRaw<int>("""SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = {0}) THEN 1 ELSE 0 END AS "Value" """, schema)
.SqlQueryRaw<int>("""SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND c.relname = {0} AND n.nspname = COALESCE(CAST({1} AS text), current_schema())) THEN 1 ELSE 0 END AS "Value" """, table, Schema ?? (object)DBNull.Value)
.SingleAsync(cancellationToken) == 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
},
{
"Name": "ServiceControl.Audit/Database/Schema",
"Mandatory": false
"Mandatory": false,
"DefaultValue": "sc_audit"
},
{
"Name": "ServiceControl.Audit/Database/CommandTimeout",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ public override bool IsDuplicateKeyException(DbUpdateException exception)
return false;
}

public override async Task<bool> SchemaExists(string schema, CancellationToken cancellationToken = default) =>
protected override async Task<bool> TableExists(string table, CancellationToken cancellationToken = default) =>
await Database
.SqlQueryRaw<int>("SELECT CASE WHEN SCHEMA_ID({0}) IS NULL THEN 0 ELSE 1 END AS [Value]", schema)
.SqlQueryRaw<int>("SELECT CASE WHEN EXISTS (SELECT 1 FROM sys.tables WHERE name = {0} AND schema_id = SCHEMA_ID(COALESCE(CAST({1} AS sysname), SCHEMA_NAME()))) THEN 1 ELSE 0 END AS [Value]", table, Schema ?? (object)DBNull.Value)
.SingleAsync(cancellationToken) == 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
},
{
"Name": "ServiceControl.Audit/Database/Schema",
"Mandatory": false
"Mandatory": false,
"DefaultValue": "sc_audit"
},
{
"Name": "ServiceControl.Audit/Database/CommandTimeout",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,11 @@ public static EFPersisterSettings CreateSettings(PersistenceSettings settings)
};
}

static string? ReadSchema(IDictionary<string, string> specific)
static string ReadSchema(IDictionary<string, string> specific)
{
if (!specific.TryGetValue(SchemaKey, out var schema) || string.IsNullOrWhiteSpace(schema))
{
return null;
return EFPersisterSettings.DefaultSchema;
}

try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class EFPersisterSettings
public static readonly TimeSpan MigrationCommandTimeout = TimeSpan.FromMinutes(40);

public const int DefaultCommandTimeout = 30;
public const string DefaultSchema = "sc_audit";

public required string ConnectionString { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ namespace ServiceControl.Audit.Persistence.EFCore.DbContexts;

public abstract class AuditDbContext(DbContextOptions options) : DbContext(options)
{
// Not EF Core's default, so an audit instance can share a schema with a primary instance.
public const string MigrationsHistoryTableName = "__AuditMigrationsHistory";

// EF Core's default history table name, which identifies a primary instance that wrongly shares this schema.
const string PrimaryMigrationsHistoryTableName = "__EFMigrationsHistory";

public string? Schema { get; } = options.FindExtension<SchemaOptionsExtension>()?.Schema;

public DbSet<AuditMessageEntity> AuditMessages { get; set; }
Expand Down Expand Up @@ -43,5 +45,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)

public abstract bool IsDuplicateKeyException(DbUpdateException exception);

public abstract Task<bool> SchemaExists(string schema, CancellationToken cancellationToken = default);
/// <summary>
/// Whether the table exists in the configured schema, or in the provider default schema when none is configured.
/// </summary>
protected abstract Task<bool> TableExists(string table, CancellationToken cancellationToken = default);

public async Task RequireNoPrimaryMigrationsHistory(CancellationToken cancellationToken = default)
{
if (await TableExists(PrimaryMigrationsHistoryTableName, cancellationToken))
{
throw new InvalidOperationException(
$"The schema '{Schema ?? "(provider default)"}' already contains the primary migrations history table '{PrimaryMigrationsHistoryTableName}'. A primary instance and an audit instance cannot share a schema. Configure a different Database/Schema for this instance and run setup again.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,7 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
var dbContext = scope.ServiceProvider.GetRequiredService<AuditDbContext>();
dbContext.Database.SetCommandTimeout(EFPersisterSettings.MigrationCommandTimeout);

if (dbContext.Schema is not null && !await dbContext.SchemaExists(dbContext.Schema, cancellationToken))
{
throw new InvalidOperationException(
$"The configured schema '{dbContext.Schema}' does not exist in the database. ServiceControl does not create schemas, the same way it does not create the database. Create the schema, grant the configured user rights on it, and run setup again.");
}
await dbContext.RequireNoPrimaryMigrationsHistory(cancellationToken);

logger.LogInformation("Starting audit database migration");
await dbContext.Database.MigrateAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace ServiceControl.Audit.Persistence.Tests
using ServiceControl.Audit.Persistence.EFCore.DbContexts;
using ServiceControl.Audit.Persistence.EFCore.Entities;
using ServiceControl.Audit.Persistence.EFCore.Infrastructure;
using ServiceControl.Audit.Persistence.EFCore.PostgreSql;

class PostgreSqlEnvironmentDataTests : EFPersistenceTestFixture
{
Expand Down Expand Up @@ -63,14 +64,15 @@ public async Task Counts_messages_across_the_partitions_once_analysed()
[Test]
public async Task Measures_and_identifies_the_search_path_schema_when_none_is_configured()
{
var settings = new PersistenceSettings(TimeSpan.FromDays(1), true, 100000);
settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.ConnectionStringKey] =
new NpgsqlConnectionStringBuilder(configuration.ConnectionString) { SearchPath = configuration.Schema }.ConnectionString;

var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<TimeProvider>(configuration.TimeProvider);
configuration.CreateConfiguration().Create(settings).AddPersistence(services);
new PostgreSqlPersistence(new EFPersisterSettings
{
ConnectionString = new NpgsqlConnectionStringBuilder(configuration.ConnectionString) { SearchPath = configuration.Schema }.ConnectionString,
AuditRetentionPeriod = TimeSpan.FromDays(1),
MaxBodySizeToStore = 100000
}).AddPersistence(services);
await using var unconfigured = services.BuildServiceProvider();

var identity = await unconfigured.GetRequiredService<IStorageIdentityProvider>().GetIdentity(TestTimeoutCancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace ServiceControl.Audit.Persistence.Tests
{
using System;
using NUnit.Framework;
using ServiceControl.Audit.Persistence.EFCore.Abstractions;

[TestFixture]
class SchemaConfigurationTests
{
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void Absent_or_blank_schema_falls_back_to_sc_audit(string configured)
{
var settings = CreateSettings();
if (configured != null)
{
settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.SchemaKey] = configured;
}

Assert.That(EFPersistenceConfigurationBase.CreateSettings(settings).Schema, Is.EqualTo("sc_audit"));
}

[TestCase("legacy")]
[TestCase("dbo")]
[TestCase("public")]
[TestCase("psw_audit")]
public void Configured_schema_is_used(string schema)
{
var settings = CreateSettings();
settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.SchemaKey] = schema;

Assert.That(EFPersistenceConfigurationBase.CreateSettings(settings).Schema, Is.EqualTo(schema));
}

static PersistenceSettings CreateSettings()
{
var settings = new PersistenceSettings(TimeSpan.FromDays(1), true, 100000);
settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.ConnectionStringKey] = "Server=nowhere";
return settings;
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
namespace ServiceControl.Audit.Persistence.Tests
{
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NUnit.Framework;
using ServiceControl.Audit.Persistence.EFCore.Abstractions;
using ServiceControl.Persistence.Tests;

[TestFixture]
class SchemaSetupTests
{
[Test]
public async Task Setup_creates_the_configured_schema_when_it_does_not_exist()
{
var testsConfiguration = new PersistenceTestsConfiguration();
var connectionString = await testsConfiguration.GetConnectionString();
var schema = $"sc_absent_{Guid.NewGuid():n}";

try
{
Assert.DoesNotThrowAsync(() => Setup(testsConfiguration, connectionString, schema));
}
finally
{
await TestSchema.Drop(connectionString, schema);
}
}

[Test]
public async Task Setup_fails_when_the_schema_holds_the_primary_migrations_history_table()
{
var testsConfiguration = new PersistenceTestsConfiguration();
var connectionString = await testsConfiguration.GetConnectionString();
var schema = $"sc_shared_{Guid.NewGuid():n}";
await TestSchema.Create(connectionString, schema);

try
{
await TestSchema.CreateTable(connectionString, schema, "__EFMigrationsHistory");

var exception = Assert.ThrowsAsync<InvalidOperationException>(() => Setup(testsConfiguration, connectionString, schema));

Assert.That(exception.Message, Does.Contain(schema).And.Contain("__EFMigrationsHistory"));
}
finally
{
await TestSchema.Drop(connectionString, schema);
}
}

[Test]
public async Task Setup_ignores_the_primary_migrations_history_table_in_another_schema()
{
var testsConfiguration = new PersistenceTestsConfiguration();
var connectionString = await testsConfiguration.GetConnectionString();
var schema = $"sc_audit_{Guid.NewGuid():n}";
var primarySchema = $"sc_primary_{Guid.NewGuid():n}";
await TestSchema.Create(connectionString, schema);
await TestSchema.Create(connectionString, primarySchema);

try
{
await TestSchema.CreateTable(connectionString, primarySchema, "__EFMigrationsHistory");

Assert.DoesNotThrowAsync(() => Setup(testsConfiguration, connectionString, schema));
}
finally
{
await TestSchema.Drop(connectionString, schema);
await TestSchema.Drop(connectionString, primarySchema);
}
}

static async Task Setup(PersistenceTestsConfiguration testsConfiguration, string connectionString, string schema)
{
var settings = new PersistenceSettings(TimeSpan.FromDays(1), true, 100000);
settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.ConnectionStringKey] = connectionString;
settings.PersisterSpecificSettings[EFPersistenceConfigurationBase.SchemaKey] = schema;

var hostBuilder = Host.CreateApplicationBuilder();
testsConfiguration.CreateConfiguration().Create(settings).AddInstaller(hostBuilder.Services);
using var host = hostBuilder.Build();

await host.StartAsync();
await host.StopAsync();
}
}
}
Loading
Loading