Skip to content

RIC-T39 Backend IC work#417

Open
ucswift wants to merge 2 commits into
masterfrom
develop
Open

RIC-T39 Backend IC work#417
ucswift wants to merge 2 commits into
masterfrom
develop

Conversation

@ucswift

@ucswift ucswift commented Jun 24, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Added an offline delta-sync endpoint (api/v{version}/Sync/Changes) that returns incident command updates and related ad-hoc resource changes using a cursor.
    • Added an optional idempotency key to check-ins so replayed requests can deduplicate safely.
  • Bug Fixes

    • Improved incident command persistence to reliably insert/update the correct rows and stamp change timestamps for sync reconciliation.
    • Added soft-delete handling for structure nodes and ensured deleted items are excluded from normal results.

@request-info

request-info Bot commented Jun 24, 2026

Copy link
Copy Markdown

Thanks for opening this, but we'd appreciate a little more information. Could you update it with more details?

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds offline delta sync for incident command and ad-hoc resources, plus idempotent replay handling for check-in requests.

Changes

Offline Sync Change Tracking

Layer / File(s) Summary
Shared contracts
Core/Resgrid.Model/IChangeTracked.cs, Core/Resgrid.Model/IncidentCommand/*, Core/Resgrid.Model/Services/IIncidentCommandService.cs, Core/Resgrid.Model/Services/IIncidentResourcesService.cs
IChangeTracked, change-cursor fields, IncidentCommandChanges, and the delta-since service methods are added to the model layer.
Command tracking
Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs, Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs, Core/Resgrid.Services/IncidentCommandService.cs, Core/Resgrid.Services/IncidentVoiceService.cs
The command schema and service write paths add ModifiedOn stamping, soft-delete nodes, and a delta-sync payload.
Ad-hoc tracking
Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs, Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs, Core/Resgrid.Services/IncidentResourcesService.cs
The ad-hoc resource schema and service write paths add ModifiedOn stamping, replay handling, and ad-hoc change retrieval.
Sync API
Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs, Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs, Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
The v4 sync controller and response model aggregate command and ad-hoc changes into the offline delta endpoint.

Check-in Replay Idempotency

Layer / File(s) Summary
Check-in replay
Core/Resgrid.Model/CheckInRecord.cs, Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs, Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs, Core/Resgrid.Services/CheckInTimerService.cs, Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs, Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs, Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
The check-in request, controller, service, schema, and docs add IdempotencyKey and replay handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Resgrid/Core#312: Related check-in timer flow and CheckInRecord persistence around PerformCheckInAsync, which this PR extends with idempotency handling.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.81% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is vague and does not describe the specific backend changes in this PR. Rename it to reflect the main change, such as offline sync and check-in idempotency support.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (1)
Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs (1)

25-40: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Same NULL ModifiedOn backfill gap as the SQL Server migration.

This PostgreSQL migration mirrors M0081_AddIncidentCommandChangeTracking and shares the same issue: existing rows receive NULL ModifiedOn and will be excluded from the initial delta/full pull. Apply the same backfill here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs`
around lines 25 - 40, The PostgreSQL migration Up method in
M0081_AddIncidentCommandChangeTrackingPg adds ModifiedOn as nullable but does
not backfill existing rows, so they stay NULL and miss the initial sync. Update
the migration to set ModifiedOn for all pre-existing rows in each table from
ChangeTrackedTables, using the same backfill approach as the SQL Server
migration before or after adding the column. Keep the existing
CommandStructureNodes DeletedOn addition unchanged, and make sure the backfill
is applied consistently within Up.
🧹 Nitpick comments (2)
Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs (1)

12-18: 🗄️ Data Integrity & Integration | 🔵 Trivial | 🏗️ Heavy lift

Consider a partial unique index to enforce idempotency at the DB level.

A plain nullable column does not prevent duplicate inserts under concurrent replay (see PerformCheckInAsync). A filtered unique index on IdempotencyKey (scoped per department, where non-null) would make the dedup guarantee robust and let the service catch the violation deterministically.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs`
around lines 12 - 18, Add a DB-level idempotency guard in
M0082_AddCheckInRecordIdempotencyKey instead of only adding the nullable
IdempotencyKey column. Update the migration to create a filtered unique index on
CheckInRecords.IdempotencyKey, scoped with DepartmentId (or the same tenant
scope used by PerformCheckInAsync) and excluding null values, so concurrent
replays cannot insert duplicates. Make sure the index is created alongside the
existing AddColumn change and that the service layer can deterministically
handle the unique-constraint violation.
Core/Resgrid.Services/IncidentCommandService.cs (1)

365-396: 🚀 Performance & Scalability | 🔵 Trivial

Delta read fans out to 8 full department-scoped loads + in-memory filtering on every poll.

Each GetChangesSinceAsync call pulls every command/node/assignment/objective/timer/annotation/role/timeline row for the department and filters in memory. Since soft-deleted/closed/released rows are intentionally never purged, these sets grow unbounded over the life of the department, so polling clients pay an ever-increasing cost. The class header already flags per-CallId/predicate repository queries as a follow-up — consider a ModifiedOn > @since`` (indexed) repository query before this ships to high-traffic departments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Services/IncidentCommandService.cs` around lines 365 - 396,
GetChangesSinceAsync in IncidentCommandService is loading entire
department-scoped collections and filtering them in memory, which will scale
poorly as data grows. Update the repository calls for commands, nodes,
assignments, objectives, timers, annotations, roles, and timeline entries to
query by sinceUtc/ModifiedOn (or OccurredOn for timeline) at the repository
level instead of using GetAllByDepartmentIdAsync plus Changed filtering. Keep
the existing change-shaping logic, but move the predicate into the repository
methods or add new indexed query methods so the method only returns delta rows.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs`:
- Around line 10-11: The XML doc comment on IncidentCommandChanges contradicts
the actual sync behavior exposed by AdHocUnits, AdHocPersonnel, and
GetAdHocChangesSinceAsync. Update the remark to describe these ad-hoc resources
as delta-tracked by ModifiedOn and returned through the incremental changes
payload, rather than saying they are not change-tracked or require a full
refetch. Keep the wording aligned with the model fields and the filtering logic
so clients can rely on the sync contract.

In `@Core/Resgrid.Services/CheckInTimerService.cs`:
- Around line 326-332: The idempotency guard in CheckInTimerService is only
doing a read-before-write check, so concurrent replays can still insert
duplicates. Add a filtered unique index for the check-in record idempotency key
on DepartmentId and IdempotencyKey with IdempotencyKey IS NOT NULL in the
M0082_AddCheckInRecordIdempotencyKey migration, then update the
CheckInTimerService insert flow to catch the unique-violation and return the
existing record instead of failing.

In `@Core/Resgrid.Services/IncidentCommandService.cs`:
- Around line 363-396: The delta-pull logic in IncidentCommandService currently
filters everything through the Changed(IChangeTracked) predicate, so rows with
null ModifiedOn are skipped even during the initial full sync. Update this flow
so the sinceUtc initial-cursor case (DateTime.MinValue) returns all rows or
otherwise includes legacy null-ModifiedOn records, and keep the filtering
behavior for normal incremental syncs; use the existing Changed helper and the
repository fetch blocks for commands, nodes, assignments, objectives, timers,
annotations, and roles to apply the fix consistently.

In
`@Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs`:
- Around line 26-40: Backfill the new ModifiedOn column in
M0081_AddIncidentCommandChangeTracking so existing rows are visible to
GetChangesSinceAsync on the first sync. Update the Up() migration logic around
ChangeTrackedTables and the CommandStructureNodes alter to seed ModifiedOn for
pre-existing records, not just add it as nullable, so current
commands/nodes/assignments/objectives/timers/annotations/roles are included in
the initial delta pull.

In
`@Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs`:
- Around line 14-23: The migration that adds `ModifiedOn` to the ad-hoc tables
leaves existing rows with null timestamps, so they never satisfy the
`GetAdHocChangesSinceAsync` change filter and won’t sync on reconnect or first
pull. Update `M0083_AddAdHocResourceChangeTracking.Up()` to backfill
`ModifiedOn` for pre-existing rows in the affected tables (`IncidentAdHocUnits`
and `IncidentAdHocPersonnel`) after adding the column, using an appropriate
existing timestamp source such as `CreatedOn`, `ReleasedOn`, or the current UTC
time so legacy records participate in delta sync.

In
`@Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs`:
- Around line 19-21: The M0083_AddAdHocResourceChangeTrackingPg migration adds
the nullable ModifiedOn column in the table loop but does not populate it for
existing records, so pre-existing rows will remain NULL and be skipped by
/Sync/Changes. Update the migration logic around the Schema.Table(t).Exists()
and Alter.Table(t).AddColumn("ModifiedOn") flow to backfill a sensible timestamp
for existing rows after adding the column, using the same migration path and
table iteration that already handles the column creation.

---

Duplicate comments:
In
`@Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs`:
- Around line 25-40: The PostgreSQL migration Up method in
M0081_AddIncidentCommandChangeTrackingPg adds ModifiedOn as nullable but does
not backfill existing rows, so they stay NULL and miss the initial sync. Update
the migration to set ModifiedOn for all pre-existing rows in each table from
ChangeTrackedTables, using the same backfill approach as the SQL Server
migration before or after adding the column. Keep the existing
CommandStructureNodes DeletedOn addition unchanged, and make sure the backfill
is applied consistently within Up.

---

Nitpick comments:
In `@Core/Resgrid.Services/IncidentCommandService.cs`:
- Around line 365-396: GetChangesSinceAsync in IncidentCommandService is loading
entire department-scoped collections and filtering them in memory, which will
scale poorly as data grows. Update the repository calls for commands, nodes,
assignments, objectives, timers, annotations, roles, and timeline entries to
query by sinceUtc/ModifiedOn (or OccurredOn for timeline) at the repository
level instead of using GetAllByDepartmentIdAsync plus Changed filtering. Keep
the existing change-shaping logic, but move the predicate into the repository
methods or add new indexed query methods so the method only returns delta rows.

In
`@Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs`:
- Around line 12-18: Add a DB-level idempotency guard in
M0082_AddCheckInRecordIdempotencyKey instead of only adding the nullable
IdempotencyKey column. Update the migration to create a filtered unique index on
CheckInRecords.IdempotencyKey, scoped with DepartmentId (or the same tenant
scope used by PerformCheckInAsync) and excluding null values, so concurrent
replays cannot insert duplicates. Make sure the index is created alongside the
existing AddColumn change and that the service layer can deterministically
handle the unique-constraint violation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3951bf55-d737-4e8a-a00d-815e42c39750

📥 Commits

Reviewing files that changed from the base of the PR and between 01beb00 and 118524e.

⛔ Files ignored due to path filters (5)
  • Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs is excluded by !**/Tests/**
  • docs/architecture/offline-first-architecture.md is excluded by !**/*.md
