Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
709a1bc
perf: share source session across workers
niteshvijay-ms Aug 18, 2026
f852240
refactor: make session ownership explicit
niteshvijay-ms Aug 18, 2026
a63bb45
refactor: retain session factory name
niteshvijay-ms Aug 18, 2026
8ea0f37
refactor: generalize session factory naming
niteshvijay-ms Aug 18, 2026
9dcc1d3
perf: allow twenty concurrent session opens
niteshvijay-ms Aug 18, 2026
341d8c7
fix: preserve source sessions during token rotation
niteshvijay-ms Aug 18, 2026
1f5c1e9
refactor: defer disposal of rotated sessions
niteshvijay-ms Aug 18, 2026
7a30d26
refactor: extend session disposal grace period
niteshvijay-ms Aug 18, 2026
9da9449
refactor: separate session rotation from token refresh
niteshvijay-ms Aug 18, 2026
25f2cc7
refactor: let session provider refresh itself
niteshvijay-ms Aug 18, 2026
fe1118a
refactor: create initial session in provider
niteshvijay-ms Aug 18, 2026
87d7000
refactor: capture immutable source settings
niteshvijay-ms Aug 18, 2026
7aade0e
refactor: use credential session factory object
niteshvijay-ms Aug 18, 2026
d5d95a9
perf: share source UDT registration cache
niteshvijay-ms Aug 19, 2026
f03fb5c
refactor: consolidate source session wrapper
niteshvijay-ms Aug 19, 2026
7df21ac
refactor: expose typed source session API
niteshvijay-ms Aug 19, 2026
c04db77
fix: harden shared session lifecycle
niteshvijay-ms Aug 19, 2026
9850625
style: expand method implementations
niteshvijay-ms Aug 19, 2026
2a6af55
Encapsulate token refresh in source sessions
niteshvijay-ms Aug 19, 2026
6000e47
Inline source token refresh lifecycle
niteshvijay-ms Aug 19, 2026
d8bd8f5
Centralize retriable operation execution
niteshvijay-ms Aug 19, 2026
974705c
Simplify shared session architecture
niteshvijay-ms Aug 19, 2026
b96e1b3
Group source session lifecycle state
niteshvijay-ms Aug 19, 2026
3971db5
Flatten source session wrapper state
niteshvijay-ms Aug 19, 2026
3cdff29
Use lock-free current session reads
niteshvijay-ms Aug 19, 2026
ca6a58b
Move AAD token ownership to source wrapper
niteshvijay-ms Aug 20, 2026
17403a4
Require explicit source AAD mode
niteshvijay-ms Aug 20, 2026
e484d13
Inline source session settings
niteshvijay-ms Aug 20, 2026
e830545
Fail jobs on hidden operational errors
niteshvijay-ms Aug 21, 2026
12b4849
Log AAD source session rotation
niteshvijay-ms Aug 21, 2026
074af7d
Allow controlled AAD refresh timing
niteshvijay-ms Aug 21, 2026
ae409bd
Support accelerated AAD refresh validation
niteshvijay-ms Aug 21, 2026
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
92 changes: 70 additions & 22 deletions CassandraMigrationProcessor/CassandraDriver/ISessionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,73 @@
namespace CassandraMigrationProcessor.CassandraDriver;

/// <summary>
/// Per-job session factory. Encapsulates everything required to mint a
/// new source or target <see cref="ISession"/> (job credentials, logger,
/// optional token refresh manager) so that consumers — primarily
/// <see cref="DataTransfer.PageReader"/> and
/// <see cref="DataTransfer.PageWriter"/> — depend on a single
/// abstraction instead of being threaded the raw <see cref="Job"/> and
/// <see cref="TokenRefreshManager"/> separately. This keeps worker-side
/// classes focused on data movement and makes it possible to swap the
/// connection wiring in tests without touching their constructors.
/// Creates worker-owned sessions. The consumer determines the session role;
/// job-owned shared sessions are passed directly instead of using this factory.
/// </summary>
public interface ISessionFactory
{
/// <summary>Mint a new keyspace-agnostic source-cluster session.</summary>
ISession CreateSourceSession();
/// <summary>Mint a new keyspace-agnostic session.</summary>
Task<ISession> CreateSessionAsync(CancellationToken cancellationToken);
}

