Skip to content

Commit 20926bd

Browse files
authored
FUND-2392 DRC Add resource locking to ensure new versions be created successfully (#151)
* initial commit * update * all drc entities uses xmin concurrency token. Made some small fixes * fixed race condition in old handers. Made other fixes * fixed UnitTest issue * cleanup code and made some small fixes * removed duplicate code * CancellationToken added for the reat of the controller actions * co-pilot fixes * co-pilot fixes * refactored merge in lockable handlers * cleanup * Fixed UnitTest * comment fixed * formatted? * fixed possible race-condition in verzending and gebruiksrecht * 409 Conflict documented for verzending and gebruiksrecht
1 parent 2223eab commit 20926bd

File tree

55 files changed

+1586
-878
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+1586
-878
lines changed

src/OneGround.ZGW.Common.Contracts/v1/ErrorCode.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,6 @@ public static class ErrorCode
6464
public const string SelfForbidden = "self-forbidden";
6565
public const string AmbiguousCatalogi = "ambiguous-catalogi";
6666
public const string FileSize = "file-size";
67-
68-
public const string Other = "other"; // TODO: In some cases I don't know the exact code to use. Where can we find them?
67+
public const string Conflict = "conflict";
68+
public const string Other = "other";
6969
}

src/OneGround.ZGW.Common.Web/Controllers/ZGWControllerBase.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,14 @@ protected void LogInvalidTaalCode(string taalRequested, string taalMapped)
9696
);
9797
}
9898
}
99+
100+
protected static T GetValueFromPartial<T>(dynamic jsonObject, string name, bool caseSensitive = false)
101+
{
102+
var value = jsonObject.Property(name, caseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase)?.Value;
103+
104+
if (value == null)
105+
return default;
106+
107+
return value.ToObject<T>();
108+
}
99109
}

src/OneGround.ZGW.Common.Web/Extensions/ServiceCollection/ZGWApiServiceCollectionExtensions.cs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
using OneGround.ZGW.Common.Web.ErrorHandling;
1414
using OneGround.ZGW.Common.Web.Extensions.ServiceCollection.ZGWApiExtensions;
1515
using OneGround.ZGW.Common.Web.Filters;
16-
using OneGround.ZGW.Common.Web.HealthChecks;
17-
using OneGround.ZGW.Common.Web.HealthChecks.Builder;
1816
using OneGround.ZGW.Common.Web.Middleware;
1917
using OneGround.ZGW.Common.Web.Validations;
2018
using OneGround.ZGW.Common.Web.Versioning;

src/OneGround.ZGW.Common.Web/Filters/ApiExceptionFilter.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.AspNetCore.Mvc.Filters;
1+
using Microsoft.AspNetCore.Mvc.Filters;
2+
using Microsoft.EntityFrameworkCore;
23
using OneGround.ZGW.Common.Exceptions;
34
using OneGround.ZGW.Common.Web.Services;
45

@@ -28,7 +29,11 @@ public override void OnException(ExceptionContext context)
2829
context.Result = _responseBuilder.BadRequest(context.ModelState);
2930
context.ExceptionHandled = true;
3031
}
31-
32+
else if (context.Exception is DbUpdateConcurrencyException)
33+
{
34+
context.Result = _responseBuilder.Conflict();
35+
context.ExceptionHandled = true;
36+
}
3237
base.OnException(context);
3338
}
3439
}

src/OneGround.ZGW.Common.Web/Services/ErrorResponseBuilder.cs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Collections.Generic;
1+
using System.Collections.Generic;
22
using System.Linq;
33
using System.Net;
44
using FluentValidation.Results;
@@ -267,6 +267,43 @@ public JsonResult InternalServerError(string message)
267267
};
268268
}
269269

