diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusHandler.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusHandler.cs new file mode 100644 index 0000000000..1b7e9f71a9 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusHandler.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using EnsureThat; +using MediatR; + +namespace Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus +{ + /// + /// Handler for getting all async job statuses. + /// + public class GetAllJobStatusHandler : IRequestHandler + { + private readonly IJobStatusService _jobStatusService; + + /// + /// Initializes a new instance of the class. + /// + /// The job status service. + public GetAllJobStatusHandler(IJobStatusService jobStatusService) + { + _jobStatusService = EnsureArg.IsNotNull(jobStatusService, nameof(jobStatusService)); + } + + /// + public async Task Handle(GetAllJobStatusRequest request, CancellationToken cancellationToken) + { + EnsureArg.IsNotNull(request, nameof(request)); + + var jobs = await _jobStatusService.GetAllJobStatusAsync(cancellationToken); + + return new GetAllJobStatusResponse(jobs); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusRequest.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusRequest.cs new file mode 100644 index 0000000000..bd30888590 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusRequest.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using MediatR; + +namespace Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus +{ + /// + /// Request to get all async job statuses. + /// + public class GetAllJobStatusRequest : IRequest + { + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusResponse.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusResponse.cs new file mode 100644 index 0000000000..f23e0dfa2f --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/GetAllJobStatusResponse.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; + +namespace Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus +{ + /// + /// Represents the response containing a list of job status information. + /// + public class GetAllJobStatusResponse + { + /// + /// Initializes a new instance of the class. + /// + /// The list of job status information. + public GetAllJobStatusResponse(IReadOnlyList jobs) + { + Jobs = jobs; + } + + /// + /// Gets the list of job status information. + /// + public IReadOnlyList Jobs { get; } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/IJobStatusService.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/IJobStatusService.cs new file mode 100644 index 0000000000..772e6d8f85 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/IJobStatusService.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus +{ + /// + /// Service interface for retrieving job status information. + /// + public interface IJobStatusService + { + /// + /// Gets the status information for all async jobs (Export, Import, Reindex, BulkDelete, BulkUpdate). + /// + /// The cancellation token. + /// A list of job status information. + Task> GetAllJobStatusAsync(CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/JobStatusInfo.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/JobStatusInfo.cs new file mode 100644 index 0000000000..891f0384ae --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/GetJobStatus/JobStatusInfo.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using Microsoft.Health.JobManagement; + +namespace Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus +{ + /// + /// Represents the status information for an async job. + /// + public class JobStatusInfo + { + /// + /// Gets or sets the group identifier for the job. + /// + public long GroupId { get; set; } + + /// + /// Gets or sets the type of the job (e.g., Export, Import, Reindex). + /// + public string JobType { get; set; } + + /// + /// Gets or sets the queue type. + /// + public QueueType QueueType { get; set; } + + /// + /// Gets or sets the status of the job. + /// + public Microsoft.Health.JobManagement.JobStatus Status { get; set; } + + /// + /// Gets or sets the content location URL for the job. + /// + public Uri ContentLocation { get; set; } + + /// + /// Gets or sets the date and time when the job was created. + /// + public DateTime CreateDate { get; set; } + + /// + /// Gets or sets the date and time when the job ended. + /// + public DateTime? EndDate { get; set; } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs index c6cf1db775..ed654f6982 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs @@ -59,6 +59,8 @@ public static class OperationsConstants public const string ValueSetExpand = $"valueset-expand"; + public const string Jobs = "jobs"; + public static readonly ReadOnlyCollection ExcludedResourceTypesForBulkUpdate = new ReadOnlyCollection(new[] { "SearchParameter", "StructureDefinition" }); } } diff --git a/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs b/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs index fc51a2e60c..dd3ccf3c7e 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs @@ -114,5 +114,7 @@ internal class KnownRoutes public const string ExpandResourceType = KnownResourceTypes.ValueSet + "/" + Expand; public const string ExpandResourceId = KnownResourceTypes.ValueSet + "/" + IdRouteSegment + "/" + Expand; public const string ExpandOperationDefinition = OperationDefinition + "/" + OperationsConstants.ValueSetExpand; + + public const string JobStatus = OperationsConstants.Operations + "/" + OperationsConstants.Jobs; } } diff --git a/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs b/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs index ea81baedd6..55c656b64c 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs @@ -100,5 +100,7 @@ internal static class RouteNames internal const string ExpandById = nameof(ExpandById); internal const string ExpandDefinition = nameof(ExpandDefinition); + + internal const string GetAllJobStatus = nameof(GetAllJobStatus); } } diff --git a/src/Microsoft.Health.Fhir.Core/Resources.Designer.cs b/src/Microsoft.Health.Fhir.Core/Resources.Designer.cs index 533c6f8b8b..a00f024fb5 100644 --- a/src/Microsoft.Health.Fhir.Core/Resources.Designer.cs +++ b/src/Microsoft.Health.Fhir.Core/Resources.Designer.cs @@ -953,6 +953,15 @@ internal static string JobNotReindexOrchestratorJob { } } + /// + /// Looks up a localized string similar to The job status endpoint is not supported for Cosmos DB deployments.. + /// + internal static string JobStatusNotSupportedForCosmosDb { + get { + return ResourceManager.GetString("JobStatusNotSupportedForCosmosDb", resourceCulture); + } + } + /// /// Looks up a localized string similar to Bundle.link values omitted because they exceeded the maximum Uri length.. /// @@ -1710,7 +1719,7 @@ internal static string SearchParameterDefinitionNullorEmptyCodeValue { } /// - /// Looks up a localized string similar to The search parameter with Uri '{0}' is defined by the FHIR specification and cannot be updated or deleted. Custom search parameters must use a different URL.. + /// Looks up a localized string similar to The search parameter with Uri '{0}' is defined by the FHIR specification and cannot be updated or deleted. Custom search parameters must use a different URL.. /// internal static string SearchParameterDefinitionSystemDefined { get { diff --git a/src/Microsoft.Health.Fhir.Core/Resources.resx b/src/Microsoft.Health.Fhir.Core/Resources.resx index 64bac96ba3..df303a3f2b 100644 --- a/src/Microsoft.Health.Fhir.Core/Resources.resx +++ b/src/Microsoft.Health.Fhir.Core/Resources.resx @@ -870,4 +870,8 @@ There are no search parameters to reindex for job Id: {0}. - \ No newline at end of file + + The job status endpoint is not supported for Cosmos DB deployments. + Error message when job status endpoint is called on a Cosmos DB deployment + + diff --git a/src/Microsoft.Health.Fhir.CosmosDb/Features/Operations/CosmosJobStatusService.cs b/src/Microsoft.Health.Fhir.CosmosDb/Features/Operations/CosmosJobStatusService.cs new file mode 100644 index 0000000000..57339ef045 --- /dev/null +++ b/src/Microsoft.Health.Fhir.CosmosDb/Features/Operations/CosmosJobStatusService.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus; + +namespace Microsoft.Health.Fhir.CosmosDb.Features.Operations +{ + /// + /// Cosmos DB implementation of the job status service. + /// This feature is not supported on Cosmos DB. + /// + public class CosmosJobStatusService : IJobStatusService + { + /// + public Task> GetAllJobStatusAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(Fhir.Core.Resources.JobStatusNotSupportedForCosmosDb); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/JobStatusController.cs b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/JobStatusController.cs new file mode 100644 index 0000000000..738f15c874 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/JobStatusController.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading.Tasks; +using EnsureThat; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Health.Api.Features.Audit; +using Microsoft.Health.Fhir.Api.Features.ActionResults; +using Microsoft.Health.Fhir.Api.Features.Filters; +using Microsoft.Health.Fhir.Api.Features.Resources; +using Microsoft.Health.Fhir.Api.Features.Routing; +using Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus; +using Microsoft.Health.Fhir.Core.Features.Routing; +using Microsoft.Health.Fhir.ValueSets; + +namespace Microsoft.Health.Fhir.Api.Controllers +{ + /// + /// Controller for retrieving job status information. + /// + [ServiceFilter(typeof(AuditLoggingFilterAttribute))] + [ServiceFilter(typeof(OperationOutcomeExceptionFilterAttribute))] + [ValidateModelState] + public class JobStatusController : Controller + { + private readonly IMediator _mediator; + + /// + /// Initializes a new instance of the class. + /// + /// The mediator. + public JobStatusController(IMediator mediator) + { + _mediator = EnsureArg.IsNotNull(mediator, nameof(mediator)); + } + + /// + /// Gets the status of all async jobs (Export, Import, Reindex, BulkDelete, BulkUpdate). + /// + /// A list of job status information. + [HttpGet] + [Route(KnownRoutes.JobStatus, Name = RouteNames.GetAllJobStatus)] + [AuditEventType(AuditEventSubType.Read)] + public async Task GetAllJobStatus() + { + var response = await _mediator.Send(new GetAllJobStatusRequest(), HttpContext.RequestAborted); + + return FhirResult.Create(response.Jobs.ToJobStatusResult()); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/JobStatusResponseExtensions.cs b/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/JobStatusResponseExtensions.cs new file mode 100644 index 0000000000..7f09ca08dc --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Api/Features/Resources/JobStatusResponseExtensions.cs @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Linq; +using Hl7.Fhir.Model; +using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus; +using Microsoft.Health.Fhir.Core.Models; + +namespace Microsoft.Health.Fhir.Api.Features.Resources +{ + /// + /// Extension methods for job status responses. + /// + public static class JobStatusResponseExtensions + { + /// + /// Converts a list of JobStatusInfo to a FHIR Parameters resource. + /// + /// The list of job status information. + /// A FHIR Parameters resource as a ResourceElement + public static ResourceElement ToJobStatusResult(this IReadOnlyList jobs) + { + var parameters = new Parameters(); + + foreach (var job in jobs) + { + var part = new Parameters.ParameterComponent + { + Name = job.JobType + " " + job.GroupId, + }; + + part.Part.Add(new Parameters.ParameterComponent + { + Name = "id", + Value = new Integer64(job.GroupId), + }); + + part.Part.Add(new Parameters.ParameterComponent + { + Name = "type", + Value = new FhirString(job.JobType), + }); + + part.Part.Add(new Parameters.ParameterComponent + { + Name = "uri", + Value = new FhirUri(job.ContentLocation), + }); + + part.Part.Add(new Parameters.ParameterComponent + { + Name = "status", + Value = new FhirString(job.Status.ToString()), + }); + + part.Part.Add(new Parameters.ParameterComponent + { + Name = "createTime", + Value = new FhirDateTime(job.CreateDate), + }); + + if (job.EndDate != null) + { + part.Part.Add(new Parameters.ParameterComponent + { + Name = "endTime", + Value = new FhirDateTime((System.DateTimeOffset)job.EndDate), + }); + } + + parameters.Parameter.Add(part); + } + + return parameters.ToResourceElement(); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Microsoft.Health.Fhir.Shared.Api.projitems b/src/Microsoft.Health.Fhir.Shared.Api/Microsoft.Health.Fhir.Shared.Api.projitems index f6bf0cd8ea..11f02c5e0e 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Microsoft.Health.Fhir.Shared.Api.projitems +++ b/src/Microsoft.Health.Fhir.Shared.Api/Microsoft.Health.Fhir.Shared.Api.projitems @@ -16,6 +16,7 @@ + @@ -49,6 +50,7 @@ + diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Operations/SqlJobStatusService.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Operations/SqlJobStatusService.cs new file mode 100644 index 0000000000..09073c1f72 --- /dev/null +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Operations/SqlJobStatusService.cs @@ -0,0 +1,134 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using EnsureThat; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using Microsoft.Health.Fhir.Core.Features.Operations; +using Microsoft.Health.Fhir.Core.Features.Operations.GetJobStatus; +using Microsoft.Health.Fhir.Core.Features.Routing; +using Microsoft.Health.JobManagement; +using Microsoft.Health.SqlServer.Features.Client; + +namespace Microsoft.Health.Fhir.SqlServer.Features.Operations +{ + /// + /// SQL Server implementation of the job status service. + /// + public class SqlJobStatusService : IJobStatusService + { + private readonly SqlConnectionWrapperFactory _sqlConnectionWrapperFactory; + private readonly IUrlResolver _urlResolver; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The SQL connection wrapper factory. + /// The URL resolver. + /// The logger. + public SqlJobStatusService( + SqlConnectionWrapperFactory sqlConnectionWrapperFactory, + IUrlResolver urlResolver, + ILogger logger) + { + _sqlConnectionWrapperFactory = EnsureArg.IsNotNull(sqlConnectionWrapperFactory, nameof(sqlConnectionWrapperFactory)); + _urlResolver = EnsureArg.IsNotNull(urlResolver, nameof(urlResolver)); + _logger = EnsureArg.IsNotNull(logger, nameof(logger)); + } + + /// + public async Task> GetAllJobStatusAsync(CancellationToken cancellationToken) + { + var jobs = new List(); + + var queueTypes = new[] + { + QueueType.Export, + QueueType.Import, + QueueType.Reindex, + QueueType.BulkDelete, + QueueType.BulkUpdate, + }; + + using SqlConnectionWrapper sqlConnectionWrapper = await _sqlConnectionWrapperFactory.ObtainSqlConnectionWrapperAsync(cancellationToken, true); + using SqlCommandWrapper sqlCommandWrapper = sqlConnectionWrapper.CreateRetrySqlCommand(); + + foreach (var queueType in queueTypes) + { + var queueJobs = await GetJobsByQueueTypeAsync(sqlCommandWrapper, queueType, cancellationToken); + jobs.AddRange(queueJobs); + } + + return jobs; + } + + private async Task> GetJobsByQueueTypeAsync(SqlCommandWrapper sqlCommandWrapper, QueueType queueType, CancellationToken cancellationToken) + { + var jobs = new List(); + var operationName = GetOperationNameForQueueType(queueType); + + sqlCommandWrapper.CommandText = @" + SELECT GroupId, min(Status) AS Status, min(CreateDate) AS CreateDate, max(EndDate) AS EndDate + FROM dbo.JobQueue + WHERE QueueType = @QueueType + AND Status <> 5 + GROUP BY GroupId + ORDER BY CreateDate DESC"; + + sqlCommandWrapper.Parameters.Clear(); + sqlCommandWrapper.Parameters.AddWithValue("@QueueType", (byte)queueType); + + try + { + using SqlDataReader reader = await sqlCommandWrapper.ExecuteReaderAsync(CommandBehavior.Default, cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var groupId = reader.GetInt64(0); + var status = (JobStatus)reader.GetByte(1); + var createDate = reader.GetDateTime(2); + DateTime? endDate = await reader.IsDBNullAsync(5, cancellationToken) ? null : reader.GetDateTime(3); + + var jobStatusInfo = new JobStatusInfo + { + GroupId = groupId, + QueueType = queueType, + JobType = operationName, + Status = status, + ContentLocation = _urlResolver.ResolveOperationResultUrl(operationName, groupId.ToString()), + CreateDate = createDate, + EndDate = endDate, + }; + + jobs.Add(jobStatusInfo); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error retrieving jobs for queue type {QueueType}", queueType); + } + + return jobs; + } + + private static string GetOperationNameForQueueType(QueueType queueType) + { + return queueType switch + { + QueueType.Export => OperationsConstants.Export, + QueueType.Import => OperationsConstants.Import, + QueueType.Reindex => OperationsConstants.Reindex, + QueueType.BulkDelete => OperationsConstants.BulkDelete, + QueueType.BulkUpdate => OperationsConstants.BulkUpdate, + _ => queueType.ToString(), + }; + } + } +} diff --git a/src/Microsoft.Health.Fhir.SqlServer/Registration/FhirServerBuilderSqlServerRegistrationExtensions.cs b/src/Microsoft.Health.Fhir.SqlServer/Registration/FhirServerBuilderSqlServerRegistrationExtensions.cs index 66d43333bb..2d08a5ba47 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Registration/FhirServerBuilderSqlServerRegistrationExtensions.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Registration/FhirServerBuilderSqlServerRegistrationExtensions.cs @@ -86,6 +86,11 @@ public static IFhirServerBuilder AddSqlServer(this IFhirServerBuilder fhirServer .AsSelf() .AsImplementedInterfaces(); + services.Add() + .Scoped() + .AsSelf() + .AsImplementedInterfaces(); + services.Add() .Singleton() .AsImplementedInterfaces();