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(