Skip to content

Commit d877aad

Browse files
authored
Fix #3725: DbBatchBatcher empty batch execution bug (#3726)
1 parent e15822d commit d877aad

File tree

3 files changed

+120
-2
lines changed

3 files changed

+120
-2
lines changed

src/NHibernate.Test/Ado/BatcherFixture.cs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Linq;
12
using NHibernate.AdoNet;
23
using NHibernate.Cfg;
34
using NUnit.Framework;
@@ -26,11 +27,13 @@ protected override string[] Mappings
2627
get { return new[] { "Ado.VerySimple.hbm.xml", "Ado.AlmostSimple.hbm.xml" }; }
2728
}
2829

30+
private const int BatchSize = 10;
31+
2932
protected override void Configure(Configuration configuration)
3033
{
3134
configuration.SetProperty(Environment.FormatSql, "true");
3235
configuration.SetProperty(Environment.GenerateStatistics, "true");
33-
configuration.SetProperty(Environment.BatchSize, "10");
36+
configuration.SetProperty(Environment.BatchSize, BatchSize.ToString());
3437
#if NET6_0_OR_GREATER
3538
if (_useDbBatch)
3639
{
@@ -303,5 +306,55 @@ public void AbstractBatcherLogFormattedSql()
303306
Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(1));
304307
Cleanup();
305308
}
309+
310+
[Test]
311+
[Description("Inserting exactly BatchSize entities should not throw on commit. See GH-3725.")]
312+
public void InsertExactlyBatchSizeEntitiesShouldNotThrowOnCommit()
313+
{
314+
// This test verifies that DbBatchBatcher handles empty batches correctly.
315+
// The bug (GH-3725): When inserting exactly BatchSize entities, the batch auto-executes
316+
// when full (via ExecuteBatchWithTiming), which clears _currentBatch but NOT _batchCommand.
317+
// On commit, ExecuteBatch() is called, sees _batchCommand is set, and calls DoExecuteBatch
318+
// on an empty _currentBatch, causing InvalidOperationException.
319+
320+
using (var session = OpenSession())
321+
using (var transaction = session.BeginTransaction())
322+
{
323+
// Insert exactly BatchSize entities - this fills the batch and triggers auto-execution.
324+
for (int i = 0; i < BatchSize; i++)
325+
{
326+
session.Save(new VerySimple { Id = 1000 + i, Name = $"Test{i}", Weight = i * 1.1 });
327+
}
328+
329+
// Commit triggers ExecuteBatch() which would fail on empty batch without the fix,
330+
// depending on the driver. It fails with Microsoft.Data.SqlClient by example, not with
331+
// System.Data.SqlClient.
332+
transaction.Commit();
333+
}
334+
335+
Cleanup();
336+
}
337+
338+
[Test]
339+
[Description("Inserting a multiple of BatchSize entities should not throw on commit. See GH-3725.")]
340+
public void InsertMultipleOfBatchSizeEntitiesShouldNotThrowOnCommit()
341+
{
342+
// Same issue as above but with multiple full batches
343+
const int batchSize = 10;
344+
const int multiplier = 3;
345+
346+
using (var session = OpenSession())
347+
using (var transaction = session.BeginTransaction())
348+
{
349+
for (int i = 0; i < batchSize * multiplier; i++)
350+
{
351+
session.Save(new VerySimple { Id = 2000 + i, Name = $"Test{i}", Weight = i * 1.1 });
352+
}
353+
354+
transaction.Commit();
355+
}
356+
357+
Cleanup();
358+
}
306359
}
307360
}

src/NHibernate.Test/Async/Ado/BatcherFixture.cs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
//------------------------------------------------------------------------------
99

1010

11+
using System.Linq;
1112
using NHibernate.AdoNet;
1213
using NHibernate.Cfg;
1314
using NUnit.Framework;
@@ -38,11 +39,13 @@ protected override string[] Mappings
3839
get { return new[] { "Ado.VerySimple.hbm.xml", "Ado.AlmostSimple.hbm.xml" }; }
3940
}
4041