270+
public JsonResult Conflict()
271+
{
272+
var statusCode = (int)HttpStatusCode.Conflict;
273+
274+
return new JsonResult(
275+
new ErrorResponse
276+
{
277+
Type = $"{BaseUrl}{ErrorCategory.ValidationError}",
278+
Code = ErrorCode.Conflict,
279+
Title = "Resource is vergrendeld.",
280+
Status = statusCode,
281+
}
282+
)
283+
{
284+
StatusCode = statusCode,
285+
};
286+
}
287+
288+
public JsonResult Conflict(IEnumerable<ValidationError> validationErrors)
289+
{
290+
var statusCode = (int)HttpStatusCode.Conflict;
291+
292+
return new JsonResult(
293+
new ErrorResponse
294+
{
295+
Type = $"{BaseUrl}{ErrorCategory.ValidationError}",
296+
Code = ErrorCode.Conflict,
297+
Title = "Resource is vergrendeld.",
298+
Status = statusCode,
299+
InvalidParams = validationErrors.ToList(),
300+
}
301+
)
302+
{
303+
StatusCode = statusCode,
304+
};
305+
}
306+
270307
private static ValidationError MapValidationError(ValidationFailure error)
271308
{
272309
return new ValidationError(error.PropertyName, MapErrorCode(error), MapErrorMessage(error));

src/OneGround.ZGW.Common.Web/Services/IErrorResponseBuilder.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Collections.Generic;
1+
using System.Collections.Generic;
22
using FluentValidation.Results;
33
using Microsoft.AspNetCore.Mvc;
44
using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -96,4 +96,8 @@ BadRequestObjectResult BadRequest(
9696
/// <param name="message"></param>
9797
/// <returns></returns>
9898
JsonResult InternalServerError(string message = "");
99+
100+
JsonResult Conflict();
101+
102+
JsonResult Conflict(IEnumerable<ValidationError> validationErrors);
99103
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using FluentValidation.Results;
4+
using OneGround.ZGW.Common.Contracts.v1;
5+
6+
namespace OneGround.ZGW.Common.Web.Validations;
7+
8+
public static class ValidationResultExtensions
9+
{
10+
public static List<ValidationError> ToValidationErrors(this ValidationResult validationResult)
11+
{
12+
return validationResult
13+
.Errors.Select(e => new ValidationError
14+
{
15+
Name = e.PropertyName,
16+
Code = e.ErrorCode,
17+
Reason = e.ErrorMessage,
18+
})
19+
.ToList();
20+
}
21+
}

src/OneGround.ZGW.Common/Handlers/CommandStatus.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace OneGround.ZGW.Common.Handlers;
1+
namespace OneGround.ZGW.Common.Handlers;
22

33
public enum CommandStatus
44
{
@@ -8,4 +8,5 @@ public enum CommandStatus
88
NotFound = HanderStatusCodes.NotFound,
99
Forbidden = HanderStatusCodes.Forbidden,
1010
Failed = HanderStatusCodes.Failed,
11+
Conflict = HanderStatusCodes.Conflict,
1112
}

src/OneGround.ZGW.Common/Handlers/HanderStatusCodes.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
namespace OneGround.ZGW.Common.Handlers;
1+
namespace OneGround.ZGW.Common.Handlers;
22

33
public static class HanderStatusCodes
44
{
@@ -8,4 +8,5 @@ public static class HanderStatusCodes
88
public const int NotFound = 3;
99
public const int Failed = 4;
1010
public const int Forbidden = 5;
11+
public const int Conflict = 6;
1112
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Linq.Expressions;
5+
using Microsoft.EntityFrameworkCore;
6+
using Npgsql;
7+
8+
namespace OneGround.ZGW.DataAccess;
9+
10+
public static class EfPostgresLockExtensions
11+
{
12+
/// <summary>
13+
/// Applies PostgreSQL row-level locking (FOR UPDATE [SKIP LOCKED]) to prevent concurrent modifications.
14+
/// This locks the rows matching the specified IDs for the duration of the transaction.
15+
/// </summary>
16+
/// <typeparam name="TEntity">The entity type being queried</typeparam>
17+
/// <typeparam name="TKey">The type of the primary key</typeparam>
18+
/// <param name="set">The DbSet to query</param>
19+
/// <param name="context">The database context</param>
20+
/// <param name="keySelector">Expression to select the primary key property</param>
21+
/// <param name="ids">The IDs of rows to lock</param>
22+
/// <param name="skipLocked">If true, skips locked rows instead of waiting</param>
23+
/// <returns>A query with row-level locking applied</returns>
24+
public static IQueryable<TEntity> LockForUpdate<TEntity, TKey>(
25+
this DbSet<TEntity> set,
26+
DbContext context,
27+
Expression<Func<TEntity, TKey>> keySelector,
28+
IEnumerable<TKey> ids,
29+
bool skipLocked = true
30+
)
31+
where TEntity : class
32+
{
33+
// Note: The following check fixes the issue when the method LockForUpdate() is called from a UnitTest:
34+
// System.InvalidOperationException : Query root of type 'FromSqlQueryRootExpression' wasn't handled by provider code.
35+
// This issue happens when using a provider specific method on a different provider where it is not supported.
36+
if (context.IsInMemory())
37+
{
38+
// Simply return the set without applying any locking, as InMemory provider does not support raw SQL or locking semantics.
39+
return set;
40+
}
41+
42+
var entityType = context.Model.FindEntityType(typeof(TEntity));
43+
if (entityType == null)
44+
{
45+
throw new InvalidOperationException($"Entity type '{typeof(TEntity).Name}' was not found in the database model.");
46+
}
47+
48+
var tableName = entityType.GetTableName();
49+
if (string.IsNullOrEmpty(tableName))
50+
{
51+
throw new InvalidOperationException($"Table name for entity type '{typeof(TEntity).Name}' could not be determined.");
52+
}
53+
54+
var schema = entityType.GetSchema() ?? "public";
55+
56+
// Safely extract the property name from the key selector expression
57+
var propertyName = GetPropertyName(keySelector);
58+
59+
var property = entityType.FindProperty(propertyName);
60+
if (property == null)
61+
{
62+
throw new InvalidOperationException(
63+
$"Property '{propertyName}' was not found on entity type '{typeof(TEntity).Name}'. "
64+
+ $"Ensure the key selector expression references a valid property."
65+
);
66+
}
67+
68+
var keyColumn = property.GetColumnName();
69+
if (string.IsNullOrEmpty(keyColumn))
70+
{
71+
throw new InvalidOperationException(
72+
$"Column name for property '{propertyName}' on entity type '{typeof(TEntity).Name}' could not be determined."
73+
);
74+
}
75+
76+
var skip = skipLocked ? " SKIP LOCKED" : "";
77+
78+
// Include xmin for concurrency token support
79+
// Use PostgreSQL's ANY array syntax for efficient parameter passing
80+
var sql =
81+
$@"
82+
SELECT *, xmin
83+
FROM ""{schema}"".""{tableName}""
84+
WHERE ""{keyColumn}"" = ANY(@ids)
85+
ORDER BY ""{keyColumn}""
86+
FOR UPDATE{skip}";
87+
88+
return set.FromSqlRaw(sql, new NpgsqlParameter("ids", ids.ToArray()));
89+
}
90+
91+
/// <summary>
92+
/// Extracts the property name from a lambda expression, handling UnaryExpression (conversions/boxing).
93+
/// </summary>
94+
/// <typeparam name="TEntity">The entity type</typeparam>
95+
/// <typeparam name="TKey">The property type</typeparam>
96+
/// <param name="expression">The lambda expression selecting the property</param>
97+
/// <returns>The property name</returns>
98+
/// <exception cref="ArgumentException">Thrown when the expression is not a valid property selector</exception>
99+
private static string GetPropertyName<TEntity, TKey>(Expression<Func<TEntity, TKey>> expression)
100+
{
101+
if (expression == null)
102+
{
103+
throw new ArgumentNullException(nameof(expression), "Key selector expression cannot be null.");
104+
}
105+
106+
// Unwrap the lambda body
107+
Expression body = expression.Body;
108+
109+
// Handle UnaryExpression (e.g., boxing, implicit conversions like int to object)
110+
if (body is UnaryExpression unaryExpression)
111+
{
112+
body = unaryExpression.Operand;
113+
}
114+
115+
// The body should now be a MemberExpression
116+
if (body is MemberExpression memberExpression)
117+
{
118+
return memberExpression.Member.Name;
119+
}
120+
121+
// If we still don't have a MemberExpression, the selector is invalid
122+
throw new ArgumentException(
123+
$"Key selector expression must be a simple property accessor (e.g., 'x => x.Id'). "
124+
+ $"Received expression type: {expression.Body.GetType().Name}",
125+
nameof(expression)
126+
);
127+
}
128+
129+
/// <summary>
130+
/// Determines if the DbContext is using the InMemory provider, which does not support raw SQL or locking semantics.
131+
/// </summary>
132+
/// <param name="context">The database context</param>
133+
/// <returns>True if running within in-memory context (UnitTest), fFalse oterwise</returns>
134+
private static bool IsInMemory(this DbContext context)
135+
{
136+
return context.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory";
137+
}
138+
}

0 commit comments

Comments
 (0)