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
9 changes: 9 additions & 0 deletions src/TickerQ.Utilities/Enums/TickerQStartMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ namespace TickerQ.Utilities.Enums
{
public enum TickerQStartMode
{
/// <summary>
/// Start job processing immediately when UseTickerQ is called.
/// Background services are registered and start automatically.
/// </summary>
Immediate,

/// <summary>
/// Background services are registered but skip the first run.
/// Job processing needs to be started manually via ITickerQHostScheduler.
/// </summary>
Manual
}
}
6 changes: 6 additions & 0 deletions src/TickerQ.Utilities/Interfaces/ITickerQDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ namespace TickerQ.Utilities.Interfaces
{
public interface ITickerQDispatcher
{
/// <summary>
/// Indicates whether the dispatcher is functional (background services enabled).
/// When false, DispatchAsync will be a no-op.
/// </summary>
bool IsEnabled { get; }

Task DispatchAsync(InternalFunctionContext[] contexts, CancellationToken cancellationToken = default);
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/TickerQ.Utilities/Managers/TickerManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ private async Task<TickerResult<TTimeTicker>> AddTimeTickerAsync(TTimeTicker ent
// Persist first
await _persistenceProvider.AddTimeTickers([entity], cancellationToken: cancellationToken);

if (executionTime <= now.AddSeconds(1))
// Only try to dispatch immediately if dispatcher is enabled (background services running)
if (_dispatcher.IsEnabled && executionTime <= now.AddSeconds(1))
{
// Acquire and mark InProgress in one provider call
var acquired = await _persistenceProvider
Expand Down Expand Up @@ -347,7 +348,8 @@ private async Task<TickerResult<List<TTimeTicker>>> AddTimeTickersBatchAsync(Lis

await _notificationHubSender.AddTimeTickersBatchNotifyAsync().ConfigureAwait(false);

if (immediateTickers.Count > 0)
// Only try to dispatch immediately if dispatcher is enabled (background services running)
if (_dispatcher.IsEnabled && immediateTickers.Count > 0)
{
var acquired = await _persistenceProvider
.AcquireImmediateTimeTickersAsync(immediateTickers.ToArray(), cancellationToken)
Expand Down
21 changes: 21 additions & 0 deletions src/TickerQ.Utilities/Temps/NoOpTickerQDispatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using TickerQ.Utilities.Interfaces;
using TickerQ.Utilities.Models;

namespace TickerQ.Utilities.Temps;

/// <summary>
/// No-operation implementation of ITickerQDispatcher.
/// Used when background services are disabled (queue-only mode).
/// </summary>
internal class NoOpTickerQDispatcher : ITickerQDispatcher
{
public bool IsEnabled => false;

public Task DispatchAsync(InternalFunctionContext[] contexts, CancellationToken cancellationToken = default)
{
// No-op: dispatcher not available in queue-only mode
return Task.CompletedTask;
}
}
35 changes: 35 additions & 0 deletions src/TickerQ.Utilities/Temps/NoOpTickerQHostScheduler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using TickerQ.Utilities.Interfaces;

namespace TickerQ.Utilities.Temps;

/// <summary>
/// No-operation implementation of ITickerQHostScheduler.
/// Used when background services are disabled (queue-only mode).
/// </summary>
internal class NoOpTickerQHostScheduler : ITickerQHostScheduler
{
public bool IsRunning => false;

public Task StartAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}

public Task StopAsync(CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}

public void RestartIfNeeded(DateTime? dateTime)
{
// No-op: scheduler not running
}

public void Restart()
{
// No-op: scheduler not running
}
}
17 changes: 17 additions & 0 deletions src/TickerQ.Utilities/TickerOptionsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ internal TickerOptionsBuilder(TickerExecutionContext tickerExecutionContext, Sch
/// Defaults to true.
/// </summary>
internal bool SeedDefinedCronTickers { get; set; } = true;

/// <summary>
/// Controls whether background services (job processors) should be registered.
/// Defaults to true. Set to false to only register managers for queuing jobs.
/// </summary>
internal bool RegisterBackgroundServices { get; set; } = true;

/// <summary>
/// Seeding delegate for time tickers, executed with the application's service provider.
Expand Down Expand Up @@ -98,6 +104,17 @@ public TickerOptionsBuilder<TTimeTicker, TCronTicker> IgnoreSeedDefinedCronTicke
SeedDefinedCronTickers = false;
return this;
}

/// <summary>
/// Disables background services registration.
/// Use this when you only want to queue jobs without processing them in this application.
/// Only the managers (ITimeTickerManager, ICronTickerManager) will be available for queuing jobs.
/// </summary>
public TickerOptionsBuilder<TTimeTicker, TCronTicker> DisableBackgroundServices()
{
RegisterBackgroundServices = false;
return this;
}

/// <summary>
/// Configure a custom seeder for time tickers, executed on application startup.
Expand Down
81 changes: 50 additions & 31 deletions src/TickerQ/DependencyInjection/TickerQServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,34 @@ public static IServiceCollection AddTickerQ<TTimeTicker, TCronTicker>(this IServ
services.AddSingleton<ITickerPersistenceProvider<TTimeTicker, TCronTicker>, TickerInMemoryPersistenceProvider<TTimeTicker, TCronTicker>>();
services.AddSingleton<ITickerQNotificationHubSender, NoOpTickerQNotificationHubSender>();
services.AddSingleton<ITickerClock, TickerSystemClock>();
services.AddSingleton<TickerQSchedulerBackgroundService>();
services.AddSingleton<ITickerQHostScheduler>(provider =>
provider.GetRequiredService<TickerQSchedulerBackgroundService>());
services.AddHostedService(provider =>
provider.GetRequiredService<TickerQSchedulerBackgroundService>());
services.AddHostedService(provider => provider.GetRequiredService<TickerQFallbackBackgroundService>());
services.AddSingleton<ITickerQInstrumentation, LoggerInstrumentation>();
services.AddSingleton<TickerQFallbackBackgroundService>();
services.AddSingleton<TickerExecutionTaskHandler>();
services.AddSingleton<ITickerQDispatcher, TickerQDispatcher>();
services.AddSingleton(sp =>

// Only register background services if enabled (default is true)
if (optionInstance.RegisterBackgroundServices)
{
services.AddSingleton<TickerQSchedulerBackgroundService>();
services.AddSingleton<ITickerQHostScheduler>(provider =>
provider.GetRequiredService<TickerQSchedulerBackgroundService>());
services.AddHostedService(provider =>
provider.GetRequiredService<TickerQSchedulerBackgroundService>());
services.AddHostedService(provider => provider.GetRequiredService<TickerQFallbackBackgroundService>());
services.AddSingleton<TickerQFallbackBackgroundService>();
services.AddSingleton<TickerExecutionTaskHandler>();
services.AddSingleton<ITickerQDispatcher, TickerQDispatcher>();
services.AddSingleton(sp =>
{
var notification = sp.GetRequiredService<ITickerQNotificationHubSender>();
var notifyDebounce = new SoftSchedulerNotifyDebounce((value) => notification.UpdateActiveThreads(value));
return new TickerQTaskScheduler(schedulerOptionsBuilder.MaxConcurrency, schedulerOptionsBuilder.IdleWorkerTimeOut, notifyDebounce);
});
}
else
{
var notification = sp.GetRequiredService<ITickerQNotificationHubSender>();
var notifyDebounce = new SoftSchedulerNotifyDebounce((value) => notification.UpdateActiveThreads(value));
return new TickerQTaskScheduler(schedulerOptionsBuilder.MaxConcurrency, schedulerOptionsBuilder.IdleWorkerTimeOut, notifyDebounce);
});
// Register NoOp implementations when background services are disabled
services.AddSingleton<ITickerQHostScheduler, NoOpTickerQHostScheduler>();
services.AddSingleton<ITickerQDispatcher, NoOpTickerQDispatcher>();
}

services.AddSingleton<ITickerQInstrumentation, LoggerInstrumentation>();

optionInstance.ExternalProviderConfigServiceAction?.Invoke(services);
optionInstance.DashboardServiceAction?.Invoke(services);
Expand All @@ -85,25 +97,32 @@ public static IApplicationBuilder UseTickerQ(this IApplicationBuilder app, Ticke
var configuration = serviceProvider.GetService<IConfiguration>();
var notificationHubSender = serviceProvider.GetService<ITickerQNotificationHubSender>();
var backgroundScheduler = serviceProvider.GetService<TickerQSchedulerBackgroundService>();
backgroundScheduler.SkipFirstRun = qStartMode == TickerQStartMode.Manual;

TickerFunctionProvider.UpdateCronExpressionsFromIConfiguration(configuration);
TickerFunctionProvider.Build();

tickerExecutionContext.NotifyCoreAction += (value, type) =>
// If background services are registered, configure them
if (backgroundScheduler != null)
{
if (type == CoreNotifyActionType.NotifyHostExceptionMessage)
backgroundScheduler.SkipFirstRun = qStartMode == TickerQStartMode.Manual;

tickerExecutionContext.NotifyCoreAction += (value, type) =>
{
notificationHubSender.UpdateHostException(value);
tickerExecutionContext.LastHostExceptionMessage = (string)value;
}
else if (type == CoreNotifyActionType.NotifyNextOccurence)
notificationHubSender.UpdateNextOccurrence(value);
else if (type == CoreNotifyActionType.NotifyHostStatus)
notificationHubSender.UpdateHostStatus(value);
else if (type == CoreNotifyActionType.NotifyThreadCount)
notificationHubSender.UpdateActiveThreads(value);
};
if (type == CoreNotifyActionType.NotifyHostExceptionMessage)
{
notificationHubSender.UpdateHostException(value);
tickerExecutionContext.LastHostExceptionMessage = (string)value;
}
else if (type == CoreNotifyActionType.NotifyNextOccurence)
notificationHubSender.UpdateNextOccurrence(value);
else if (type == CoreNotifyActionType.NotifyHostStatus)
notificationHubSender.UpdateHostStatus(value);
else if (type == CoreNotifyActionType.NotifyThreadCount)
notificationHubSender.UpdateActiveThreads(value);
};
}
// If background services are not registered (due to DisableBackgroundServices()),
// silently skip background service configuration. This is expected behavior.

TickerFunctionProvider.UpdateCronExpressionsFromIConfiguration(configuration);
TickerFunctionProvider.Build();

// Run core seeding pipeline based on main options (works for both in-memory and EF providers).
var options = tickerExecutionContext.OptionsSeeding;
Expand Down
2 changes: 2 additions & 0 deletions src/TickerQ/Src/Dispatcher/TickerQDispatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ internal class TickerQDispatcher : ITickerQDispatcher
private readonly TickerQTaskScheduler _taskScheduler;
private readonly TickerExecutionTaskHandler _taskHandler;

public bool IsEnabled => true;

public TickerQDispatcher(TickerQTaskScheduler taskScheduler, TickerExecutionTaskHandler taskHandler)
{
_taskScheduler = taskScheduler ?? throw new ArgumentNullException(nameof(taskScheduler));
Expand Down
24 changes: 24 additions & 0 deletions tests/TickerQ.Tests/TickerOptionsBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,28 @@ public void ConfigureScheduler_Invokes_Delegate()
schedulerOptions.MaxConcurrency.Should().Be(42);
schedulerOptions.NodeIdentifier.Should().Be("test-node");
}

[Fact]
public void DisableBackgroundServices_Sets_Flag_To_False()
{
var executionContext = new TickerExecutionContext();
var schedulerOptions = new SchedulerOptionsBuilder();

var builder = new TickerOptionsBuilder<FakeTimeTicker, FakeCronTicker>(executionContext, schedulerOptions);

// Default should be true
var defaultFlag = typeof(TickerOptionsBuilder<FakeTimeTicker, FakeCronTicker>)
.GetProperty("RegisterBackgroundServices", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(builder);
defaultFlag.Should().BeOfType<bool>().Which.Should().BeTrue();

// After calling DisableBackgroundServices, should be false
builder.DisableBackgroundServices();

var flag = typeof(TickerOptionsBuilder<FakeTimeTicker, FakeCronTicker>)
.GetProperty("RegisterBackgroundServices", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.GetValue(builder);

flag.Should().BeOfType<bool>().Which.Should().BeFalse();
}
}