From d2590cf9701cc4241619614a0872e07c3a36248f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 08:55:52 +0000 Subject: [PATCH 1/3] Initial plan From 9f7020741db05129401065c90787473fc1e3bae8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 09:10:22 +0000 Subject: [PATCH 2/3] Fix race condition in ViewCountService with atomic SQL updates Co-authored-by: hishamco <3237266+hishamco@users.noreply.github.com> --- Directory.Packages.props | 1 + .../OrchardCoreContrib.ViewCount.csproj | 1 + .../Services/ViewCountService.cs | 83 ++++++++++++++++++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 438cccb..8ba51e8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + diff --git a/src/OrchardCoreContrib.ViewCount/OrchardCoreContrib.ViewCount.csproj b/src/OrchardCoreContrib.ViewCount/OrchardCoreContrib.ViewCount.csproj index 98c6865..75f05e8 100644 --- a/src/OrchardCoreContrib.ViewCount/OrchardCoreContrib.ViewCount.csproj +++ b/src/OrchardCoreContrib.ViewCount/OrchardCoreContrib.ViewCount.csproj @@ -37,6 +37,7 @@ + diff --git a/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs b/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs index 644891f..d8b5c36 100644 --- a/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs +++ b/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs @@ -4,6 +4,9 @@ using OrchardCoreContrib.Infrastructure; using OrchardCoreContrib.ViewCount.Handlers; using OrchardCoreContrib.ViewCount.Models; +using System.Data.Common; +using YesSql; +using Dapper; namespace OrchardCoreContrib.ViewCount.Services; @@ -17,6 +20,7 @@ namespace OrchardCoreContrib.ViewCount.Services; /// public class ViewCountService( IContentManager contentManager, + ISession session, IEnumerable handlers, ILogger logger) : IViewCountService { @@ -42,12 +46,87 @@ public async Task ViewAsync(ContentItem contentItem) await handlers.InvokeAsync((handler, context) => handler.ViewingAsync(context), context, logger); - contentItem.Content.ViewCountPart.Count = ++count; + // Perform atomic increment directly in the database to avoid race conditions + await IncrementViewCountAtomicallyAsync(contentItem.ContentItemId); - await contentManager.UpdateAsync(contentItem); + // Reload the content item to get the updated count + contentItem = await contentManager.GetAsync(contentItem.ContentItemId); + viewCountPart = contentItem.As(); + count = viewCountPart?.Count ?? count + 1; context = new ViewCountContentContext(contentItem, count); await handlers.InvokeAsync((handler, context) => handler.ViewedAsync(context), context, logger); } + + private async Task IncrementViewCountAtomicallyAsync(string contentItemId) + { + var dialect = session.Store.Configuration.SqlDialect; + var tablePrefix = session.Store.Configuration.TablePrefix; + var schema = session.Store.Configuration.Schema; + var documentTable = $"{tablePrefix}{nameof(ContentItem)}Document"; + + string sql; + + // Use database-specific JSON update functions for atomic increment + if (dialect.Name == "SqlServer") + { + sql = $@" + UPDATE {dialect.QuoteForTableName(documentTable, schema)} + SET {dialect.QuoteForColumnName("Content")} = JSON_MODIFY( + {dialect.QuoteForColumnName("Content")}, + '$.ViewCountPart.Count', + CAST(ISNULL(JSON_VALUE({dialect.QuoteForColumnName("Content")}, '$.ViewCountPart.Count'), '0') AS INT) + 1 + ) + WHERE JSON_VALUE({dialect.QuoteForColumnName("Content")}, '$.ContentItemId') = @ContentItemId"; + } + else if (dialect.Name == "Sqlite") + { + sql = $@" + UPDATE {dialect.QuoteForTableName(documentTable, schema)} + SET {dialect.QuoteForColumnName("Content")} = json_set( + {dialect.QuoteForColumnName("Content")}, + '$.ViewCountPart.Count', + CAST(COALESCE(json_extract({dialect.QuoteForColumnName("Content")}, '$.ViewCountPart.Count'), 0) AS INTEGER) + 1 + ) + WHERE json_extract({dialect.QuoteForColumnName("Content")}, '$.ContentItemId') = @ContentItemId"; + } + else if (dialect.Name == "MySql") + { + sql = $@" + UPDATE {dialect.QuoteForTableName(documentTable, schema)} + SET {dialect.QuoteForColumnName("Content")} = JSON_SET( + {dialect.QuoteForColumnName("Content")}, + '$.ViewCountPart.Count', + CAST(COALESCE(JSON_EXTRACT({dialect.QuoteForColumnName("Content")}, '$.ViewCountPart.Count'), 0) AS UNSIGNED) + 1 + ) + WHERE JSON_EXTRACT({dialect.QuoteForColumnName("Content")}, '$.ContentItemId') = @ContentItemId"; + } + else if (dialect.Name == "PostgreSql") + { + sql = $@" + UPDATE {dialect.QuoteForTableName(documentTable, schema)} + SET {dialect.QuoteForColumnName("Content")} = jsonb_set( + {dialect.QuoteForColumnName("Content")}, + '{{ViewCountPart,Count}}', + to_jsonb(COALESCE(({dialect.QuoteForColumnName("Content")}->>'ViewCountPart'->>'Count')::int, 0) + 1) + ) + WHERE {dialect.QuoteForColumnName("Content")}->>'ContentItemId' = @ContentItemId"; + } + else + { + throw new NotSupportedException($"The SQL dialect '{dialect.Name}' is not supported for atomic view count updates."); + } + + // Use existing session connection. Do not use 'using' or dispose the connection. + var connection = await session.CreateConnectionAsync(); + + var commandDefinition = new CommandDefinition( + commandText: sql, + parameters: new { ContentItemId = contentItemId }, + transaction: session.CurrentTransaction + ); + + await connection.ExecuteAsync(commandDefinition); + } } From 971ae55dca2c999a320d4d19e0ef001d2dd99afb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Jan 2026 09:13:20 +0000 Subject: [PATCH 3/3] Address code review feedback - improve error handling and PostgreSQL syntax Co-authored-by: hishamco <3237266+hishamco@users.noreply.github.com> --- .../Services/ViewCountService.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs b/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs index d8b5c36..4df03c5 100644 --- a/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs +++ b/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs @@ -51,8 +51,12 @@ public async Task ViewAsync(ContentItem contentItem) // Reload the content item to get the updated count contentItem = await contentManager.GetAsync(contentItem.ContentItemId); - viewCountPart = contentItem.As(); - count = viewCountPart?.Count ?? count + 1; + viewCountPart = contentItem?.As(); + + if (viewCountPart != null) + { + count = viewCountPart.Count; + } context = new ViewCountContentContext(contentItem, count); @@ -109,16 +113,18 @@ private async Task IncrementViewCountAtomicallyAsync(string contentItemId) SET {dialect.QuoteForColumnName("Content")} = jsonb_set( {dialect.QuoteForColumnName("Content")}, '{{ViewCountPart,Count}}', - to_jsonb(COALESCE(({dialect.QuoteForColumnName("Content")}->>'ViewCountPart'->>'Count')::int, 0) + 1) + to_jsonb(COALESCE(({dialect.QuoteForColumnName("Content")}#>>'{{ViewCountPart,Count}}')::int, 0) + 1) ) WHERE {dialect.QuoteForColumnName("Content")}->>'ContentItemId' = @ContentItemId"; } else { + logger.LogError("Unsupported SQL dialect '{DialectName}' for atomic view count updates. Supported dialects are: SqlServer, Sqlite, MySql, PostgreSql.", dialect.Name); throw new NotSupportedException($"The SQL dialect '{dialect.Name}' is not supported for atomic view count updates."); } - // Use existing session connection. Do not use 'using' or dispose the connection. + // Use existing session connection managed by YesSql. + // Do not dispose the connection as YesSql manages its lifecycle within the session's transaction scope. var connection = await session.CreateConnectionAsync(); var commandDefinition = new CommandDefinition(