fix(transport): flush created-site ids before the connection pass — create-missing import failed FK 547 on real SQL Server
Caught live 2026-08-01 importing a bundle into the empty env2 cluster: a create-missing Site has Id == 0 until SaveChanges on a relational provider, and ApplyDataConnectionsAsync stamps DataConnection.SiteId as a raw scalar (no navigation, no EF fix-up), so the insert violated FK_DataConnections_Sites_SiteId. Every importer integration test runs on the EF in-memory provider, which assigns ids eagerly on AddAsync — the exact masking the in-code comment predicted. Fix: one SaveChangesAsync between the site pass and the connection pass, riding the same outer transaction (all-or-nothing preserved; the failed live import rolled back cleanly). Regression test runs the create-missing path on SQLite (CreateMissingSiteRelationalTests) — red without the fix, green with it.
This commit is contained in:
@@ -1515,6 +1515,14 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
// ids by name.)
|
// ids by name.)
|
||||||
var siteBySourceIdentifier = await ApplySitesAsync(
|
var siteBySourceIdentifier = await ApplySitesAsync(
|
||||||
content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||||
|
// Flush BETWEEN the site pass and the connection pass: a create-missing
|
||||||
|
// site has Id == 0 until SaveChanges on a relational provider, and the
|
||||||
|
// connection pass stamps DataConnection.SiteId as a raw scalar (no EF
|
||||||
|
// navigation, so no fix-up) — without this flush the insert fails the
|
||||||
|
// FK on real SQL Server (FK_DataConnections_Sites_SiteId, error 547).
|
||||||
|
// The in-memory provider assigns ids eagerly on AddAsync, which is why
|
||||||
|
// in-process tests never hit this. Rides the same outer transaction.
|
||||||
|
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||||
var connectionMaps = await ApplyDataConnectionsAsync(
|
var connectionMaps = await ApplyDataConnectionsAsync(
|
||||||
content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||||||
// Flush so site + connection surrogate ids are assigned (relational
|
// Flush so site + connection surrogate ids are assigned (relational
|
||||||
|
|||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression guard for the create-missing site/connection apply path on a REAL
|
||||||
|
/// relational provider (SQLite). Every other importer integration test runs on the
|
||||||
|
/// EF in-memory provider, which assigns surrogate ids eagerly on <c>AddAsync</c> —
|
||||||
|
/// masking that a create-missing <c>Site</c> still has <c>Id == 0</c> until the
|
||||||
|
/// context is flushed. <c>ApplyDataConnectionsAsync</c> stamps
|
||||||
|
/// <c>DataConnection.SiteId</c> as a raw scalar (no EF navigation, so no fix-up),
|
||||||
|
/// so without a flush between the site pass and the connection pass the insert
|
||||||
|
/// fails the FK on a relational provider (live failure 2026-08-01: SQL Server
|
||||||
|
/// error 547, <c>FK_DataConnections_Sites_SiteId</c>, on a cross-environment
|
||||||
|
/// bundle import into an empty target).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateMissingSiteRelationalTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
|
||||||
|
public CreateMissingSiteRelationalTests()
|
||||||
|
{
|
||||||
|
// One open in-memory SQLite connection for the fixture's lifetime — the
|
||||||
|
// database lives exactly as long as the connection.
|
||||||
|
_connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
_connection.Open();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton<IConfiguration>(
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection().Build());
|
||||||
|
|
||||||
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseSqlite(_connection));
|
||||||
|
|
||||||
|
// Secret-bearing columns require the encrypting two-arg ctor — same wiring
|
||||||
|
// as RoundTripTests / BundleImporterRollbackFailureTests.
|
||||||
|
services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
|
||||||
|
services.AddScoped(sp => new ScadaBridgeDbContext(
|
||||||
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||||
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||||
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||||
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
|
services.AddTransport();
|
||||||
|
|
||||||
|
_provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
using var scope = _provider.CreateScope();
|
||||||
|
scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_provider.Dispose();
|
||||||
|
_connection.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Import_create_missing_site_and_connection_wires_the_materialised_site_id()
|
||||||
|
{
|
||||||
|
// ---- Seed a source site + connection, export the site closure. ----
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var site = new Site("Plant 1", "plant-1")
|
||||||
|
{
|
||||||
|
Description = "source plant",
|
||||||
|
NodeAAddress = "akka://site@10.0.0.1:2552",
|
||||||
|
NodeBAddress = "akka://site@10.0.0.2:2552",
|
||||||
|
GrpcNodeAAddress = "10.0.0.1:8083",
|
||||||
|
GrpcNodeBAddress = "10.0.0.2:8083",
|
||||||
|
};
|
||||||
|
ctx.Sites.Add(site);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
ctx.DataConnections.Add(new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
||||||
|
{
|
||||||
|
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
|
||||||
|
FailoverRetryCount = 5,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid sessionId;
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
|
||||||
|
var bundleStream = await exporter.ExportAsync(
|
||||||
|
new ExportSelection(
|
||||||
|
TemplateIds: Array.Empty<int>(),
|
||||||
|
SharedScriptIds: Array.Empty<int>(),
|
||||||
|
ExternalSystemIds: Array.Empty<int>(),
|
||||||
|
DatabaseConnectionIds: Array.Empty<int>(),
|
||||||
|
NotificationListIds: Array.Empty<int>(),
|
||||||
|
SmtpConfigurationIds: Array.Empty<int>(),
|
||||||
|
ApiMethodIds: Array.Empty<int>(),
|
||||||
|
IncludeDependencies: true,
|
||||||
|
SiteIds: siteIds),
|
||||||
|
user: "alice", sourceEnvironment: "dev",
|
||||||
|
passphrase: null, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await bundleStream.CopyToAsync(ms);
|
||||||
|
ms.Position = 0;
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||||
|
sessionId = session.SessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Wipe the target so the apply exercises CreateNew on both. ----
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
ctx.DataConnections.RemoveRange(ctx.DataConnections);
|
||||||
|
ctx.Sites.RemoveRange(ctx.Sites);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Apply with create-missing mappings (the CLI's --create-missing-*). ----
|
||||||
|
await using (var scope = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||||
|
var nameMap = new BundleNameMap(
|
||||||
|
Sites: [new SiteMapping("plant-1", MappingAction.CreateNew, null)],
|
||||||
|
Connections: [new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null)]);
|
||||||
|
await importer.ApplyAsync(
|
||||||
|
sessionId, Array.Empty<ImportResolution>(), user: "bob",
|
||||||
|
ct: CancellationToken.None, nameMap: nameMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The created connection must reference the created site's REAL id. ----
|
||||||
|
await using (var verify = _provider.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||||
|
var site = Assert.Single(await ctx.Sites.ToListAsync());
|
||||||
|
var conn = Assert.Single(await ctx.DataConnections.ToListAsync());
|
||||||
|
Assert.True(site.Id > 0, "created site id was never materialised");
|
||||||
|
Assert.Equal(site.Id, conn.SiteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user