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
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,8 @@
<ProjectReference Include="../../../../core/src/modules/Elsa.Scheduling/Elsa.Scheduling.csproj"/>
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Elsa.Scheduling.Quartz.UnitTests"/>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Elsa.Common.Multitenancy;
using Elsa.Scheduling.Quartz.Contracts;
using Elsa.Scheduling.Quartz.Jobs;
using Microsoft.Extensions.Logging;
using Quartz;

namespace Elsa.Scheduling.Quartz;
Expand Down Expand Up @@ -124,18 +125,63 @@ string stringValue when long.TryParse(stringValue, NumberStyles.Integer, Culture
};
}

public static async Task<Tenant?> GetTenantAsync(this IJobExecutionContext context, ITenantFinder tenantFinder)
/// <summary>
/// Fails the job when trigger or job data has a <c>TenantId</c> but the tenant in context
/// is missing or different. The job must never fall through to default-tenant stores.
/// </summary>
public static void EnsureScheduledTenant(this IJobExecutionContext context, ITenantAccessor tenantAccessor, ILogger logger)
{
var tenantId = ReadTenantId(context);
if (tenantId is null)
return;

var currentTenantId = tenantAccessor.Tenant?.Id;
if (string.Equals(currentTenantId, tenantId, StringComparison.Ordinal))
return;

logger.LogError(
"Quartz job {JobKey} is scheduled for tenant {TenantId} but the tenant in context is {CurrentTenant}. TenantAwareJobFactory is not installed, or a custom IJobFactory did not resolve the job inside the trigger's tenant. The job will not fall through to the default tenant.",
context.JobDetail.Key,
tenantId,
currentTenantId ?? "null");

throw new InvalidOperationException(
$"Quartz job '{context.JobDetail.Key}' is scheduled for tenant '{tenantId}' but the tenant in context is '{currentTenantId ?? "null"}'. TenantAwareJobFactory is not installed, or a custom IJobFactory did not resolve the job inside the trigger's tenant.");
}

private static string? ReadTenantId(IJobExecutionContext context)
{
if (TryGetTenantId(context.Trigger.JobDataMap, out var tenantId))
return tenantId;

if (TryGetTenantId(context.JobDetail.JobDataMap, out tenantId))
return tenantId;

if (TryGetTenantId(context.MergedJobDataMap, out tenantId))
return tenantId;

return null;
}

