Fix stale hash/idempotency regressions and add sync-loop regression coverage#80
Fix stale hash/idempotency regressions and add sync-loop regression coverage#80
Conversation
Agent-Logs-Url: https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/sessions/86ad7ece-bec0-429d-a50a-3d4ff8092bde Co-authored-by: BoBiene <23037659+BoBiene@users.noreply.github.com>
Agent-Logs-Url: https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/sessions/2878be68-07b9-4a1d-aaf9-3e5d60bebeb2 Co-authored-by: BoBiene <23037659+BoBiene@users.noreply.github.com>
Agent-Logs-Url: https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/sessions/5052719e-3481-45f1-807f-a4a9760b84f4 Co-authored-by: BoBiene <23037659+BoBiene@users.noreply.github.com>
Agent-Logs-Url: https://github.com/PTCInc/Kepware-ConfigAPI-SDK-dotnet/sessions/5052719e-3481-45f1-807f-a4a9760b84f4 Co-authored-by: BoBiene <23037659+BoBiene@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes stale-hash/idempotency regressions that could cause repeated sync-loop behavior (#78) and adds regression coverage to prevent reintroductions (#79).
Changes:
- Invalidate cached entity hashes on state mutation and normalize nested dynamic properties before hashing/diffing.
- Update project compare/apply to trigger project-property updates based on effective property diffs (not raw hash mismatch alone).
- Add serializer/storage/sync-loop idempotency regression tests (YAML/CSV roundtrip, compare/apply no-op, sync loop protection).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| KepwareSync.Service/Kepware.SyncService.csproj | Exposes internals to the test assembly to enable sync-service regression tests. |
| Kepware.Api/Model/BaseEntity.cs | Adds hash invalidation on key mutations and ensures normalization occurs before hash/diff operations. |
| Kepware.Api/ClientHandler/ProjectApiHandler.cs | Switches project-property update decision from hash mismatch to effective diff presence. |
| Kepware.Api.Test/Util/EntityCompareTests.cs | Adds regression test for stale cached hash after entity mutation. |
| Kepware.Api.Test/Sync/SyncIdempotencyTests.cs | Adds sync-loop/idempotency regression tests (net10-only). |
| Kepware.Api.Test/Serializer/RoundtripIdempotencyTests.cs | Adds YAML/CSV roundtrip idempotency tests to ensure stable hashes/output. |
| Kepware.Api.Test/Kepware.Api.Test.csproj | Adds net10-only reference to SyncService project for the new sync tests. |
| var targetProject = await roundtrippedProject.CloneAsync(); | ||
| AssignOwners(targetProject); | ||
|
|
||
| var sourceTag = roundtrippedProject.Channels![0].Devices![0].Tags![0]; |
There was a problem hiding this comment.
sourceTag is declared but never used. With warnings treated as errors in Kepware.Api.Test.csproj, this will fail the build (CS0219). Remove the variable or use it (e.g., to derive the endpoint/expected diff).
| var sourceTag = roundtrippedProject.Channels![0].Devices![0].Tags![0]; |
| /// Dynamic properties associated with the entity. | ||
| /// </summary> | ||
| [JsonExtensionData] | ||
| public Dictionary<string, JsonElement> DynamicProperties { get; set; } = new(); | ||
| public Dictionary<string, JsonElement> DynamicProperties | ||
| { | ||
| get => _dynamicProperties; | ||
| set | ||
| { | ||
| _dynamicProperties = value ?? []; | ||
| _dynamicPropertiesNormalized = false; | ||
| InvalidateHash(); | ||
| } | ||
| } |
There was a problem hiding this comment.
DynamicProperties exposes a mutable Dictionary. If a caller mutates it directly (e.g., entity.DynamicProperties["k"] = ... / .Remove(...)) after Hash has been computed, the cached hash will not be invalidated, which can reintroduce stale-hash/idempotency issues. Consider returning an IReadOnlyDictionary (and mutating only through SetDynamicProperty/helpers) or wrapping mutations so they always call InvalidateHash().
| var result = new ProjectCompareAndApplyResult(); | ||
| var projectPropertyDiff = sourceProject.GetUpdateDiff(projectFromApi); | ||
|
|
||
| if (sourceProject.Hash != projectFromApi.Hash) | ||
| if (projectPropertyDiff.Count > 0) | ||
| { | ||
| m_logger.LogInformation("Project properties has changed. Updating project properties..."); | ||
| var projectPropertyFailure = await SetProjectPropertiesDetailedAsync(sourceProject, cancellationToken: cancellationToken).ConfigureAwait(false); |
There was a problem hiding this comment.
projectPropertyDiff is computed here but then SetProjectPropertiesDetailedAsync reloads the current project from the API and recomputes a diff again. Since you already have projectFromApi and the computed diff, consider avoiding the duplicate diff computation and extra GET by adding an overload that accepts the already-loaded current project (and/or the precomputed diff).
| if (sourceProject.Hash != projectFromApi.Hash) | ||
| if (projectPropertyDiff.Count > 0) | ||
| { | ||
| m_logger.LogInformation("Project properties has changed. Updating project properties..."); |
There was a problem hiding this comment.
Log message grammar: "Project properties has changed" should be "Project properties have changed" (or "Project properties changed") for clarity/searchability.
| m_logger.LogInformation("Project properties has changed. Updating project properties..."); | |
| m_logger.LogInformation("Project properties have changed. Updating project properties..."); |
| public async Task CompareAndApply_ProjectHashMismatchWithoutProjectPropertyDiff_ShouldReportNoChanges() | ||
| { | ||
| ConfigureConnectedClient(); | ||
|
|
||
| var sourceProject = CreateProjectPropertiesOnly(string.Empty); | ||
| sourceProject.Description = "Transient description"; | ||
| _ = sourceProject.Hash; | ||
| sourceProject.Description = string.Empty; | ||
|
|
There was a problem hiding this comment.
This test name implies it is exercising a "hash mismatch without effective diff" scenario, but the current arrange code doesn’t assert (or reliably create) a hash mismatch anymore now that Description mutations invalidate the cached hash. Either rename the test to reflect what it actually verifies (no-op compare/apply when there’s no effective diff), or adjust the setup to force a hash mismatch without an effective diff so the regression is truly covered.
This PR resolves the stale-hash and idempotency issues behind the repeated sync-loop behavior reported in #78 and adds the regression coverage requested in #79.
What changed
Issue coverage