Searching successful jobs in the Hangfire database - some sample SQL code

Note: The following is incomplete and unofficial; just something that I have been experimenting with for my own purposes, and that might be useful to others.

I have seen occasional requests for the ability to search for jobs in the Hangfire dashboard, and I would find that useful as well. So far that capability doesn’t exist in Hangfire (that I know of). I now have a preliminary version of a web page (outside of the Hangfire dashboard pages) that allows me to search and sort “succeeded” jobs. I am posting the SQL queries that I used here in case it’s helpful to anyone else.

Notes:

  • This is separate from any of the Hangfire dashboard pages (not an addition to, or extension for, Hangfire); I am using it in a standalone web page with a basic grid (sortable and searchable).
  • This is only the SQL code, not a complete web page. You should be able to use this in any grid or component that you use to display tables of data in your environment.
  • This works in the latest version of Hangfire as of April-2026 (v1.8.23), but it will probably require adjusting for some future versions.
  • The JSON functions used below require SQL Server 2016 or a later version.
  • The code below only queries “Succeeded” jobs, but it could be extended to other job states. [We typically have very few jobs in other states like Failed, Deleted, etc., so I only needed to search/sort the “Succeeded” jobs.]
  • I developed the SQL queries partly by looking at queries sent by Hangfire in “SQL Server Profiler” and partly by reviewing the Hangfire tables and data.

First, some Hangfire date/time values are stored as Unix epoch (UTC) timestamps (seconds since 1-Jan-1970), so I have a function like the following to convert epoch values to DATETIME2 values for a specified local time zone:

CREATE FUNCTION dbo.DateTimeEpochTimestampLocal
(
    @EpochTime AS BIGINT,
    @LocalTimeZone AS VARCHAR(50)
)
RETURNS DATETIME2(3)
AS
BEGIN
    RETURN DATEADD(MILLISECOND, @EpochTime % 1000, DATEADD(second, @EpochTime / 1000, '1-Jan-1970')) AT TIME ZONE 'UTC' AT TIME ZONE @LocalTimeZone;
END;

For Example:

SELECT dbo.DateTimeEpochTimestampLocal(1776875837462, 'Eastern Standard Time');

Note: Time zones in SQL Server are always “Standard” time zones, but they automatically adjust for daylight saving time. In SQL Server, use this query to see all the available time zones:

SELECT name FROM sys.time_zone_info;

Note that some columns contain data in JSON format, such as this example from the State.Data column:

{“SucceededAt”:“1776875837462”,“PerformanceDuration”:“8312”,“Latency”:“2327357046”}

In SQL Server 2016 and later, you can retrieve individual elements from these values with a query like:

JSON_VALUE(HFState.Data, '$.Latency') AS Latency

The InvocationData and Arguments columns (in the “Job” table) also contain JSON-style data, but with more variability in the structure, for example:

{“t”:“System.Diagnostics.Debug, mscorlib”,“m”:“WriteLine”,“p”:[“System.String”]}
{“t”:“AppHangfire.Code.ImportRSSFeed, AppHangfire”,“m”:“ExecuteAsync”,“p”:[“System.String”,“System.String”,“Hangfire.Server.PerformContext, Hangfire.Core”,“System.Threading.CancellationToken, mscorlib”]}
[““Hello, world!””]
[““ImportRSSFeed””,““AQAAANCMn””,null,null]

Note also that some of these embedded values are arrays, such as:
“p”:[“System.String”,“System.String”,“Hangfire.Server[…]”]

You can retrieve these individual array items with SQL such as:

JSON_VALUE(HFJob.InvocationData, 'lax $.p[0]') AS InvocationP0
JSON_VALUE(HFJob.InvocationData, 'lax $.p[1]') AS InvocationP1
JSON_VALUE(HFJob.Arguments, 'lax $[0]') AS Argument0

Note the “lax” qualifier above - this means that the query will return NULL if the array element doesn’t exist. The alternative is “strict” which will raise an error if the element defined in the query doesn’t exist.

Here is the SQL query that I’m currently using to retrieve data for “Succeeded” jobs:

;WITH cte
AS (SELECT HFJob.Id,
           ROW_NUMBER() OVER (ORDER BY HFJob.Id DESC) AS row_num
    FROM [HangFire].Job AS HFJob WITH (NOLOCK, FORCESEEK)
    WHERE HFJob.StateName = 'Succeeded')
SELECT HFJob.Id,
       HFJob.StateId,
       HFJob.StateName,
       HFJob.CreatedAt,
       HFJob.ExpireAt,
       HFState.CreatedAt AS StateChanged,
       JSON_VALUE(HFState.Data, '$.SucceededAt') AS SucceededAt,
       dbo.DateTimeEpochTimestampLocal(JSON_VALUE(HFState.Data, '$.SucceededAt'), 'Eastern Standard Time') AS SucceededAtLocalTime,
       JSON_VALUE(HFState.Data, '$.PerformanceDuration') AS PerformanceDuration,
       JSON_VALUE(HFState.Data, '$.Latency') AS Latency,
       HFState.Reason AS StateReason,
       JSON_VALUE(HFJob.InvocationData, '$.t') AS InvocT,
       JSON_VALUE(HFJob.InvocationData, '$.m') AS InvocM,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[0]') AS InvocP0,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[1]') AS InvocP1,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[2]') AS InvocP2,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[3]') AS InvocP3,
       JSON_VALUE(HFJob.Arguments, 'lax $[0]') AS Arg0,
       JSON_VALUE(HFJob.Arguments, 'lax $[1]') AS Arg1,
       JSON_VALUE(HFJob.Arguments, 'lax $[2]') AS Arg2,
       JSON_VALUE(HFJob.Arguments, 'lax $[3]') AS Arg3,
       HFState.Data AS StateData,
       HFJob.InvocationData,
       HFJob.Arguments
FROM [HangFire].Job AS HFJob WITH (NOLOCK, FORCESEEK)
    INNER JOIN cte
        ON cte.Id = HFJob.Id
    LEFT JOIN [HangFire].State AS HFState WITH (NOLOCK, FORCESEEK)
        ON HFJob.StateId = HFState.Id
           AND HFJob.Id = HFState.JobId;

Depending on how you use this query, you may want to add an “ORDER BY” clause at the end of the query.
And, if you are using this with paging in your grid, you may also need to add something like the following to the end of the query (before the “ORDER BY” clause):

WHERE cte.row_num BETWEEN @start AND @end

And have your grid code set the “@start” and “@end” values appropriately to page through the results (for example, to 21 and 40 to retrieve the second set of 20 records).

After looking at this again, I realized that I can use a simpler JOIN statement (without a CTE) if I’m only looking for “Succeeded” jobs. And the “Reason” column from the State table is always NULL for “Succeeded” jobs (it has non-NULL values for some other states, such as “Enqueued”) so I can remove that column from the query results.

My latest SQL code is similar to the following:

DECLARE @SearchTerm VARCHAR(100) = '';
DECLARE @SearchLike VARCHAR(100) = '%' + @SearchTerm + '%';
DECLARE @Start int = 0;
DECLARE @Offset int = 20;

SELECT HFJob.Id AS Id,
       HFJob.StateId AS StateId,
       HFJob.StateName AS StateName,
       HFJob.CreatedAt AS CreatedAt,
       HFJob.ExpireAt AS ExpireAt,
       HFState.CreatedAt AS StateChanged,
       JSON_VALUE(HFState.Data, '$.SucceededAt') AS SucceededAt,
       dbo.DateTimeEpochTimestampLocal(JSON_VALUE(HFState.Data, '$.SucceededAt'), 'Eastern Standard Time') AS SucceededAtLocalTime,
       CONVERT(BIGINT, JSON_VALUE(HFState.Data, '$.Latency')) AS LatencyNumeric,
       CONVERT(VARCHAR(15), DATEADD(ms, CONVERT(BIGINT, JSON_VALUE(HFState.Data, '$.PerformanceDuration')), 0), 114) AS Duration,
       JSON_VALUE(HFJob.InvocationData, '$.t') AS InvocT,
       JSON_VALUE(HFJob.InvocationData, '$.m') AS InvocM,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[0]') AS InvocP0,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[1]') AS InvocP1,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[2]') AS InvocP2,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[3]') AS InvocP3,
       JSON_VALUE(HFJob.Arguments, 'lax $[0]') AS Arg0,
       JSON_VALUE(HFJob.Arguments, 'lax $[1]') AS Arg1,
       JSON_VALUE(HFJob.Arguments, 'lax $[2]') AS Arg2,
       JSON_VALUE(HFJob.Arguments, 'lax $[3]') AS Arg3,
       HFState.Data AS StateData,
       HFJob.InvocationData AS InvocationData,
       HFJob.Arguments AS Arguments