📒 Files selected for processing (25)
  • Core/Resgrid.Model/CheckInRecord.cs
  • Core/Resgrid.Model/IChangeTracked.cs
  • Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs
  • Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs
  • Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs
  • Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs
  • Core/Resgrid.Model/IncidentCommand/IncidentRole.cs
  • Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs
  • Core/Resgrid.Model/Services/IIncidentCommandService.cs
  • Core/Resgrid.Model/Services/IIncidentResourcesService.cs
  • Core/Resgrid.Services/CheckInTimerService.cs
  • Core/Resgrid.Services/IncidentCommandService.cs
  • Core/Resgrid.Services/IncidentResourcesService.cs
  • Core/Resgrid.Services/IncidentVoiceService.cs
  • Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs
  • Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs
  • Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs
  • Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs
  • Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs
  • Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs
  • Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs
  • Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs
  • Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs
  • Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs
  • Web/Resgrid.Web.Services/Resgrid.Web.Services.xml

Comment on lines +10 to +11
/// Ad-hoc resources are not change-tracked and are pulled separately (full refetch). See
/// docs/architecture/offline-first-architecture.md.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc comment contradicts the model and the actual delta behavior.

The remark says ad-hoc resources "are not change-tracked and are pulled separately (full refetch)", yet this payload exposes AdHocUnits/AdHocPersonnel (Lines 32-34) and GetAdHocChangesSinceAsync filters them by ModifiedOn (delta, not full refetch). This will mislead clients implementing the sync contract.