/// <summary>
/// Provides a lease on the current shared session. A rotated session is not
/// disposed until all operations using its leases have completed.
/// </summary>
public interface ISessionProvider
{
SessionLease AcquireSession();
}

public sealed class SessionLease : IDisposable
{
private Action? _release;

internal SessionLease(ISession session, Action release)
{
Session = session;
_release = release;
}

public ISession Session { get; }

public void Dispose()
=> Interlocked.Exchange(ref _release, null)?.Invoke();
}

/// <summary>Mint a new keyspace-agnostic target-cluster session. Async because
/// target credential discovery may go through ARM.</summary>
Task<ISession> CreateTargetSessionAsync();
/// <summary>
/// Limits simultaneous session opens. This prevents high-worker jobs from
/// creating a connection storm during startup.
/// </summary>
public sealed class GatedSessionFactory : ISessionFactory, IDisposable
{
private const int MaxConcurrentSessionCreations = 20;

private readonly ISessionFactory _inner;
private readonly SemaphoreSlim _creationGate = new(
MaxConcurrentSessionCreations,
MaxConcurrentSessionCreations);

public GatedSessionFactory(ISessionFactory inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}

public async Task<ISession> CreateSessionAsync(CancellationToken cancellationToken)
{
await _creationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await _inner.CreateSessionAsync(cancellationToken)
.ConfigureAwait(false);
}
finally
{
_creationGate.Release();
}
}

public void Dispose() => _creationGate.Dispose();
}