FROM [HangFire].Job AS HFJob
    INNER JOIN HangFire.State AS HFState
        ON HFState.JobId = HFJob.Id
           AND HFState.Name = HFJob.StateName
WHERE HFJob.StateName = 'Succeeded'
      AND
      (
          @SearchTerm = ''
          OR InvocationData LIKE @SearchLike
          OR Arguments LIKE @SearchLike
      )
ORDER BY SucceededAt DESC OFFSET @Start ROWS FETCH NEXT @Offset ROWS ONLY;

Note that there are a few conversions in the query:

  • Converting “SucceededAt” from timestamp to a datetime value, and also to local time(using the DateTimeEpochTimestampLocal function from the earlier post)
  • Converting the “Latency” value from a string to a number
  • Converting the “PerformanceDuration” value to a time string in the format “hh:mm:ss.msec”

If you use this in code, you will also want code to insert the relevant sort column and order into the SQL query.

One more followup - here is a very basic barebones ASP.NET Core web page that uses the SQL query in the previous reply in a web page displaying a grid/table of successful Hangfire jobs, with sorting, paging, and search capabilities.

Note: This is NOT a complete standalone web page; this is a (Razor/cshtml) page that could be added to an ASP.NET Core (using .NET 8 or later) (Razor) project created in Visual Studio (2022 or later). This page depends on libraries and files (Bootstrap, jQuery, _Layout.cshtml file, etc.) that are included in a standard ASP.NET Core project, as well as on the Hangfire setup and dashboard defined in Program.cs (or Startup.cs) within the project.

If you use the following, you will need to make a few changes for your environment:

  • Change the namespace in both files
  • Define the “HangfireConnection” item in your appsettings.json file, or change the connection used in this page to one that you already have defined
  • Change the “href” for the “Details” button in the grid to match the URL for the details page in your Hangfire dashboard
  • Change the “href” for the “Clear” button, if this page is not named “Jobs”
  • Change the page title, and the text in the explanation paragraph
  • Change the PAGE_SIZE constant, if desired

Note that this page only shows Hangfire jobs (from the Hangfire database) that have completed and succeeded and that have not yet expired and been removed. By default, Hangfire expires (and removes) successful job entries after 24 hours, but you can change this expiration interval using the “WithJobExpirationTimeout” method in Hangfire configuration.

The following code uses the “Microsoft.Data.SqlClient” library, not the “System.Data.SqlClient” library. The “Microsoft.Data.SqlClient” NuGet package needs to be added to the project.

Code for Jobs.cshtml

@page
@model MyProject.Pages.JobsModel
@{
    ViewData["Title"] = "Report of Recent Successful Jobs";
}

<div class="container-fluid mt-5">
    <h1 class="mb-4">@ViewData["Title"]</h1>
        <p>[... Explanation for this page goes here. ...]</p>
    @if (ModelState.Values.SelectMany(v => v.Errors).Any())
    {
        <div class="alert alert-danger alert-dismissible fade show" role="alert">
            <strong>Error!</strong>
            @foreach (var error in ModelState.Values.SelectMany(v => v.Errors))
            {
                <div>@error.ErrorMessage</div>
            }
            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
        </div>
    }

    <!-- Search and Filter Section -->
    <div class="card mb-4">
        <div class="card-body">
            <form method="get" class="row g-3">
                <!-- Hidden fields to preserve sort and pagination state -->
                <input type="hidden" name="sort" value="@Model.Sort" />
                <input type="hidden" name="order" value="@Model.Order" />
                
                <div class="col-md-9">
                    <label for="searchInput" class="form-label">Search</label>
                    <input type="text" id="searchInput" name="search" class="form-control" placeholder="Enter search term..." value="@Model.SearchTerm" />
                </div>
                <div class="col-md-3">
                <label class="form-label">&nbsp;</label>
                <div class="d-grid gap-2 d-md-flex">
                    <button type="submit" class="btn btn-primary">Search</button>
                    <a href="Jobs" class="btn btn-secondary">Clear</a>
                </div>
            </div>
        </form>
    </div>