📝 Suggested doc fix
-		/// Soft-deleted / closed / released rows ARE included (with their state columns set) so the client can remove or
-		/// update them locally. The client stores <see cref="ServerTimestampMs"/> and passes it back as the next `since`.
-		/// Ad-hoc resources are not change-tracked and are pulled separately (full refetch). See
-		/// docs/architecture/offline-first-architecture.md.
+		/// Soft-deleted / closed / released rows ARE included (with their state columns set) so the client can remove or
+		/// update them locally. The client stores <see cref="ServerTimestampMs"/> and passes it back as the next `since`.
+		/// Ad-hoc resources are change-tracked via ModifiedOn and delta-pulled alongside command rows (aggregated by
+		/// SyncController). See docs/architecture/offline-first-architecture.md.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Ad-hoc resources are not change-tracked and are pulled separately (full refetch). See
/// docs/architecture/offline-first-architecture.md.
/// Soft-deleted / closed / released rows ARE included (with their state columns set) so the client can remove or
/// update them locally. The client stores <see cref="ServerTimestampMs"/> and passes it back as the next `since`.
/// Ad-hoc resources are change-tracked via ModifiedOn and delta-pulled alongside command rows (aggregated by
/// SyncController). See docs/architecture/offline-first-architecture.md.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs` around lines 10
- 11, The XML doc comment on IncidentCommandChanges contradicts the actual sync
behavior exposed by AdHocUnits, AdHocPersonnel, and GetAdHocChangesSinceAsync.
Update the remark to describe these ad-hoc resources as delta-tracked by
ModifiedOn and returned through the incremental changes payload, rather than
saying they are not change-tracked or require a full refetch. Keep the wording
aligned with the model fields and the filtering logic so clients can rely on the
sync contract.

