Skip to content
Menu

Embed Beacon on SQL Server with Hangfire

View .md
You end up withYour ASP.NET Core app serving Beacon at /beacon, Beacon’s schema created in SQL Server, and subscriptions, data-quality checks and maintenance running as Hangfire jobs.
Timeabout 40 minutes
You needAn ASP.NET Core app on .NET 10, the PostgreSQL and Warp recipe open beside this one, an empty SQL Server database for Beacon, and a login that may create a schema in it.
Applies toBeacon 5.0 · Full Beacon

This recipe lists only the differences from the PostgreSQL and Warp recipe. Follow that recipe for the secrets, the middleware order, first-run setup and the first data source, and swap in what is below.

DecisionOptionsDefault
Hangfire’s storageThe Beacon database (Hangfire keeps its tables in its own HangFire schema), or a separate SQL Server databaseA separate database if you can; the Beacon database otherwise
Which data sources you monitorAdd the matching connector for each engine. Moberg.Beacon.Connector.SqlServer for SQL ServerThe engines you actually monitor
Several app instancesRun Beacon’s migrations once from a deployment step, not from every replicaA deployment step
Terminal window
dotnet add package Moberg.Beacon.Core.SqlServer
dotnet add package Moberg.Beacon.Api
dotnet add package Moberg.Beacon.UI
dotnet add package Moberg.Beacon.AI
dotnet add package Moberg.Beacon.MCP
dotnet add package Moberg.Beacon.Connector.SqlServer
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer

Moberg.Beacon.Core.SqlServer stores Beacon’s own data in SQL Server. Moberg.Beacon.Connector.SqlServer lets Beacon monitor a SQL Server database. Add the connector only if you monitor one. The packages Moberg.Warp.*, EFCore.NamingConventions and the Warp* files are not needed.

In the AddBeaconServices chain, replace the PostgreSQL calls:

using Beacon.Connector.SqlServer; // AddSqlServerConnector
using Beacon.Core.SqlServer; // UseSqlServer
builder.Services.AddBeaconServices(builder.Configuration, options =>
{
options.AddBeaconScheduler<HangfireBeaconScheduler>(); // the class from step 3
// ... the rest of the options as in the PostgreSQL recipe ...
})
.AddSqlServerConnector()
.UseSqlServer(); // reads ConnectionStrings:BeaconContext; must end the chain

ConnectionStrings:BeaconContext is now a SQL Server connection string. Everything lives in the fixed schema beacon; Beacon:Schema accepts nothing else.

Replace AddBeaconWarp(...) and StartBeaconWarpAsync() with Hangfire’s registration and the startup of step 4:

builder.Services.AddHangfire(x => x.UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireContext")
?? builder.Configuration.GetConnectionString("BeaconContext")));
builder.Services.AddHangfireServer();

Keep AddBeaconHostInfrastructure<...>(), the cookie, Data Protection, API, MCP and UI calls and the whole middleware pipeline exactly as in the other recipe. Data Protection keeps its keys in Beacon’s database on either provider.

Beacon ships no job runner and no Hangfire integration. IBeaconScheduler is the contract between Beacon and your runner, and this class is your code, in your host. The members, from src/Beacon.Core/Worker/IBeaconScheduler.cs:

MemberNeeded for
AddOrUpdate(subscriptionId, subscriptionName, cron) and Remove(subscriptionId, subscriptionName)Subscriptions. Required: they have no default body.
AddOrUpdateDataQualityJob(contractId, contractName, cron) and RemoveDataQualityJob(contractId, contractName)Data contracts. Throw NotImplementedException until you implement them.
EnqueueProjectDocumentation(projectId, userId, notifyUserId)AI documentation. Returns your runner’s job id. Throws until implemented.
EnqueueAiActorThinkCycle(actorId, subscriptionId)AI actors. Returns a job id. Throws until implemented.
EnqueueMcpEval(runId)MCP evaluation runs. Returns a job id. Throws until implemented.
EnqueueMetadataRefresh(dataSourceId)Re-scan after a data source was pointed at another target. Optional: the default returns null and Beacon logs that the old schema stays until a refresh.
EnqueueBackgroundTask(taskId, notifyUserId)A task a user started and follows (a background metadata refresh); your job calls IBackgroundTaskService.ExecuteAsync(taskId, ct). Optional: the default returns null and Beacon runs the task in the request.