/// <summary>
Expand All @@ -34,18 +83,17 @@ public sealed class JobSessionFactory : ISessionFactory
{
private readonly MigrationLog _log;
private readonly Job _job;
private readonly TokenRefreshManager? _tokenRefreshManager;

public JobSessionFactory(MigrationLog log, Job job, TokenRefreshManager? tokenRefreshManager)
public JobSessionFactory(MigrationLog log, Job job)
{
_log = log;
_job = job;
_tokenRefreshManager = tokenRefreshManager;
}

public ISession CreateSourceSession()
=> CassandraClientFactory.CreateSourceSession(_log, _job, _tokenRefreshManager);

public Task<ISession> CreateTargetSessionAsync()
=> CassandraClientFactory.CreateTargetSessionAsync(_log, _job);
public async Task<ISession> CreateSessionAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return await CassandraClientFactory.CreateTargetSessionAsync(_log, _job)
.ConfigureAwait(false);
}
}
82 changes: 75 additions & 7 deletions CassandraMigrationProcessor/CassandraDriver/TokenRefreshManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@ namespace CassandraMigrationProcessor.CassandraDriver;
/// Manages AAD token lifecycle and proactive refresh for
/// Cosmos DB Cassandra API connections.
/// </summary>
public class TokenRefreshManager : IDisposable
public class TokenRefreshManager : ISessionProvider, IDisposable
{
private sealed class ManagedSessionState
{
public ManagedSessionState(ISession session) => Session = session;

public ISession Session { get; }
public int ActiveLeases { get; set; }
public bool Retired { get; set; }
}

private Timer? _tokenRefreshTimer;
private readonly object _refreshLock = new();
private ISession? _managedSourceSession;
private ManagedSessionState? _managedSourceSession;
private readonly MigrationLog _log;
private bool _disposed;
private DateTime _tokenExpiresAt = DateTime.MinValue;
private int _consecutiveRefreshFailures;
private const int MaxRefreshFailures = 6;
Expand Down Expand Up @@ -153,17 +163,16 @@ private void TokenRefreshCallback(object? state)

// If we have a managed session, recreate it
if (_managedSourceSession != null
&& !_managedSourceSession.IsDisposed
&& !_managedSourceSession.Session.IsDisposed
&& _lastSourceContactPoint != null)
{
var oldSession = _managedSourceSession;
_managedSourceSession = CassandraClientFactory.CreateSourceSession(
var newSession = CassandraClientFactory.CreateSourceSession(
_log,
_lastSourceContactPoint,
_lastSourcePort,
_lastSourceUsername ?? string.Empty,
freshToken);
MigrationUtilities.SafeDisposeSession(oldSession, "TokenRefresh old session");
SetManagedSourceSession(newSession);
}

// Schedule next refresh
Expand Down Expand Up @@ -199,14 +208,73 @@ private void TokenRefreshCallback(object? state)
/// </summary>
public void SetManagedSourceSession(ISession session)
{
_managedSourceSession = session;
ArgumentNullException.ThrowIfNull(session);

ISession? sessionToDispose = null;
lock (_refreshLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (ReferenceEquals(_managedSourceSession?.Session, session))
return;

var previous = _managedSourceSession;
_managedSourceSession = new ManagedSessionState(session);
if (previous != null)
{
previous.Retired = true;
if (previous.ActiveLeases == 0)
sessionToDispose = previous.Session;
}
}

MigrationUtilities.SafeDisposeSession(
sessionToDispose, "TokenRefresh retired source session");
}

public SessionLease AcquireSession()
{
lock (_refreshLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var state = _managedSourceSession
?? throw new InvalidOperationException("The source session has not been initialized.");
state.ActiveLeases++;
return new SessionLease(state.Session, () => ReleaseSession(state));
}
}

private void ReleaseSession(ManagedSessionState state)
{
ISession? sessionToDispose = null;
lock (_refreshLock)
{
state.ActiveLeases--;
if (state.Retired && state.ActiveLeases == 0)
sessionToDispose = state.Session;
}

MigrationUtilities.SafeDisposeSession(
sessionToDispose, "TokenRefresh retired source session");
}

public void Dispose()
{
ISession? sessionToDispose = null;
lock (_refreshLock)
{
if (_disposed) return;
_disposed = true;
StopTokenRefreshTimer();
if (_managedSourceSession != null)
{
_managedSourceSession.Retired = true;
if (_managedSourceSession.ActiveLeases == 0)
sessionToDispose = _managedSourceSession.Session;
_managedSourceSession = null;
}
}

MigrationUtilities.SafeDisposeSession(
sessionToDispose, "TokenRefresh managed source session");
}
}
3 changes: 1 addition & 2 deletions CassandraMigrationProcessor/DataTransfer/DataCopyWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public async Task RunAsync(PipelineContext ctx)
Partition? current = null;
try
{
reader = await PageReader.CreateAsync(_workerLog, ctx.SessionFactory, ctx.ReaderConfig, _ct);
reader = await PageReader.CreateAsync(_workerLog, ctx.SourceSessionProvider, ctx.ReaderConfig, _ct);
writer = await PageWriter.CreateAsync(_workerLog, ctx.SessionFactory, ctx.WriterConfig, _ct);

while (!_ct.IsCancellationRequested
Expand Down Expand Up @@ -159,7 +159,6 @@ public async Task RunAsync(PipelineContext ctx)
// instant any worker exits. Channel completion is driven
// by the orchestrator (offline) or _cts.Cancel (fatal).
MigrationUtilities.SafeDispose(writer, "worker PageWriter");
MigrationUtilities.SafeDispose(reader, "worker PageReader");
}
}

Expand Down
12 changes: 10 additions & 2 deletions CassandraMigrationProcessor/DataTransfer/JobPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,18 @@ internal sealed class JobPipeline : IDisposable, IAsyncDisposable
private readonly JobControl _control;
private readonly WorkerPool _workerPool;
private readonly PartitionManager _partitions;
private readonly ISessionFactory _sessionFactory;
public PipelineContext Context { get; }

public JobPipeline(MigrationLog log, Job job, PipelineConfig pipelineConfig, JobPartitioning partitioning, TokenRefreshManager? tokenRefreshManager, JobControl control)
public JobPipeline(MigrationLog log, Job job, PipelineConfig pipelineConfig,
JobPartitioning partitioning, ISessionProvider sourceSessionProvider,
ISessionFactory sessionFactory,
JobControl control)
{
_log = log;
_pipelineConfig = pipelineConfig;
_control = control;
_sessionFactory = sessionFactory;

bool enableReplay = job.IsOnline;
_partitions = new PartitionManager(
Expand All @@ -43,7 +48,8 @@ public JobPipeline(MigrationLog log, Job job, PipelineConfig pipelineConfig, Job

Context = new PipelineContext(
_partitions,
new JobSessionFactory(log, job, tokenRefreshManager),
sourceSessionProvider,
sessionFactory,
readerConfig,
writerConfig,
EnableReplay: enableReplay,
Expand Down Expand Up @@ -95,5 +101,7 @@ public async ValueTask DisposeAsync()
// by JobManager — we never cancel or dispose it here.
await _partitions.DisposeAsync().ConfigureAwait(false);
MigrationUtilities.SafeDispose(_workerPool, "JobPipeline WorkerPool");
if (_sessionFactory is IDisposable disposableFactory)
MigrationUtilities.SafeDispose(disposableFactory, "JobPipeline SessionFactory");
}
}
18 changes: 12 additions & 6 deletions CassandraMigrationProcessor/DataTransfer/MigrationJobRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ public class MigrationJobRunner : IAsyncDisposable
/// Runner-wide source / target sessions opened once in
/// <see cref="CreateAsync"/> and reused across wildcard expansion,
/// schema provisioning, and partition discovery. Disposed in
/// <see cref="DisposeAsync"/>. Copy workers mint their own sessions
/// via <see cref="ISessionFactory"/> for throughput isolation.
/// <see cref="DisposeAsync"/>. Copy workers reuse the thread-safe source
/// session to avoid multiplying driver metadata topology/schema handshakes,
/// while retaining independent target sessions for write throughput.
/// For simulated runs the target session is a <see cref="NullSession"/>.
/// </summary>
private readonly ISession _sourceSession;
Expand Down Expand Up @@ -87,14 +88,14 @@ public static async Task<MigrationJobRunner> CreateAsync(
try
{
source = CassandraClientFactory.CreateSourceSession(log, job, tokenRefreshManager);
tokenRefreshManager.SetManagedSourceSession(source);
target = await CassandraClientFactory.CreateTargetSessionAsync(log, job);
return new MigrationJobRunner(log, job, pipelineConfig, control, tokenRefreshManager, source, target);
}
catch
{
MigrationUtilities.SafeDisposeSession(target, "MigrationJobRunner target (CreateAsync rollback)");
MigrationUtilities.SafeDisposeSession(source, "MigrationJobRunner source (CreateAsync rollback)");
tokenRefreshManager.StopTokenRefreshTimer();
tokenRefreshManager.Dispose();
throw;
}
}
Expand Down Expand Up @@ -173,7 +174,12 @@ public async Task StartAsync()
var partitioning = await RunPartitioningPhaseAsync(
job, units, cancellationToken);

_pipeline = new JobPipeline(_log, job, _pipelineConfig, partitioning, _tokenRefreshManager, _control);
_pipeline = new JobPipeline(
_log, job, _pipelineConfig, partitioning,
_tokenRefreshManager,
new GatedSessionFactory(
new JobSessionFactory(_log, job)),
_control);
_pipeline.Start();

await RunCopyPhaseAsync(job, units, partitioning, cancellationToken);
Expand Down Expand Up @@ -245,7 +251,7 @@ public ValueTask DisposeAsync()
MigrationUtilities.SafeDispose(_pipeline, "JobPipeline (Dispose)");
_pipeline = null;
MigrationUtilities.SafeDisposeSession(_targetSession, "MigrationJobRunner target session");
MigrationUtilities.SafeDisposeSession(_sourceSession, "MigrationJobRunner source session");
_tokenRefreshManager.Dispose();
return ValueTask.CompletedTask;
}

Expand Down
Loading