</div>

<!-- Records Info -->
<div class="row mb-3">
    <div class="col-md-12">
        <p class="text-muted">
            Showing <strong>@((Model.CurrentPage - 1) * JobsModel.PAGE_SIZE + 1)</strong> to
            <strong>@Math.Min(Model.CurrentPage * JobsModel.PAGE_SIZE, Model.TotalRecords)</strong> of
            <strong>@Model.TotalRecords</strong> total records
            <span class="badge bg-info">Page @Model.CurrentPage of @Model.TotalPages</span>
        </p>
    </div>
</div>

@if (Model.Jobs.Any())
{
    <!-- Table -->
    <div class="table-responsive">
        <table class="table table-striped table-hover table-sm">
            <thead class="table-dark">
                <tr>
                    <th style="width: 7%;">
                        <a href="@Model.GetSortUrl("Id")" class="text-white">
                            ID<span>@Model.GetSortIndicator("Id")</span>
                        </a>
                    </th>
                    <th style="width: 15%;">
                        <a href="@Model.GetSortUrl("CreatedAt")" class="text-white">
                            Created<span>@Model.GetSortIndicator("CreatedAt")</span>
                        </a>
                    </th>
                    <th style="width: 15%;">
                        <a href="@Model.GetSortUrl("SucceededAt")" class="text-white">
                            Succeeded (Local)<span>@Model.GetSortIndicator("SucceededAt")</span>
                        </a>
                    </th>
                        <th style="width: 15%;">
                            <a href="@Model.GetSortUrl("Duration")" class="text-white">
                                Time (h:m:s.ms)<span>@Model.GetSortIndicator("Duration")</span>
                            </a>
                        </th>
                        <th style="width: 15%;">
                            <a href="@Model.GetSortUrl("InvocT")" class="text-white">
                                Type<span>@Model.GetSortIndicator("InvocT")</span>
                            </a>
                        </th>
                        <th style="width: 15%;">
                            <a href="@Model.GetSortUrl("InvocM")" class="text-white">
                                Method<span>@Model.GetSortIndicator("InvocM")</span>
                            </a>
                        </th>
                        <th style="width: 10%;">
                            <a href="@Model.GetSortUrl("Arg0")" class="text-white">
                                Argument<span>@Model.GetSortIndicator("Arg0")</span>
                            </a>
                        </th>
                    <th style="width: 8%;">Details</th>
                </tr>
            </thead>
            <tbody>
                @foreach (var job in Model.Jobs)
                {
                    <tr>
                        <td>@job.Id</td>
                        <td>@job.CreatedAt?.ToString("yyyy-MM-dd HH:mm:ss")</td>
                        <td>@job.SucceededAtLocalTime?.ToString("yyyy-MM-dd HH:mm:ss")</td>
                            <td>@job.Duration</td>
                            <td>
                            <small class="text-muted" title="@job.InvocT">
                                @(job.InvocT?.Length > 25 ? job.InvocT.Substring(0, 25) + "..." : job.InvocT)
                            </small>
                        </td>
                        <td>
                            <small class="text-muted" title="@job.InvocM">
                                @(job.InvocM?.Length > 25 ? job.InvocM.Substring(0, 25) + "..." : job.InvocM)
                            </small>
                        </td>
                            <td>
                                <small class="text-muted" title="@job.Arg0">
                                    @(job.Arg0?.Length > 25 ? job.Arg0.Substring(0, 25) + "..." : job.Arg0)
                                </small>
                            </td>
                        <td>
                            <a href="dashboard-tasks/jobs/details/@job.Id" class="btn btn-sm btn-info">
                                Details
                            </a>
                        </td>
                    </tr>

                }
            </tbody>
        </table>
    </div>

    <!-- Pagination -->
     <nav aria-label="Page navigation" class="mt-4">
        <ul class="pagination justify-content-center">
            @if (Model.CurrentPage > 1)
            {
                <li class="page-item">
                    <a class="page-link" href="@Model.GetPageUrl(1)">First</a>
                </li>
                <li class="page-item">
                    <a class="page-link" href="@Model.GetPageUrl(Model.CurrentPage - 1)">Previous</a>
                </li>
            }

            @for (int i = Math.Max(1, Model.CurrentPage - 4); i <= Math.Min(Model.TotalPages, Model.CurrentPage + 4); i++)
            {
                if (i == Model.CurrentPage)
                {
                    <li class="page-item active"><span class="page-link">@i</span></li>
                }
                else
                {
                    <li class="page-item">
                        <a class="page-link" href="@Model.GetPageUrl(i)">@i</a>
                    </li>
                }
            }

            @if (Model.CurrentPage < Model.TotalPages)
            {
                <li class="page-item">
                    <a class="page-link" href="@Model.GetPageUrl(Model.CurrentPage + 1)">Next</a>
                </li>
                <li class="page-item">
                    <a class="page-link" href="@Model.GetPageUrl(Model.TotalPages)">Last</a>
                </li>
            }
        </ul>
    </nav>
}
else
{
    <div class="alert alert-info" role="alert">
        <strong>No Results</strong> - No succeeded Hangfire jobs found matching your search criteria.
    </div>
}
</div>