cron is a standard 5-field expression, already validated by Beacon. Key recurring jobs by the entity id, never by the name.

Every job opens its own DI scope, marks it as system work with ISystemWorkScope.MarkAsSystemWork() before it calls Beacon, and calls one IJobService method. A scope that was not marked gets no project access and logs a warning. Work done for a user, such as a subscription’s run, is still executed as that user: Beacon sets the owner itself (the system-work rule). Do not resolve the current user in your wrapper.

using Beacon.Core.Authorization;
using Beacon.Core.Worker;
using Hangfire;
[AutomaticRetry(Attempts = 0)] // a failed check is investigated; a retry would notify twice
public sealed class BeaconHangfireJobs(ISystemWorkScope systemWork, IJobService jobs)
{
public Task ExecuteSubscription(int subscriptionId, CancellationToken ct)
{
systemWork.MarkAsSystemWork();
return jobs.ExecuteQuery(subscriptionId, ct);
}
public Task EvaluateContract(int contractId, CancellationToken ct)
{
systemWork.MarkAsSystemWork();
return jobs.EvaluateDataContract(contractId, ct);
}
public Task RunMcpEval(int runId, CancellationToken ct)
{
systemWork.MarkAsSystemWork();
return jobs.RunMcpEval(runId, ct);
}
public Task CleanupOldSignals(CancellationToken ct)
{
systemWork.MarkAsSystemWork();
return jobs.CleanupOldSignals(ct);
}
// AggregateLearnedPatterns, ReindexEmbeddings and ReindexDocChunks follow the same shape.
}
public sealed class HangfireBeaconScheduler(IRecurringJobManager recurring, IBackgroundJobClient background) : IBeaconScheduler
{
public Task AddOrUpdate(int subscriptionId, string subscriptionName, string cron)
{
recurring.AddOrUpdate<BeaconHangfireJobs>($"subscription-{subscriptionId}", x => x.ExecuteSubscription(subscriptionId, CancellationToken.None), cron);
return Task.CompletedTask;
}
public Task Remove(int subscriptionId, string subscriptionName)
{
recurring.RemoveIfExists($"subscription-{subscriptionId}");
return Task.CompletedTask;
}
public Task AddOrUpdateDataQualityJob(int contractId, string contractName, string cron)
{
recurring.AddOrUpdate<BeaconHangfireJobs>($"dq-{contractId}", x => x.EvaluateContract(contractId, CancellationToken.None), cron);
return Task.CompletedTask;
}
public Task RemoveDataQualityJob(int contractId, string contractName)
{
recurring.RemoveIfExists($"dq-{contractId}");
return Task.CompletedTask;
}
public Task<string> EnqueueMcpEval(int runId)
=> Task.FromResult(background.Enqueue<BeaconHangfireJobs>(x => x.RunMcpEval(runId, CancellationToken.None)));
}

Hangfire’s ASP.NET Core integration creates one DI scope per job and builds BeaconHangfireJobs in it, so the ISystemWorkScope the wrapper marks is the one IJobService reads. Hangfire fills the CancellationToken argument with its shutdown token at run time. The Hangfire calls above (AddOrUpdate<T>, RemoveIfExists, Enqueue<T>, [AutomaticRetry]) come from Hangfire, not from Beacon: check them against the Hangfire version you install. For AI documentation and AI actors, add wrapper methods that call IProjectDocumentationService.GenerateDocumentationAsync(projectId, userId, ct) and AiActorService.ExecuteThinkCycleBackgroundAsync(actorId, subscriptionId) (Background jobs names their namespaces).

Beacon expects these recurring jobs. Register each at startup, after app.Build(), with IRecurringJobManager:

IdCronCalls
mcp-learning-aggregate0 */6 * * *IJobService.AggregateLearnedPatterns
mcp-learning-cleanup0 3 * * *IJobService.CleanupOldSignals
mcp-audit-retention30 3 * * *IMcpAuditRetentionService.PurgeExpiredAsync
security-audit-retention45 3 * * *ISecurityAuditRetentionService.PurgeExpiredAsync
security-audit-verify15 * * * *ISecurityAuditVerificationService.VerifyNewEventsAsync
mcp-embedding-reindex0 */12 * * *IJobService.ReindexEmbeddings
mcp-docchunk-reindex0 */12 * * *IJobService.ReindexDocChunks
var recurring = app.Services.GetRequiredService<IRecurringJobManager>();
recurring.AddOrUpdate<BeaconHangfireJobs>("mcp-learning-cleanup", x => x.CleanupOldSignals(CancellationToken.None), "0 3 * * *");
// ... one line per row of the table, each with its own wrapper method ...