Comment thread Core/Resgrid.Services/CheckInTimerService.cs
Comment thread Core/Resgrid.Services/IncidentCommandService.cs Outdated
Comment on lines +26 to +40
public override void Up()
{
foreach (var table in ChangeTrackedTables)
{
if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists())
{
Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable();
}
}

if (Schema.Table("CommandStructureNodes").Exists() && !Schema.Table("CommandStructureNodes").Column("DeletedOn").Exists())
{
Alter.Table("CommandStructureNodes").AddColumn("DeletedOn").AsDateTime2().Nullable();
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the command-side delta reader to see how null ModifiedOn is handled
fd -t f 'IncidentCommandService.cs' Core/Resgrid.Services --exec sed -n '320,400p' {}

Repository: Resgrid/Core

Length of output: 3732


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the sync controller cursor handling and the migration context
fd -t f 'SyncController.cs' Core/Resgrid.* --exec sed -n '1,240p' {}
printf '\n---\n'
fd -t f 'M0081_AddIncidentCommandChangeTracking.cs' Providers/Resgrid.Providers.Migrations --exec sed -n '1,220p' {}

Repository: Resgrid/Core

Length of output: 2018


Backfill ModifiedOn for existing rows in M0081_AddIncidentCommandChangeTracking.cs.
GetChangesSinceAsync only surfaces rows with ModifiedOn.HasValue && ModifiedOn > sinceUtc, so adding the column as nullable leaves all pre-existing commands/nodes/assignments/objectives/timers/annotations/roles invisible to the initial delta pull. Seed the new column during Up() so current board state is included on first sync.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs`
around lines 26 - 40, Backfill the new ModifiedOn column in
M0081_AddIncidentCommandChangeTracking so existing rows are visible to
GetChangesSinceAsync on the first sync. Update the Up() migration logic around
ChangeTrackedTables and the CommandStructureNodes alter to seed ModifiedOn for
pre-existing records, not just add it as nullable, so current
commands/nodes/assignments/objectives/timers/annotations/roles are included in
the initial delta pull.

Comment on lines +14 to +23
public override void Up()
{
foreach (var table in Tables)
{
if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists())
{
Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable();
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Pre-existing ad-hoc rows will never appear in delta sync.

ModifiedOn is added as nullable with no backfill, so any IncidentAdHocUnits/IncidentAdHocPersonnel rows created before this migration keep ModifiedOn = NULL. The downstream Changed predicate in GetAdHocChangesSinceAsync requires e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc, so those rows are excluded even on an initial full pull (since <= 0DateTime.MinValue). A reconnecting client would silently miss all pre-migration ad-hoc resources until they are touched again.

Consider backfilling ModifiedOn (e.g. from CreatedOn/ReleasedOn, or GETUTCDATE()) in Up() so existing rows participate in the first sync.

🛠️ Possible backfill
 				if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists())
 				{
 					Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable();
+					Execute.Sql($"UPDATE [{table}] SET [ModifiedOn] = COALESCE([ReleasedOn], [CreatedOn]) WHERE [ModifiedOn] IS NULL");
 				}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public override void Up()
{
foreach (var table in Tables)
{
if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists())
{
Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable();
}
}
}
public override void Up()
{
foreach (var table in Tables)
{
if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists())
{
Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable();
Execute.Sql($"UPDATE [{table}] SET [ModifiedOn] = COALESCE([ReleasedOn], [CreatedOn]) WHERE [ModifiedOn] IS NULL");
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs`
around lines 14 - 23, The migration that adds `ModifiedOn` to the ad-hoc tables
leaves existing rows with null timestamps, so they never satisfy the
`GetAdHocChangesSinceAsync` change filter and won’t sync on reconnect or first
pull. Update `M0083_AddAdHocResourceChangeTracking.Up()` to backfill
`ModifiedOn` for pre-existing rows in the affected tables (`IncidentAdHocUnits`
and `IncidentAdHocPersonnel`) after adding the column, using an appropriate
existing timestamp source such as `CreatedOn`, `ReleasedOn`, or the current UTC
time so legacy records participate in delta sync.

Comment on lines +19 to +21
if (Schema.Table(t).Exists() && !Schema.Table(t).Column("ModifiedOn".ToLower()).Exists())
{
Alter.Table(t).AddColumn("ModifiedOn".ToLower()).AsDateTime2().Nullable();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

FluentMigrator 7.0.0 Postgres generator AsDateTime2 column type mapping

💡 Result:

In FluentMigrator 7.0.0, the AsDateTime2() column type extension is supported and maps to the timestamp data type when using the PostgreSQL generator [1][2]. While AsDateTime2() is explicitly available in the FluentMigrator API [1][3], the PostgreSQL generator maps DbType.DateTime2 to the standard PostgreSQL timestamp type [2]. This behavior is consistent across recent versions of FluentMigrator, as PostgreSQL's timestamp type effectively covers the functional requirements typically associated with DateTime2 in other SQL dialects [4][2]. If you require specific PostgreSQL timestamp behaviors—such as time zone awareness—it is recommended to use AsDateTimeOffset(), which maps to the PostgreSQL timestamptz (timestamp with time zone) type [2]. For scenarios requiring database-specific precision or custom timestamp definitions that the built-in generator does not provide, you can use the AsCustom("type_name") method to explicitly define the column type in your migration [1][5]. Top results: [1], [3], [2]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target migration and the related SQL Server migration for the same version.
git ls-files 'Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs' \
             'Providers/Resgrid.Providers.MigrationsSql/Migrations/M0083_AddAdHocResourceChangeTracking.cs' \
             'Providers/Resgrid.Providers.Migrations*/Migrations/M0083_AddAdHocResourceChangeTracking*.cs'

echo '--- Pg migration outline ---'
ast-grep outline Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs --view expanded || true

echo '--- Sql migration outline ---'
ast-grep outline Providers/Resgrid.Providers.MigrationsSql/Migrations/M0083_AddAdHocResourceChangeTracking.cs --view expanded || true

echo '--- Pg migration lines ---'
sed -n '1,220p' Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs

echo '--- Sql migration lines ---'
sed -n '1,240p' Providers/Resgrid.Providers.MigrationsSql/Migrations/M0083_AddAdHocResourceChangeTracking.cs

Repository: Resgrid/Core

Length of output: 1923


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If the migrations differ, check whether the Pg migration also backfills the new column.
rg -n "ModifiedOn|Update.*ModifiedOn|Set.*ModifiedOn|Backfill|Execute\.Sql|Update\(" Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs Providers/Resgrid.Providers.MigrationsSql/Migrations/M0083_AddAdHocResourceChangeTracking.cs

Repository: Resgrid/Core

Length of output: 1203


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the shared migration counterpart to see whether it performs a backfill or uses a different type.
sed -n '1,240p' Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs

Repository: Resgrid/Core

Length of output: 1174


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the PostgreSQL generator maps DateTime2 to timestamp in the bundled FluentMigrator sources, if present.
rg -n "DateTime2|timestamp" -g '*/PostgresTypeMap.cs' -g '*/Postgres*.cs' .

Repository: Resgrid/Core

Length of output: 150


Add a backfill for existing rows
Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs:19-21 adds a nullable ModifiedOn column, but leaves existing rows NULL. Seed the column for pre-existing records if they should participate in /Sync/Changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs`
around lines 19 - 21, The M0083_AddAdHocResourceChangeTrackingPg migration adds
the nullable ModifiedOn column in the table loop but does not populate it for
existing records, so pre-existing rows will remain NULL and be skipped by
/Sync/Changes. Update the migration logic around the Schema.Table(t).Exists()
and Alter.Table(t).AddColumn("ModifiedOn") flow to backfill a sensible timestamp
for existing rows after adding the column, using the same migration path and
table iteration that already handles the column creation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Core/Resgrid.Services/CheckInTimerService.cs`:
- Around line 341-350: The replay-adoption catch in CheckInTimerService’s save
flow is too broad because it catches every exception and can mask real failures
as successful idempotent replays. Narrow the exception filter around the
SaveOrUpdateAsync path to only handle PostgreSQL unique-constraint violations by
catching Npgsql.PostgresException and checking SqlState == "23505" together with
a non-empty record.IdempotencyKey; keep the existing winner lookup logic, but
let all other exceptions propagate immediately so non-unique-key errors are not
swallowed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7a28cb24-6850-4249-bcd8-4a404bd2208e

📥 Commits

Reviewing files that changed from the base of the PR and between 118524e and d70e9a1.

⛔ Files ignored due to path filters (3)
  • Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs is excluded by !**/Tests/**
  • Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs is excluded by !**/Tests/**
📒 Files selected for processing (5)
  • Core/Resgrid.Services/CheckInTimerService.cs
  • Core/Resgrid.Services/IncidentCommandService.cs
  • Core/Resgrid.Services/IncidentResourcesService.cs
  • Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs
  • Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs
🚧 Files skipped from review as they are similar to previous changes (3)
  • Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs
  • Core/Resgrid.Services/IncidentResourcesService.cs
  • Core/Resgrid.Services/IncidentCommandService.cs

Comment on lines +341 to +350
catch (Exception) when (!string.IsNullOrWhiteSpace(record.IdempotencyKey))
{
// The in-memory pre-check above is check-then-insert and races under concurrent replays; the filtered
// unique index UX_CheckInRecords_Department_IdempotencyKey is the real guard. If we lost the race,
// adopt the winner — same idempotent result as the pre-check — rather than surfacing a 500 (which would
// just trigger another retry).
var existing = await _recordRepository.GetByCallIdAsync(record.CallId);
var winner = existing?.FirstOrDefault(r => r.IdempotencyKey == record.IdempotencyKey);
if (winner != null)
return winner;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify which provider/repository exception types represent unique constraint violations
# so the catch filter can be narrowed without guessing.
rg -nP -C3 '(SaveOrUpdateAsync|SqlException|PostgresException|DbUpdateException|unique constraint|duplicate key|2601|2627|23505)' Core Providers -g '*.cs'

Repository: Resgrid/Core

Length of output: 50369


🏁 Script executed:

python3 - <<'PY'
code_snippet = """
			try
			{
				saved = await _recordRepository.SaveOrUpdateAsync(record, cancellationToken);
			}
			catch (Exception) when (!string.IsNullOrWhiteSpace(record.IdempotencyKey))
			{
				var existing = await _recordRepository.GetByCallIdAsync(record.CallId);
				var winner = existing?.FirstOrDefault(r => r.IdempotencyKey == record.IdempotencyKey);
				if (winner != null)
					return winner;
				throw;
			}
"""

# Analyze the behavior of `throw` in this specific context
analysis = """
The snippet uses `catch (Exception)` without specifying a variable name.
Inside the block:
1. `var existing = await ...` is an I/O call. If this call fails, an *new* exception is awaited.
   - If it throws an `OperationCanceledException`, that exception bubbles up immediately. The original `Exception` is effectively lost/swapped for the new failure if the await fails, or the new one propagates.
   - If it succeeds but `winner` is null, the code hits `throw;`.
2. C# `throw;` with no arguments re-throws the *original* exception caught by the `catch` block (the one from `SaveOrUpdateAsync`).
3. Therefore, if `winner` is null, the original exception propagates correctly.

However, the risk remains if `SaveOrUpdateAsync` throws an exception that is NOT a unique violation (e.g., FK constraint violation "23503", Serialization error, or Timeout), and `existing` query *successfully* returns a record (perhaps from a different idempotency key or just a stale read).

If `winner` is found (even if it's the wrong idempotency key? No, the code checks `r.IdempotencyKey == record.IdempotencyKey`), then it returns the winner.
The logic `if (winner != null)` only returns if a record matches the key.
If the original exception was a Timeout, but `winner` happens to exist in the DB with the matching key (from a previous retry that partially completed), the current code treats it as a success.

CRITICAL ISSUE: The exception filter `when (!string.IsNullOrWhiteSpace(record.IdempotencyKey))` applies to the ENTRY of the catch block.
It does NOT guard against:
1. Non-unique exceptions (like Foreign Keys) where a matching record happens to exist.
2. It is safer to check the exception type explicitly.
"""
print(analysis)
PY

Repository: Resgrid/Core

Length of output: 1853


Limit retry adoption strictly to unique-key violations.

The current retry logic catches all exceptions regardless of type, risking the masking of valid failures (e.g., timeouts, foreign key violations) as successful replays if a matching row happens to exist. For example, if a transaction times out but a partial write created the record, the code returns success even though the original operation failed with a non-unique-key error.

Refine the exception handling to filter only for PostgreSQL unique constraint violations (SqlState == "23505") to ensure other errors propagate immediately.

Proposed Fix
try
{
    saved = await _recordRepository.SaveOrUpdateAsync(record, cancellationToken);
}
catch (Npgsql.PostgresException ex) when (ex.SqlState == "23505" && !string.IsNullOrWhiteSpace(record.IdempotencyKey))
{
    var existing = await _recordRepository.GetByCallIdAsync(record.CallId, cancellationToken);
    var winner = existing?.FirstOrDefault(r => r.IdempotencyKey == record.IdempotencyKey);
    if (winner != null)
        return winner;

    throw;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Services/CheckInTimerService.cs` around lines 341 - 350, The
replay-adoption catch in CheckInTimerService’s save flow is too broad because it
catches every exception and can mask real failures as successful idempotent
replays. Narrow the exception filter around the SaveOrUpdateAsync path to only
handle PostgreSQL unique-constraint violations by catching
Npgsql.PostgresException and checking SqlState == "23505" together with a
non-empty record.IdempotencyKey; keep the existing winner lookup logic, but let
all other exceptions propagate immediately so non-unique-key errors are not
swallowed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant