Skip to content
Open
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
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,39 @@ jobs:
- name: MySQL Tests
run: dotnet test ./tests/Paramore.Brighter.MySQL.Tests/Paramore.Brighter.MySQL.Tests.csproj --filter "Fragile!=CI" --configuration Release --logger "console;verbosity=normal" --logger GitHubActions --blame -v n

oracle-ci:
runs-on: ubuntu-latest
timeout-minutes: 12
needs: [build]

services:
oracle:
image: gvenzl/oracle-free:latest
ports:
- 1521:1521
env:
ORACLE_PASSWORD: oracle
APP_USER: brighter
APP_USER_PASSWORD: BrighterTests1!
options: >-
--health-cmd healthcheck.sh
--health-interval 10s
--health-timeout 5s
--health-retries 30

steps:
- uses: actions/checkout@v7
- name: Setup dotnet
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
9.0.x
10.0.x
- name: Grant DBMS_LOCK to brighter user
run: docker exec -i "${{ job.services.oracle.id }}" sqlplus -s "/ as sysdba" @/dev/stdin < docker/oracle-init/01-grants.sql
- name: Oracle Tests
run: dotnet test ./tests/Paramore.Brighter.Oracle.Tests/Paramore.Brighter.Oracle.Tests.csproj --filter "Fragile!=CI" --configuration Release --logger "console;verbosity=normal" --logger GitHubActions --blame -v n

dynamo-ci:
runs-on: ubuntu-latest
timeout-minutes: 8
Expand Down
6 changes: 6 additions & 0 deletions Brighter.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@
<Project Path="tests/Paramore.Brighter.MQTT.Tests/Paramore.Brighter.MQTT.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.MSSQL.Tests/Paramore.Brighter.MSSQL.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.MySQL.Tests/Paramore.Brighter.MySQL.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.Oracle.Tests/Paramore.Brighter.Oracle.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.PostgresSQL.Tests/Paramore.Brighter.PostgresSQL.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.Quartz.Tests/Paramore.Brighter.Quartz.Tests.csproj" />
<Project Path="tests/Paramore.Brighter.Redis.Tests/Paramore.Brighter.Redis.Tests.csproj" />
Expand Down Expand Up @@ -250,6 +251,7 @@
<Project Path="src/Paramore.Brighter.BoxProvisioning/Paramore.Brighter.BoxProvisioning.csproj" />
<Project Path="src/Paramore.Brighter.BoxProvisioning.MsSql/Paramore.Brighter.BoxProvisioning.MsSql.csproj" />
<Project Path="src/Paramore.Brighter.BoxProvisioning.MySql/Paramore.Brighter.BoxProvisioning.MySql.csproj" />
<Project Path="src/Paramore.Brighter.BoxProvisioning.Oracle/Paramore.Brighter.BoxProvisioning.Oracle.csproj" />
<Project Path="src/Paramore.Brighter.BoxProvisioning.PostgreSql/Paramore.Brighter.BoxProvisioning.PostgreSql.csproj" />
<Project Path="src/Paramore.Brighter.BoxProvisioning.Sqlite/Paramore.Brighter.BoxProvisioning.Sqlite.csproj" />
<Project Path="src/Paramore.Brighter.BoxProvisioning.Spanner/Paramore.Brighter.BoxProvisioning.Spanner.csproj" />
Expand All @@ -266,6 +268,7 @@
<Project Path="src/Paramore.Brighter.Inbox.MongoDb/Paramore.Brighter.Inbox.MongoDb.csproj" />
<Project Path="src/Paramore.Brighter.Inbox.MsSql/Paramore.Brighter.Inbox.MsSql.csproj" />
<Project Path="src/Paramore.Brighter.Inbox.MySql/Paramore.Brighter.Inbox.MySql.csproj" />
<Project Path="src/Paramore.Brighter.Inbox.Oracle/Paramore.Brighter.Inbox.Oracle.csproj" />
<Project Path="src/Paramore.Brighter.Inbox.Postgres/Paramore.Brighter.Inbox.Postgres.csproj" />
<Project Path="src/Paramore.Brighter.Inbox.Spanner/Paramore.Brighter.Inbox.Spanner.csproj" />
<Project Path="src/Paramore.Brighter.Inbox.Sqlite/Paramore.Brighter.Inbox.Sqlite.csproj" />
Expand All @@ -276,6 +279,7 @@
<Project Path="src/Paramore.Brighter.Locking.MongoDb/Paramore.Brighter.Locking.MongoDb.csproj" />
<Project Path="src/Paramore.Brighter.Locking.MsSql/Paramore.Brighter.Locking.MsSql.csproj" />
<Project Path="src/Paramore.Brighter.Locking.MySql/Paramore.Brighter.Locking.MySql.csproj" />
<Project Path="src/Paramore.Brighter.Locking.Oracle/Paramore.Brighter.Locking.Oracle.csproj" />
<Project Path="src/Paramore.Brighter.Locking.PostgresSql/Paramore.Brighter.Locking.PostgresSql.csproj" />
<Project Path="src/Paramore.Brighter.Mediator/Paramore.Brighter.Mediator.csproj" />
<Project Path="src/Paramore.Brighter.MessageScheduler.AWS.V4/Paramore.Brighter.MessageScheduler.AWS.V4.csproj" />
Expand Down Expand Up @@ -303,13 +307,15 @@
<Project Path="src/Paramore.Brighter.MsSql/Paramore.Brighter.MsSql.csproj" />
<Project Path="src/Paramore.Brighter.MySql.EntityFrameworkCore/Paramore.Brighter.MySql.EntityFrameworkCore.csproj" />
<Project Path="src/Paramore.Brighter.MySql/Paramore.Brighter.MySql.csproj" />
<Project Path="src/Paramore.Brighter.Oracle/Paramore.Brighter.Oracle.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.DynamoDB.V4/Paramore.Brighter.Outbox.DynamoDB.V4.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.DynamoDB/Paramore.Brighter.Outbox.DynamoDB.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.Firestore/Paramore.Brighter.Outbox.Firestore.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.Hosting/Paramore.Brighter.Outbox.Hosting.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.MongoDb/Paramore.Brighter.Outbox.MongoDb.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.MsSql/Paramore.Brighter.Outbox.MsSql.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.MySql/Paramore.Brighter.Outbox.MySql.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.Oracle/Paramore.Brighter.Outbox.Oracle.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.PostgreSql/Paramore.Brighter.Outbox.PostgreSql.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.Spanner/Paramore.Brighter.Outbox.Spanner.csproj" />
<Project Path="src/Paramore.Brighter.Outbox.Sqlite/Paramore.Brighter.Outbox.Sqlite.csproj" />
Expand Down
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="Oracle.ManagedDataAccess" Version="23.26.200" />
<PackageVersion Include="Oracle.ManagedDataAccess.Core" Version="23.26.200" />
<PackageVersion Include="Paramore.Darker" Version="4.1.1" />
<PackageVersion Include="Paramore.Darker.AspNetCore" Version="4.1.1" />
<PackageVersion Include="Paramore.Darker.Policies" Version="4.1.1" />
Expand Down
17 changes: 17 additions & 0 deletions docker-compose-oracle.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Oracle Database Free (gvenzl/oracle-free)
#
# First startup may take a few minutes while the database is initialized.
# Connection string (ODP.NET):
# Data Source=//localhost:1521/FREEPDB1;User Id=system;Password=BrighterTests1!;

services:
oracle:
image: gvenzl/oracle-free:slim
ports:
- "1521:1521"
volumes:
- ./docker/oracle-init:/container-entrypoint-initdb.d
environment:
ORACLE_RANDOM_PASSWORD: true
APP_USER: brighter
APP_USER_PASSWORD: BrighterTests1!
2 changes: 2 additions & 0 deletions docker/oracle-init/01-grants.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER SESSION SET CONTAINER = FREEPDB1;
GRANT EXECUTE ON DBMS_LOCK TO brighter;
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// The MIT License (MIT)
// Copyright © 2026 Ian Cooper <ian_hammond_cooper@yahoo.co.uk>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

using System;
using System.Threading;
using System.Threading.Tasks;
using Oracle.ManagedDataAccess.Client;

namespace Paramore.Brighter.BoxProvisioning.Oracle;

/// <summary>
/// Encapsulates the Oracle session-level advisory-lock primitive used to serialise
/// box-table migrations. Backed by <c>DBMS_LOCK.REQUEST</c> / <c>DBMS_LOCK.RELEASE</c>.
/// Requires the executing user to hold <c>EXECUTE ON DBMS_LOCK</c> (or
/// <c>EXECUTE ANY PROCEDURE</c>).
/// </summary>
/// <remarks>
/// The default implementation is <see cref="OracleAdvisoryLock"/>. <see cref="OracleBoxMigrationRunner"/>
/// accepts an injected instance through its constructor for testability and for advanced
/// integrators. Lock-key derivation is performed by the runner via
/// <see cref="OracleMigrationLockName.For"/>; the abstraction owns only the
/// <c>DBMS_LOCK.REQUEST</c> / <c>DBMS_LOCK.RELEASE</c> PL/SQL calls.
/// </remarks>
public interface IOracleAdvisoryLock
{
/// <summary>
/// Acquires a session-level exclusive advisory lock via <c>DBMS_LOCK.REQUEST</c>.
/// </summary>
/// <param name="connection">The open <see cref="OracleConnection"/> on which to run the PL/SQL block.</param>
/// <param name="lockKey">The lock name; must be within the 128-character <c>DBMS_LOCK.ALLOCATE_UNIQUE</c> limit.
/// Callers should derive via <see cref="OracleMigrationLockName.For"/>.</param>
/// <param name="timeout">Maximum wait. Values are truncated to whole seconds; zero means
/// try-once (NOWAIT).</param>
/// <param name="cancellationToken">Cancellation token observed by the underlying command.</param>
/// <exception cref="TimeoutException">Thrown when <c>DBMS_LOCK.REQUEST</c> returns <c>1</c>
/// (could not acquire within <paramref name="timeout"/>).</exception>
/// <exception cref="OracleAdvisoryLockException">Thrown when <c>DBMS_LOCK.REQUEST</c> returns
/// <c>2</c> (deadlock), <c>3</c> (parameter error), or <c>5</c> (illegal handle).</exception>
Task AcquireAsync(
OracleConnection connection, string lockKey,
TimeSpan timeout, CancellationToken cancellationToken);

/// <summary>
/// Releases the advisory lock via <c>DBMS_LOCK.RELEASE</c>.
/// </summary>
/// <param name="connection">The same <see cref="OracleConnection"/> on which the lock was acquired.</param>
/// <param name="lockKey">The lock name used at acquisition.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// <c>true</c> — released successfully (code 0).
/// <c>false</c> — not the owner or parameter error (codes 3–5).
/// <c>null</c> — lock was never acquired (no handle allocated for this session).
/// </returns>
Task<bool?> ReleaseAsync(
OracleConnection connection, string lockKey,
CancellationToken cancellationToken);
}
194 changes: 194 additions & 0 deletions src/Paramore.Brighter.BoxProvisioning.Oracle/OracleAdvisoryLock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// The MIT License (MIT)
// Copyright © 2026 Ian Cooper <ian_hammond_cooper@yahoo.co.uk>
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

using System;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Oracle.ManagedDataAccess.Client;

namespace Paramore.Brighter.BoxProvisioning.Oracle;

/// <summary>
/// Default <see cref="IOracleAdvisoryLock"/> backed by Oracle <c>DBMS_LOCK.ALLOCATE_UNIQUE</c>,
/// <c>DBMS_LOCK.REQUEST</c>, and <c>DBMS_LOCK.RELEASE</c>.
/// </summary>
/// <remarks>
/// <para>
/// The connecting user must hold <c>EXECUTE ON DBMS_LOCK</c> (or
/// <c>EXECUTE ANY PROCEDURE</c>). Both <c>ALLOCATE_UNIQUE</c> and <c>REQUEST</c> are called
/// in the same session so the allocated handle is stable for the lock's lifetime.
/// </para>
/// <para>
/// Because <c>ALLOCATE_UNIQUE</c> is idempotent for the same lock name within the same
/// database, <c>ReleaseAsync</c> re-allocates the handle for the given <paramref name="lockKey"/>
/// before releasing — this keeps the interface stateless and avoids storing per-session state
/// on the instance.
/// </para>
/// <para>
/// <c>DBMS_LOCK.REQUEST</c> return codes:
/// 0 = success, 1 = timeout, 2 = deadlock, 3 = parameter error, 4 = already held (same
/// session), 5 = illegal lock handle.
/// </para>
/// </remarks>
public class OracleAdvisoryLock : IOracleAdvisoryLock
{
/// <inheritdoc />
public async Task AcquireAsync(
OracleConnection connection, string lockKey,
TimeSpan timeout, CancellationToken cancellationToken)
{
if (timeout < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout), timeout,
"Migration lock timeout must be non-negative.");
}

var timeoutSeconds = (int)Math.Ceiling(timeout.TotalSeconds);
var lockHandle = await AllocateLockHandleAsync(connection, lockKey, cancellationToken);
if (string.IsNullOrEmpty(lockHandle))
{
throw new OracleAdvisoryLockException($"DBMS_LOCK.ALLOCATE_UNIQUE returned an empty handle for '{lockKey}'.");
}

var result = await RequestLockAsync(connection, lockHandle!, timeoutSeconds, cancellationToken);

switch (result)
{
case 0:
case 4:
return;
case 1: throw new TimeoutException(
$"Could not acquire migration lock '{lockKey}' within {timeoutSeconds}s.");
case 2: throw new OracleAdvisoryLockException(
$"DBMS_LOCK.REQUEST on '{lockKey}' detected a deadlock (return code 2).");
default: throw new OracleAdvisoryLockException(
$"DBMS_LOCK.REQUEST on '{lockKey}' failed with return code {result}.");
}
}

/// <inheritdoc />
public async Task<bool?> ReleaseAsync(
OracleConnection connection, string lockKey,
CancellationToken cancellationToken)
{
try
{
var lockHandle = await AllocateLockHandleAsync(connection, lockKey, cancellationToken);
if (string.IsNullOrEmpty(lockHandle))
{
return null;
}

var result = await ReleaseLockByHandleAsync(connection, lockHandle!, cancellationToken);
return result == 0;
}
catch
{
return null;
}
}

private static async Task<string?> AllocateLockHandleAsync(
OracleConnection connection, string lockKey,
CancellationToken cancellationToken)
{
const string plsql = """
BEGIN
DBMS_LOCK.ALLOCATE_UNIQUE(:name, :handle);
END;
""";

#if NETFRAMEWORK
using var cmd = new OracleCommand(plsql, connection);
#else
await using var cmd = new OracleCommand(plsql, connection);
#endif

var handleParam = new OracleParameter("handle", OracleDbType.Varchar2, 128)
{
Direction = ParameterDirection.Output
};

cmd.Parameters.Add(
new OracleParameter("name", OracleDbType.Varchar2)
{
Value = lockKey,
Direction = ParameterDirection.Input
});
cmd.Parameters.Add(handleParam);

await cmd.ExecuteNonQueryAsync(cancellationToken);
return handleParam.Value?.ToString();
}

private static async Task<int> RequestLockAsync(
OracleConnection connection,
string lockHandle,
int timeoutSeconds,
CancellationToken cancellationToken)
{
const string plsql = """
BEGIN
:result := DBMS_LOCK.REQUEST(:lockhandle, 6, :timeout, FALSE);
END;
""";

#if NETFRAMEWORK
using var cmd = new OracleCommand(plsql, connection);
#else
await using var cmd = new OracleCommand(plsql, connection);
#endif

var resultParam = new OracleParameter("result", OracleDbType.Int32, ParameterDirection.Output);
cmd.Parameters.Add(resultParam);
cmd.Parameters.Add("lockhandle", OracleDbType.Varchar2, lockHandle, ParameterDirection.Input);
cmd.Parameters.Add("timeout", OracleDbType.Int32, timeoutSeconds, ParameterDirection.Input);

await cmd.ExecuteNonQueryAsync(cancellationToken);
return Convert.ToInt32(resultParam.Value?.ToString());
}

private static async Task<int> ReleaseLockByHandleAsync(
OracleConnection connection,
string lockHandle,
CancellationToken cancellationToken)
{
const string plsql = """
BEGIN
:result := DBMS_LOCK.RELEASE(:lockhandle);
END;
""";

#if NETFRAMEWORK
using var cmd = new OracleCommand(plsql, connection);
#else
await using var cmd = new OracleCommand(plsql, connection);
#endif

var resultParam = new OracleParameter("result", OracleDbType.Int32, ParameterDirection.Output);
cmd.Parameters.Add(resultParam);
cmd.Parameters.Add("lockhandle", OracleDbType.Varchar2, lockHandle, ParameterDirection.Input);

await cmd.ExecuteNonQueryAsync(cancellationToken);
return Convert.ToInt32(resultParam.Value?.ToString());
}
}
Loading
Loading