42+
private const int BatchSize = 10;
43+
4144
protected override void Configure(Configuration configuration)
4245
{
4346
configuration.SetProperty(Environment.FormatSql, "true");
4447
configuration.SetProperty(Environment.GenerateStatistics, "true");
45-
configuration.SetProperty(Environment.BatchSize, "10");
48+
configuration.SetProperty(Environment.BatchSize, BatchSize.ToString());
4649
#if NET6_0_OR_GREATER
4750
if (_useDbBatch)
4851
{
@@ -275,5 +278,55 @@ public async Task AbstractBatcherLogFormattedSqlAsync()
275278
Assert.That(Sfi.Statistics.PrepareStatementCount, Is.EqualTo(1));
276279
await (CleanupAsync());
277280
}
281+
282+
[Test]
283+
[Description("Inserting exactly BatchSize entities should not throw on commit. See GH-3725.")]
284+
public async Task InsertExactlyBatchSizeEntitiesShouldNotThrowOnCommitAsync()
285+
{
286+
// This test verifies that DbBatchBatcher handles empty batches correctly.
287+
// The bug (GH-3725): When inserting exactly BatchSize entities, the batch auto-executes
288+
// when full (via ExecuteBatchWithTiming), which clears _currentBatch but NOT _batchCommand.
289+
// On commit, ExecuteBatch() is called, sees _batchCommand is set, and calls DoExecuteBatch
290+
// on an empty _currentBatch, causing InvalidOperationException.
291+
292+
using (var session = OpenSession())
293+
using (var transaction = session.BeginTransaction())
294+
{
295+
// Insert exactly BatchSize entities - this fills the batch and triggers auto-execution.
296+
for (int i = 0; i < BatchSize; i++)
297+
{
298+
await (session.SaveAsync(new VerySimple { Id = 1000 + i, Name = $"Test{i}", Weight = i * 1.1 }));
299+
}
300+
301+
// Commit triggers ExecuteBatch() which would fail on empty batch without the fix,
302+
// depending on the driver. It fails with Microsoft.Data.SqlClient by example, not with
303+
// System.Data.SqlClient.
304+
await (transaction.CommitAsync());
305+
}
306+
307+
await (CleanupAsync());
308+
}
309+
310+
[Test]
311+
[Description("Inserting a multiple of BatchSize entities should not throw on commit. See GH-3725.")]
312+
public async Task InsertMultipleOfBatchSizeEntitiesShouldNotThrowOnCommitAsync()
313+
{
314+
// Same issue as above but with multiple full batches
315+
const int batchSize = 10;
316+
const int multiplier = 3;
317+
318+
using (var session = OpenSession())
319+
using (var transaction = session.BeginTransaction())
320+
{
321+
for (int i = 0; i < batchSize * multiplier; i++)
322+
{
323+
await (session.SaveAsync(new VerySimple { Id = 2000 + i, Name = $"Test{i}", Weight = i * 1.1 }));
324+
}
325+
326+
await (transaction.CommitAsync());
327+
}
328+
329+
await (CleanupAsync());
330+
}
278331
}
279332
}

src/NHibernate/AdoNet/DbBatchBatcher.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ public override Task AddToBatchAsync(IExpectation expectation, CancellationToken
115115

116116
protected override void DoExecuteBatch(DbCommand ps)
117117
{
118+
if (_currentBatch.BatchCommands.Count == 0)
119+
{
120+
Expectations.VerifyOutcomeBatched(_totalExpectedRowsAffected, 0, ps);
121+
return;
122+
}
123+
118124
try
119125
{
120126
Log.Debug("Executing batch");
@@ -145,6 +151,12 @@ protected override void DoExecuteBatch(DbCommand ps)
145151
protected override async Task DoExecuteBatchAsync(DbCommand ps, CancellationToken cancellationToken)
146152
{
147153
cancellationToken.ThrowIfCancellationRequested();
154+
if (_currentBatch.BatchCommands.Count == 0)
155+
{
156+
Expectations.VerifyOutcomeBatched(_totalExpectedRowsAffected, 0, ps);
157+
return;
158+
}
159+
148160
try
149161
{
150162
Log.Debug("Executing batch");

0 commit comments

Comments
 (0)