<style>
    a:hover {
        text-decoration: underline !important;
    }

    .table-sm tbody tr:hover {
        background-color: #f5f5f5;
    }

    .modal-body pre {
        font-size: 0.85rem;
    }
</style>

Code for Jobs.cshtml.cs

using Hangfire.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Data.SqlClient;
using System.Diagnostics.CodeAnalysis;

namespace MyProject.Pages
{
    public class JobsModel : PageModel
    {
        private readonly IConfiguration _configuration;
        public const int PAGE_SIZE = 25; // this needs to be public to allow its use in the cshtml page

        public JobsModel(IConfiguration configuration)
        {
            _configuration = configuration;
        }

        public List<HFJob> Jobs { get; set; } = new();
        public int CurrentPage { get; set; } = 1;
        public int TotalPages { get; set; } = 1;
        public int TotalRecords { get; set; } = 0;
        public string SortBy { get; set; } = "Id";
        public string SortOrder { get; set; } = "desc";
        public string SearchTerm { get; set; } = "";
        public string SearchField { get; set; } = "all";

        [BindProperty(SupportsGet = true)]
        public int PageNumber { get; set; } = 1;

        [BindProperty(SupportsGet = true)]
        [AllowNull]
        public string? Sort { get; set; } = "Id";

        [BindProperty(SupportsGet = true)]
        [AllowNull]
        public string? Order { get; set; } = "desc";

        [BindProperty(SupportsGet = true)]
        [AllowNull]
        public string? Search { get; set; } = "";

        [BindProperty(SupportsGet = true)]
        [AllowNull]
        public string? Field { get; set; } = "all";

        public void OnGet()
        {
            // Validate ModelState
            if (!ModelState.IsValid)
            {
                ModelState.Clear(); // Clear validation errors and use defaults
            }

            CurrentPage = PageNumber > 0 ? PageNumber : 1;
            SortBy = string.IsNullOrWhiteSpace(Sort) ? "Id" : Sort;
            SortOrder = string.IsNullOrWhiteSpace(Order) ? "desc" : Order;
            SearchTerm = Search ?? "";
            SearchField = string.IsNullOrWhiteSpace(Field) ? "all" : Field;

            LoadHangfireJobs();
        }