private static bool TryGetTenantId(JobDataMap? map, out string tenantId)
{
if(!context.MergedJobDataMap.ContainsKey("TenantId"))
return null;

if(!context.MergedJobDataMap.TryGetString("TenantId", out var tenantId))
return null;

if (string.IsNullOrWhiteSpace(tenantId))
return null;

return await tenantFinder.FindByIdAsync(tenantId, context.CancellationToken);
tenantId = "";
if (map is null)
return false;

if (map.TryGetString("TenantId", out var value) && !string.IsNullOrWhiteSpace(value))
{
tenantId = value;
return true;
}

if (map.TryGetValue("TenantId", out var obj) && obj is string stringValue && !string.IsNullOrWhiteSpace(stringValue))
{
tenantId = stringValue;
return true;
}

return false;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ public static IModule UseQuartz(this IModule module, Action<QuartzFeature>? conf

/// <summary>
/// Installs a Quartz.NET implementation for <see cref="IWorkflowScheduler"/>.
/// Registers a tenant-aware Quartz job factory when the container has no host-provided
/// <c>IJobFactory</c>. Quartz's own default <c>MicrosoftDependencyInjectionJobFactory</c>
/// is treated as replaceable. Hosts that register their own factory must resolve jobs
/// inside the trigger's tenant, or the job fails instead of using the default tenant.
/// </summary>
public static SchedulingFeature UseQuartzScheduler(this SchedulingFeature feature, Action<QuartzSchedulerFeature>? configure = null)
{
Expand All @@ -31,6 +35,8 @@ public static SchedulingFeature UseQuartzScheduler(this SchedulingFeature featur

/// <summary>
/// Installs a Quartz.NET implementation for <see cref="Elsa.Workflows.Runtime.IBackgroundActivityScheduler"/>.
/// Registers a tenant-aware Quartz job factory when the container has no host-provided
/// <c>IJobFactory</c>. Quartz's own default factory is treated as replaceable.
/// </summary>
public static WorkflowRuntimeFeature UseQuartzBackgroundActivityScheduler(this WorkflowRuntimeFeature feature, Action<QuartzBackgroundActivitySchedulerFeature>? configure = null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ public override void Configure()
public override void Apply()
{
Services.AddSingleton<QuartzBackgroundActivityScheduler>();
JobFactoryRegistration.TryUseTenantAwareJobFactory(Services);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Elsa.Scheduling.Quartz.Services;
using Microsoft.Extensions.DependencyInjection;
using Quartz;

Expand Down Expand Up @@ -124,6 +125,8 @@ public override void Apply()

Services
.AddQuartz(configure => { ConfigureQuartzInternal(configure, ConfigureQuartz); });

JobFactoryRegistration.TryUseTenantAwareJobFactory(Services);
}

private static void ConfigureQuartzInternal(IServiceCollectionQuartzConfigurator quartz, Action<IServiceCollectionQuartzConfigurator>? configureQuartz)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public override void Apply()
.AddStartupTask<RegisterJobsTask>()
.AddQuartz();

JobFactoryRegistration.TryUseTenantAwareJobFactory(Services);
Services.Configure(_configureQuartzJobOptions);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Elsa.Common.Multitenancy;
using Elsa.Scheduling.Quartz;
using Elsa.Workflows.Runtime;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
Expand All @@ -8,6 +9,9 @@ namespace Elsa.Scheduling.Quartz.Jobs;

/// <summary>
/// A job that executes a background activity.
/// Tenant context is established by the Quartz job factory.
/// <paramref name="tenantAccessor"/> verifies that the scheduled tenant is already in context;
/// <paramref name="tenantFinder"/> is retained for binary compatibility (lookup happens in the factory).
/// </summary>
[UsedImplicitly]
public class ExecuteBackgroundActivityJob(
Expand All @@ -19,23 +23,22 @@ public class ExecuteBackgroundActivityJob(
/// <inheritdoc />
public async Task Execute(IJobExecutionContext context)
{
_ = tenantFinder;
var cancellationToken = context.CancellationToken;
string? workflowInstanceId = null;

try
{
var tenant = await context.GetTenantAsync(tenantFinder);
using (tenantAccessor.PushContext(tenant))
{
var map = context.MergedJobDataMap;
workflowInstanceId = (string?)map.Get(nameof(ScheduledBackgroundActivity.WorkflowInstanceId));
var scheduledBackgroundActivity = new ScheduledBackgroundActivity(
workflowInstanceId!,
(string)map.Get(nameof(ScheduledBackgroundActivity.ActivityNodeId)),
(string)map.Get(nameof(ScheduledBackgroundActivity.BookmarkId)));
context.EnsureScheduledTenant(tenantAccessor, logger);

await backgroundActivityInvoker.ExecuteAsync(scheduledBackgroundActivity, cancellationToken);
}
var map = context.MergedJobDataMap;
workflowInstanceId = (string?)map.Get(nameof(ScheduledBackgroundActivity.WorkflowInstanceId));
var scheduledBackgroundActivity = new ScheduledBackgroundActivity(
workflowInstanceId!,
(string)map.Get(nameof(ScheduledBackgroundActivity.ActivityNodeId)),
(string)map.Get(nameof(ScheduledBackgroundActivity.BookmarkId)));

await backgroundActivityInvoker.ExecuteAsync(scheduledBackgroundActivity, cancellationToken);
}
catch (Exception e)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Elsa.Common;
using Elsa.Common.Multitenancy;
using Elsa.Extensions;
using Elsa.Scheduling.Quartz;
using Elsa.Scheduling.Quartz.Contracts;
using Elsa.Workflows.Models;
using Elsa.Workflows.Runtime;
Expand All @@ -13,6 +14,9 @@ namespace Elsa.Scheduling.Quartz.Jobs;

/// <summary>
/// A job that resumes a workflow.
/// Tenant context is established by the Quartz job factory.
/// <paramref name="tenantAccessor"/> verifies that the scheduled tenant is already in context;
/// <paramref name="tenantFinder"/> is retained for binary compatibility (lookup happens in the factory).
/// </summary>
public class ResumeWorkflowJob(
IWorkflowRuntime workflowRuntime,
Expand All @@ -26,30 +30,29 @@ public class ResumeWorkflowJob(
/// <inheritdoc />
public async Task Execute(IJobExecutionContext context)
{
_ = tenantFinder;
context.EnsureScheduledTenant(tenantAccessor, logger);

var cancellationToken = context.CancellationToken;
string? workflowInstanceId = null;
try
{
var tenant = await context.GetTenantAsync(tenantFinder);
using (tenantAccessor.PushContext(tenant))
{
var map = context.MergedJobDataMap;
var serializedActivityHandle = (string)map.Get(nameof(ScheduleExistingWorkflowInstanceRequest.ActivityHandle));
var activityHandle = serializedActivityHandle != null! ? jsonSerializer.Deserialize<ActivityHandle>(serializedActivityHandle) : null;
workflowInstanceId = (string)map.Get(nameof(ScheduleExistingWorkflowInstanceRequest.WorkflowInstanceId));
var map = context.MergedJobDataMap;
var serializedActivityHandle = (string)map.Get(nameof(ScheduleExistingWorkflowInstanceRequest.ActivityHandle));
var activityHandle = serializedActivityHandle != null! ? jsonSerializer.Deserialize<ActivityHandle>(serializedActivityHandle) : null;
workflowInstanceId = (string)map.Get(nameof(ScheduleExistingWorkflowInstanceRequest.WorkflowInstanceId));

var workflowClient = await workflowRuntime.CreateClientAsync(workflowInstanceId, cancellationToken);
var request = new RunWorkflowInstanceRequest
{
BookmarkId = (string)map.Get(nameof(ScheduleExistingWorkflowInstanceRequest.BookmarkId)),
ActivityHandle = activityHandle,
Input = map.GetDictionary(nameof(ScheduleExistingWorkflowInstanceRequest.Input)),
Properties = map.GetDictionary(nameof(ScheduleExistingWorkflowInstanceRequest.Properties)),
};
await workflowClient.RunInstanceAsync(request, cancellationToken: cancellationToken);
var workflowClient = await workflowRuntime.CreateClientAsync(workflowInstanceId, cancellationToken);
var request = new RunWorkflowInstanceRequest
{
BookmarkId = (string)map.Get(nameof(ScheduleExistingWorkflowInstanceRequest.BookmarkId)),
ActivityHandle = activityHandle,
Input = map.GetDictionary(nameof(ScheduleExistingWorkflowInstanceRequest.Input)),
Properties = map.GetDictionary(nameof(ScheduleExistingWorkflowInstanceRequest.Properties)),
};
await workflowClient.RunInstanceAsync(request, cancellationToken: cancellationToken);

logger.LogInformation("Resumed workflow instance {WorkflowInstanceId}", workflowInstanceId);
}
logger.LogInformation("Resumed workflow instance {WorkflowInstanceId}", workflowInstanceId);
}
catch (WorkflowGraphNotFoundException e)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Elsa.Common.Multitenancy;
using Elsa.Extensions;
using Elsa.Scheduling.Quartz;
using Elsa.Scheduling.Quartz.Contracts;
using Elsa.Workflows.Models;
using Elsa.Workflows.Runtime;
Expand All @@ -12,6 +13,9 @@ namespace Elsa.Scheduling.Quartz.Jobs;

/// <summary>
/// A job that runs a workflow.
/// Tenant context is established by the Quartz job factory.
/// <paramref name="tenantAccessor"/> verifies that the scheduled tenant is already in context;
/// <paramref name="tenantFinder"/> is retained for binary compatibility (lookup happens in the factory).
/// </summary>
[UsedImplicitly]
public class RunWorkflowJob(
Expand All @@ -25,36 +29,35 @@ public class RunWorkflowJob(
/// <inheritdoc />
public async Task Execute(IJobExecutionContext context)
{
_ = tenantFinder;
context.EnsureScheduledTenant(tenantAccessor, logger);

var cancellationToken = context.CancellationToken;
StartWorkflowRequest? startRequest = null;
try
{
var tenant = await context.GetTenantAsync(tenantFinder);
using (tenantAccessor.PushContext(tenant))
{
var map = context.MergedJobDataMap;
var map = context.MergedJobDataMap;

startRequest = new StartWorkflowRequest
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId((string)map.Get(nameof(ScheduleNewWorkflowInstanceRequest.WorkflowDefinitionHandle.DefinitionVersionId))),
CorrelationId = (string?)map.Get(nameof(ScheduleNewWorkflowInstanceRequest.CorrelationId)),
TriggerActivityId = (string?)map.Get(nameof(ScheduleNewWorkflowInstanceRequest.TriggerActivityId)),
Input = map.GetDictionary(nameof(ScheduleNewWorkflowInstanceRequest.Input)),
Variables = map.GetDictionary(nameof(ScheduleNewWorkflowInstanceRequest.Variables)),
Properties = map.GetDictionary(nameof(ScheduleNewWorkflowInstanceRequest.Properties)),
ParentId = (string?)map.Get(nameof(ScheduleNewWorkflowInstanceRequest.ParentId))
};

var startResponse = await workflowStarter.StartWorkflowAsync(startRequest, cancellationToken);
startRequest = new StartWorkflowRequest
{
WorkflowDefinitionHandle = WorkflowDefinitionHandle.ByDefinitionVersionId((string)map.Get(nameof(ScheduleNewWorkflowInstanceRequest.WorkflowDefinitionHandle.DefinitionVersionId))),
CorrelationId = (string?)map.Get(nameof(ScheduleNewWorkflowInstanceRequest.CorrelationId)),
TriggerActivityId = (string?)map.Get(nameof(ScheduleNewWorkflowInstanceRequest.TriggerActivityId)),
Input = map.GetDictionary(nameof(ScheduleNewWorkflowInstanceRequest.Input)),
Variables = map.GetDictionary(nameof(ScheduleNewWorkflowInstanceRequest.Variables)),
Properties = map.GetDictionary(nameof(ScheduleNewWorkflowInstanceRequest.Properties)),
ParentId = (string?)map.Get(nameof(ScheduleNewWorkflowInstanceRequest.ParentId))
};

if (startResponse.CannotStart)
{
logger.LogWarning("Workflow activation strategy disallowed starting workflow {WorkflowDefinitionHandle} with correlation ID {CorrelationId}", startRequest.WorkflowDefinitionHandle, startRequest.CorrelationId);
return;
}
var startResponse = await workflowStarter.StartWorkflowAsync(startRequest, cancellationToken);

logger.LogInformation("Started workflow {WorkflowInstanceId} with correlation ID {CorrelationId}", startResponse.WorkflowInstanceId, startRequest.CorrelationId);
if (startResponse.CannotStart)
{
logger.LogWarning("Workflow activation strategy disallowed starting workflow {WorkflowDefinitionHandle} with correlation ID {CorrelationId}", startRequest.WorkflowDefinitionHandle, startRequest.CorrelationId);
return;
}

logger.LogInformation("Started workflow {WorkflowInstanceId} with correlation ID {CorrelationId}", startResponse.WorkflowInstanceId, startRequest.CorrelationId);
}
catch (WorkflowGraphNotFoundException e)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Quartz.Simpl;
using Quartz.Spi;

namespace Elsa.Scheduling.Quartz.Services;

/// <summary>
/// Registers <see cref="TenantAwareJobFactory"/> once per container when the host has not supplied its own
/// <see cref="IJobFactory"/>. Quartz 3.15.1's <c>AddQuartz</c> default
/// (<see cref="MicrosoftDependencyInjectionJobFactory"/> via <c>TryAddSingleton</c>) is treated as replaceable.
/// </summary>
internal static class JobFactoryRegistration
{
/// <summary>
/// Replaces Quartz's default factory with <see cref="TenantAwareJobFactory"/> unless a host custom
/// <see cref="IJobFactory"/> is already registered. No-ops when this factory is already present.
/// Safe to call from every Quartz feature, in any order relative to <c>AddQuartz</c>.
/// </summary>
public static void TryUseTenantAwareJobFactory(IServiceCollection services)
{
if (HasHostJobFactory(services))
return;

if (services.Any(IsTenantAwareJobFactory))
return;

services.Replace(ServiceDescriptor.Singleton(typeof(IJobFactory), typeof(TenantAwareJobFactory)));
}

private static bool HasHostJobFactory(IServiceCollection services) =>
services.Any(descriptor => descriptor.ServiceType == typeof(IJobFactory) && IsHostJobFactory(descriptor));

private static bool IsTenantAwareJobFactory(ServiceDescriptor descriptor) =>
descriptor.ServiceType == typeof(IJobFactory) && GetImplementationType(descriptor) == typeof(TenantAwareJobFactory);

/// <summary>
/// Quartz 3.15.1 registers its default as
/// <c>TryAddSingleton(typeof(IJobFactory), typeof(MicrosoftDependencyInjectionJobFactory))</c>
/// (ImplementationType set, no factory delegate). <c>UseJobFactory&lt;T&gt;</c> uses the same shape with
/// <c>Replace</c>. Anything else is a host custom factory and must be kept.
/// </summary>
private static bool IsHostJobFactory(ServiceDescriptor descriptor)
{
var implementationType = GetImplementationType(descriptor);
if (implementationType == typeof(MicrosoftDependencyInjectionJobFactory) || implementationType == typeof(TenantAwareJobFactory))
return false;

return implementationType is not null || descriptor.ImplementationFactory is not null || descriptor.ImplementationInstance is not null;
}

private static Type? GetImplementationType(ServiceDescriptor descriptor) =>
descriptor.ImplementationType ?? descriptor.ImplementationInstance?.GetType();
}
Loading
Loading