While Beacon:PreviousEncryptionKeys lists a retired key, also run IStoredValueReEncryptionService.ReEncryptAsync once per start (Background jobs). The Warp dashboard and the live JobStatusChanged progress push are features of the reference host’s Warp setup; with Hangfire the UI still works, it just shows no live progress for documentation jobs.

Beacon does not migrate itself. For one instance, call this after builder.Build(), as in the other recipe:

Beacon.Core.ServiceConfiguration.UseBeacon(app.Services, createSchema: true);

It creates the beacon schema when it is missing and applies the pending migrations. The login needs the right to create the schema and the tables in it. If it cannot, create the beacon schema as an administrator and call UseBeacon(app.Services) without createSchema.

With several instances, or a separate migrator process that does not compose Beacon, call the standalone migrator once from a deployment step:

await Beacon.Core.SqlServer.BeaconSqlServerMigrator.MigrateAsync(connectionString);

It creates the schema and applies the migrations, and it is idempotent. Both paths write the history table beacon.__EFMigrationsHistory. SQL Server needs no extension.

6. Know what SQL Server does without pgvector

Section titled “6. Know what SQL Server does without pgvector”

On PostgreSQL, Beacon keeps embeddings in a vector(384) column with an HNSW index. On SQL Server there is no such column: Beacon stores the embedding bytes and computes similarity over them in memory. The embedding and document re-index jobs still run. As on PostgreSQL, retrieval is semantic only when you add Moberg.Beacon.AI.Embeddings.Onnx and call AddBeaconOnnxEmbeddings(); without it retrieval stays keyword-based. With it, SQL Server has no vector index to lean on, so the similarity work grows with the number of indexed items.

7. Monitoring SQL Server: the read-only login

Section titled “7. Monitoring SQL Server: the read-only login”

For a data source on SQL Server, create a login that can only read. Beacon’s SQL Server path relies on its SQL checks and this login, because SQL Server has no read-only session (Data sources):

CREATE LOGIN beacon_reader WITH PASSWORD = '…';
USE YourDatabase;
CREATE USER beacon_reader FOR LOGIN beacon_reader;
ALTER ROLE db_datareader ADD MEMBER beacon_reader;

The connection string must verify the server: Encrypt Mandatory or Strict and no TrustServerCertificate, for example Server=db.example.com;Database=YourDatabase;User Id=beacon_reader;Password=…;Encrypt=True;TrustServerCertificate=False. Beacon refuses a connection that does not (Transport security).

  • The app starts. No Implementation of IBeaconScheduler is required., no encryption-key error.
  • In SQL Server, the beacon schema holds Beacon’s tables and beacon.__EFMigrationsHistory lists the migrations.
  • /beacon/setup, the first admin and curl -k https://localhost:<port>/beacon/api/health behave as in the other recipe.
  • The Hangfire tables exist (schema HangFire) and the Hangfire server is running.
  • After you create a subscription, subscription-<id> is in the Hangfire recurring jobs. After you delete it, the entry is gone.
  • Create a subscription with a short schedule and wait for its run: the Hangfire job succeeds, with no “not marked as system work” warning in the log.
SymptomCause and fix
A warning that a scope is not marked as system work, and the job does nothingThe job did not call MarkAsSystemWork() before Beacon code. Check that every wrapper method does.
NotImplementedException mentioning scheduling or background enqueueYou use a feature (data contracts, AI documentation, AI actors, MCP evals) whose IBeaconScheduler member you have not implemented.
A failed check notifies twiceHangfire retried it. Keep [AutomaticRetry(Attempts = 0)] on the wrapper class.
The migration fails with a permission errorThe login cannot create the schema or tables. Create the beacon schema as an administrator and call UseBeacon without createSchema.
Startup fails on Beacon:SchemaThe schema is fixed at beacon. Remove the key.
Jobs run twice with several instancesHangfire stores recurring jobs in its database and fires each once across servers. Check that every instance uses the same Hangfire storage.