        private void LoadHangfireJobs()
        {
            try
            {
                string connectionString = _configuration.GetConnectionString("HangfireConnection");

                // First, get total count
                GetTotalRecords(connectionString);

                using (var connection = new SqlConnection(connectionString))
                {
                    connection.Open();

                    int start = (CurrentPage - 1) * PAGE_SIZE;
                    int end = PAGE_SIZE;

                    string query = $@"
SELECT HFJob.Id AS Id,
       HFJob.StateId AS StateId,
       HFJob.StateName AS StateName,
       HFJob.CreatedAt AS CreatedAt,
       HFJob.ExpireAt AS ExpireAt,
       HFState.CreatedAt AS StateChanged,
       JSON_VALUE(HFState.Data, '$.SucceededAt') AS SucceededAt,
       dbo.DateTimeEpochTimestampLocal(JSON_VALUE(HFState.Data, '$.SucceededAt'), 'Eastern Standard Time') AS SucceededAtLocalTime,
       CONVERT(BIGINT, JSON_VALUE(HFState.Data, '$.Latency')) AS LatencyNumeric,
       CONVERT(VARCHAR(15), DATEADD(ms, CONVERT(BIGINT, JSON_VALUE(HFState.Data, '$.PerformanceDuration')), 0), 114) AS Duration,
       JSON_VALUE(HFJob.InvocationData, '$.t') AS InvocT,
       JSON_VALUE(HFJob.InvocationData, '$.m') AS InvocM,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[0]') AS InvocP0,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[1]') AS InvocP1,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[2]') AS InvocP2,
       JSON_VALUE(HFJob.InvocationData, 'lax $.p[3]') AS InvocP3,
       JSON_VALUE(HFJob.Arguments, 'lax $[0]') AS Arg0,
       JSON_VALUE(HFJob.Arguments, 'lax $[1]') AS Arg1,
       JSON_VALUE(HFJob.Arguments, 'lax $[2]') AS Arg2,
       JSON_VALUE(HFJob.Arguments, 'lax $[3]') AS Arg3,
       HFState.Data AS StateData,
       HFJob.InvocationData AS InvocationData,
       HFJob.Arguments AS Arguments
FROM HangFire.Job AS HFJob
    INNER JOIN HangFire.State AS HFState
        ON HFState.JobId = HFJob.Id
           AND HFState.Name = HFJob.StateName
WHERE HFJob.StateName = 'Succeeded'
      AND
      (
          @SearchTerm = ''
          OR InvocationData LIKE @SearchLike
          OR Arguments LIKE @SearchLike
      )
ORDER BY {Sort} {Order} OFFSET @Start ROWS FETCH NEXT @End ROWS ONLY;";

                    using (var command = new SqlCommand(query, connection))
                    {
                        command.CommandTimeout = 300;
                        command.Parameters.AddWithValue("@Start", start);
                        command.Parameters.AddWithValue("@End", end);
                        command.Parameters.AddWithValue("@SearchTerm", SearchTerm);
                        command.Parameters.AddWithValue("@SearchLike", $"%{SearchTerm}%");

                        using (var reader = command.ExecuteReader())
                        {
                            while (reader.Read())
                            {
                                Jobs.Add(MapToHFJob(reader));
                            }
                        }
                    }
                }

                TotalPages = (int)Math.Ceiling((double)TotalRecords / PAGE_SIZE);
            }
            catch (Exception ex)
            {
                ModelState.AddModelError(string.Empty, $"Error loading jobs: {ex.Message}");
            }
        }

        private void GetTotalRecords(string connectionString)
        {
            try
            {
                using (var connection = new SqlConnection(connectionString))
                {
                    connection.Open();

                    string countQuery = @"
                        SELECT COUNT(*) 
                        FROM HangFire.Job AS HFJob WITH (NOLOCK)
                        WHERE HFJob.StateName = 'Succeeded'
                        AND (@SearchTerm = '' OR HFJob.InvocationData LIKE @SearchLike OR HFJob.Arguments LIKE @SearchLike)";

                    using (var command = new SqlCommand(countQuery, connection))
                    {
                        command.Parameters.AddWithValue("@SearchTerm", SearchTerm);
                        command.Parameters.AddWithValue("@SearchLike", $"%{SearchTerm}%");

                        TotalRecords = (int)command.ExecuteScalar();
                    }
                }
            }
            catch (Exception ex)
            {
                ModelState.AddModelError(string.Empty, $"Error counting records: {ex.Message}");
            }
        }

        private HFJob MapToHFJob(SqlDataReader reader)
        {
            return new HFJob
            {
                Id = reader["Id"] != DBNull.Value ? Convert.ToInt64(reader["Id"]) : 0,
                StateId = reader["StateId"] != DBNull.Value ? Convert.ToInt64(reader["StateId"]) : 0,
                StateName = reader["StateName"]?.ToString(),
                CreatedAt = reader["CreatedAt"] != DBNull.Value ? Convert.ToDateTime(reader["CreatedAt"]) : null,
                ExpireAt = reader["ExpireAt"] != DBNull.Value ? Convert.ToDateTime(reader["ExpireAt"]) : null,
                StateChanged = reader["StateChanged"] != DBNull.Value ? Convert.ToDateTime(reader["StateChanged"]) : null,
                SucceededAt = reader["SucceededAt"]?.ToString(),
                SucceededAtLocalTime = reader["SucceededAtLocalTime"] != DBNull.Value ? Convert.ToDateTime(reader["SucceededAtLocalTime"]) : null,
                Duration = reader["Duration"]?.ToString(),
                LatencyNumeric = reader["LatencyNumeric"]?.ToString(),
                InvocT = reader["InvocT"]?.ToString(),
                InvocM = reader["InvocM"]?.ToString(),
                InvocP0 = reader["InvocP0"]?.ToString(),
                InvocP1 = reader["InvocP1"]?.ToString(),
                InvocP2 = reader["InvocP2"]?.ToString(),
                InvocP3 = reader["InvocP3"]?.ToString(),
                Arg0 = reader["Arg0"]?.ToString(),
                Arg1 = reader["Arg1"]?.ToString(),
                Arg2 = reader["Arg2"]?.ToString(),
                Arg3 = reader["Arg3"]?.ToString(),
                StateData = reader["StateData"]?.ToString(),
                InvocationData = reader["InvocationData"]?.ToString(),
                Arguments = reader["Arguments"]?.ToString()
            };
        }

        public string GetSortUrl(string column)
        {
            string newOrder = (SortBy == column && SortOrder == "asc") ? "desc" : "asc";
            //return $"?pageNumber={CurrentPage}&sort={column}&order={newOrder}&search={Uri.EscapeDataString(SearchTerm)}";
            return $"?pageNumber=1&sort={column}&order={newOrder}&search={Uri.EscapeDataString(SearchTerm)}";
        }

        public string GetSortIndicator(string column)
        {
            if (SortBy != column) return "";
            return SortOrder == "asc" ? " ▲" : " ▼";
        }

        public string GetPageUrl(int page)
        {
            return $"?pageNumber={page}&sort={SortBy}&order={SortOrder}&search={Uri.EscapeDataString(SearchTerm)}";
        }
    }

    public class HFJob
    {
        public long Id { get; set; }
        public long StateId { get; set; }
        public string StateName { get; set; }
        public DateTime? CreatedAt { get; set; }
        public DateTime? ExpireAt { get; set; }
        public DateTime? StateChanged { get; set; }
        public string SucceededAt { get; set; }
        public DateTime? SucceededAtLocalTime { get; set; }
        public string LatencyNumeric { get; set; }
        public string Duration { get; set; }
        public string InvocT { get; set; }
        public string InvocM { get; set; }
        public string InvocP0 { get; set; }
        public string InvocP1 { get; set; }
        public string InvocP2 { get; set; }
        public string InvocP3 { get; set; }
        public string Arg0 { get; set; }
        public string Arg1 { get; set; }
        public string Arg2 { get; set; }
        public string Arg3 { get; set; }
        public string StateData { get; set; }
        public string InvocationData { get; set; }
        public string Arguments { get; set; }
    }
}

The code shown above does run correctly, but some of the code above will show warnings (in Visual Studio) about “possible null reference” and similar null-related warnings; these could be resolved with appropriate annotations or null-forgiving operators, etc.