Skip to content

feat: External entity dedupe with union-find and FK violation recovery - #2138

Merged
adityathebe merged 6 commits into
mainfrom
feat/external-entity-dedupe
Apr 20, 2026
Merged

feat: External entity dedupe with union-find and FK violation recovery#2138
adityathebe merged 6 commits into
mainfrom
feat/external-entity-dedupe

Conversation

@adityathebe

@adityathebe adityathebe commented Apr 20, 2026

Copy link
Copy Markdown
Member

Deduplication:

  • Replace naive ID-match dedup with union-find over alias overlap so entries from different scrapers (e.g. Azure AD with real UUIDs and Azure DevOps with descriptor-only aliases) collapse into one survivor. Prefer non-nil ID survivors when merging with nil-ID entries.
  • Remove logic that added entity ID to aliases (no longer needed with union-find grouping).

FK violation recovery (config_access + config_access_logs):

  • Wrap bulk inserts in savepoints; on FK violation, roll back the savepoint (preserving the temp table) and retry row-by-row with per-row exception handling so valid rows still persist.
  • Create stub external_users/roles/groups for missing references before the bulk insert to minimize FK fallback hits.
  • Add logFKDiagnostics and logAccessLogFKDiagnostics to classify and summarize unresolvable rows (user_missing, role_deleted, etc.).
  • Rewrite SaveConfigAccessLogs to use the same temp-table + savepoint + stub-user + row-by-row fallback pattern as upsertConfigAccess.

Entity resolution:

  • Add findExternalEntityByID: resolves by canonical ID, falls back to alias overlap for merged/loser IDs.
  • Add ID-keyed caches for all three entity types (previously only users had one) and populate them in WarmExternalEntityCaches.

Merge function resilience:

  • Wrap merge_and_upsert_external_* calls in savepoints via runMergeFunctionWithDump; on failure, dump temp + overlapping live rows to traces/ as JSON for post-mortem diagnosis.
  • Isolate user_groups upsert in its own savepoint so a failure there does not roll back already-successful user/group/role upserts.

Config access validation:

  • Require both a principal (user or group) AND a role; skip rows missing either with a warning instead of silently passing through.

Scrape summary improvements:

  • Track orphaned changes and FK error changes on ScrapeSummary.
  • Add warning deduplication (AddScrapeWarning with warningIndex).
  • Track access log last_created_at per config type; retain from previous scrape when no new logs are seen.
  • Add resolveChange to populate ChangeResult.Resolved with the final ConfigChange state after the change mapping pipeline.

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced change tracking with resolved, orphaned, and FK error changes now visible in API responses
    • Comprehensive warnings aggregation and deduplication for clearer reporting
    • Improved access logging with automatic recovery from missing entity references
  • Bug Fixes

    • Better external entity resolution and caching mechanisms
    • FK error recovery with fallback mechanisms to prevent data loss

@github-actions

github-actions Bot commented Apr 20, 2026

Copy link
Copy Markdown

Benchstat

Base: 0d20c927db336250de3f09d4e50f690be40627ed
Head: ad3a167eee93017aa13d1d6153a838fa511798f3

📊 7 minor regression(s) (all within 5% threshold)

Benchmark Base Head Change p-value
BenchSaveResultsUpdateUnchanged/N=1000-4 726.3m 747.8m +2.96% 0.004
BenchSaveResultsUpdateUnchanged/N=1000-4 25.71Mi 25.83Mi +0.49% 0.002
BenchSaveResultsUpdateChanged/N=1000-4 74.97Mi 75.27Mi +0.39% 0.002
BenchSaveResultsUpdateUnchanged/N=1000-4 314.0k 315.1k +0.35% 0.002
BenchSaveResultsSeed/N=1000-4 36.19Mi 36.29Mi +0.28% 0.002
BenchSaveResultsSeed/N=1000-4 442.0k 443.1k +0.24% 0.002
BenchSaveResultsUpdateChanged/N=1000-4 904.6k 906.7k +0.23% 0.041
Full benchstat output
goos: linux
goarch: amd64
pkg: github.com/flanksource/config-db/bench
cpu: AMD EPYC 7763 64-Core Processor                
                                         │ bench-base.txt │           bench-head.txt           │
                                         │     sec/op     │    sec/op     vs base              │
BenchSaveResultsSeed/N=1000-4                634.1m ± 11%   632.4m ±  8%       ~ (p=0.937 n=6)
BenchSaveResultsUpdateUnchanged/N=1000-4     726.3m ±  0%   747.8m ± 15%  +2.96% (p=0.004 n=6)
BenchSaveResultsUpdateChanged/N=1000-4        1.188 ±  1%    1.187 ±  1%       ~ (p=0.937 n=6)
geomean                                      817.9m         824.8m        +0.85%

                                         │ bench-base.txt │           bench-head.txt           │
                                         │      MB/s      │    MB/s     vs base                │
BenchSaveResultsSeed/N=1000-4                0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
BenchSaveResultsUpdateUnchanged/N=1000-4     0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
BenchSaveResultsUpdateChanged/N=1000-4       0.000 ± 0%     0.000 ± 0%       ~ (p=1.000 n=6) ¹
geomean                                                 ²               +0.00%               ²
¹ all samples are equal
² summaries must be >0 to compute geomean

                                         │ bench-base.txt │           bench-head.txt           │
                                         │      B/op      │     B/op      vs base              │
BenchSaveResultsSeed/N=1000-4                36.19Mi ± 0%   36.29Mi ± 0%  +0.28% (p=0.002 n=6)
BenchSaveResultsUpdateUnchanged/N=1000-4     25.71Mi ± 0%   25.83Mi ± 0%  +0.49% (p=0.002 n=6)
BenchSaveResultsUpdateChanged/N=1000-4       74.97Mi ± 0%   75.27Mi ± 0%  +0.39% (p=0.002 n=6)
geomean                                      41.16Mi        41.32Mi       +0.38%

                                         │ bench-base.txt │          bench-head.txt           │
                                         │   allocs/op    │  allocs/op   vs base              │
BenchSaveResultsSeed/N=1000-4                 442.0k ± 0%   443.1k ± 0%  +0.24% (p=0.002 n=6)
BenchSaveResultsUpdateUnchanged/N=1000-4      314.0k ± 0%   315.1k ± 0%  +0.35% (p=0.002 n=6)
BenchSaveResultsUpdateChanged/N=1000-4        904.6k ± 0%   906.7k ± 0%  +0.23% (p=0.041 n=6)
geomean                                       500.8k        502.1k       +0.27%

@coderabbitai

coderabbitai Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@adityathebe has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 35 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 5 minutes and 35 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4893b38d-f8e8-4d30-9260-d1244b8e242a

📥 Commits

Reviewing files that changed from the base of the PR and between 91bd2b7 and ad3a167.

📒 Files selected for processing (11)
  • Makefile
  • db/update.go
  • fixtures/data/config_access_test.json
  • fixtures/data/incremental_rbac_full_test.json
  • fixtures/data/incremental_rbac_partial_test.json
  • fixtures/data/permission_change_reduced_test.json
  • fixtures/data/permission_change_test.json
  • scrapers/config_access_test.go
  • scrapers/external_entities_test.go
  • scrapers/incremental_rbac_test.go
  • scrapers/permission_changes_test.go

Walkthrough

The PR adds warning deduplication and aggregation to the scrape pipeline, introduces resolved change tracking in the post-transformation pipeline, enhances external entity caching and ID resolution, implements FK-violation recovery with diagnostics for permission and access-log upserts, and refactors the extraction pipeline to track orphaned changes and FK errors in ScrapeSummary.

Changes

Cohort / File(s) Summary
Warning Deduplication & Summary Extension
api/v1/interface.go, api/v1/interface_test.go
Added warning deduplication/aggregation logic to ScrapeSummary and ConfigTypeScrapeSummary via AddScrapeWarning and AddWarning methods with warningIndex tracking. Extended ScrapeSummary with new exported fields: OrphanedChanges, FKErrorChanges, Warnings, and State. Re-enabled warningCount helper.
Change Resolution Tracking
db/change_traversal.go
Introduced resolveChange helper to populate ChangeResult.Resolved field with post-transformation ConfigChange details. Integrated into move/copy fan-out and move-up/copy-up traversal logic to track resolved Action and target ConfigID.
External Entity Caching & Resolution
db/external_cache.go, db/external_entities.go, db/external_entities_test.go, db/external_loser_alias_test.go
Added entity ID caching layer (getEntityIDCache, findExternalEntityByID) for canonical-ID resolution. Enhanced WarmExternalEntityCaches to populate both alias→ID and id→winner mappings. Replaced dedupeByID with dedupeByIDWithIndex using union-find over alias overlap. Added merge-failure diagnostics with savepoint-backed temp-table dumps. Removed automatic alias augmentation from entity IDs.
FK Error Handling & Access Log Persistence
db/permission_changes.go, db/update.go, scrapers/config_access_test.go
Implemented savepoint-based FK-violation recovery for permission and access-log upserts with per-row fallback loops. Added stub creation for missing external entities. Changed SaveConfigAccessLogs signature to return (accessLogUpsertResult, error) with saved/FK-error counts. Extended extractChangesResult to track orphanedChanges, fkErrorChanges, and warnings for aggregation into ScrapeSummary.

Possibly related PRs

  • #2137: Coordinated changes to ScrapeSummary/Warning handling and warning aggregation in the extraction pipeline.
  • #1990: Modifies external-entity merge/upsert logic, ID/alias resolution, and FK-related upsert behavior in db/external_entities.go.
  • #1926: Modifies change traversal and result shaping with adjustments to api/v1 ChangeResult and copy/move-up logic in db/change_traversal.go.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately summarizes the main changes: external entity deduplication using union-find algorithm and FK (foreign key) violation recovery mechanisms.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/external-entity-dedupe
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/external-entity-dedupe

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 and usage tips.

@adityathebe
adityathebe force-pushed the feat/external-entity-dedupe branch from 551b661 to e4b2228 Compare April 20, 2026 15:02
Deduplication:
  - Replace naive ID-match dedup with union-find over alias overlap so
    entries from different scrapers (e.g. Azure AD with real UUIDs and
    Azure DevOps with descriptor-only aliases) collapse into one survivor.
    Prefer non-nil ID survivors when merging with nil-ID entries.
  - Remove logic that added entity ID to aliases (no longer needed with
    union-find grouping).

  FK violation recovery (config_access + config_access_logs):
  - Wrap bulk inserts in savepoints; on FK violation, roll back the
    savepoint (preserving the temp table) and retry row-by-row with
    per-row exception handling so valid rows still persist.
  - Create stub external_users/roles/groups for missing references before
    the bulk insert to minimize FK fallback hits.
  - Add logFKDiagnostics and logAccessLogFKDiagnostics to classify and
    summarize unresolvable rows (user_missing, role_deleted, etc.).
  - Rewrite SaveConfigAccessLogs to use the same temp-table + savepoint +
    stub-user + row-by-row fallback pattern as upsertConfigAccess.

  Entity resolution:
  - Add findExternalEntityByID: resolves by canonical ID, falls back to
    alias overlap for merged/loser IDs.
  - Add ID-keyed caches for all three entity types (previously only users
    had one) and populate them in WarmExternalEntityCaches.

  Merge function resilience:
  - Wrap merge_and_upsert_external_* calls in savepoints via
    runMergeFunctionWithDump; on failure, dump temp + overlapping live
    rows to traces/ as JSON for post-mortem diagnosis.
  - Isolate user_groups upsert in its own savepoint so a failure there
    does not roll back already-successful user/group/role upserts.

  Config access validation:
  - Require both a principal (user or group) AND a role; skip rows
    missing either with a warning instead of silently passing through.

  Scrape summary improvements:
  - Track orphaned changes and FK error changes on ScrapeSummary.
  - Add warning deduplication (AddScrapeWarning with warningIndex).
  - Track access log last_created_at per config type; retain from
    previous scrape when no new logs are seen.
  - Add resolveChange to populate ChangeResult.Resolved with the final
    ConfigChange state after the change mapping pipeline.

Co-authored-by: Aditya Thebe <contact@adityathebe.com>
@adityathebe
adityathebe force-pushed the feat/external-entity-dedupe branch from e4b2228 to 91bd2b7 Compare April 20, 2026 15:16

@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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
db/permission_changes.go (1)

162-222: ⚠️ Potential issue | 🟠 Major

FK-recovery fallback silently drops PermissionAdded change events.

The result.added loop below iterates newRows, which is populated only by the original bulk INSERT ... RETURNING. When the bulk path hits a foreign-key error we roll back to bulk_insert and run the row-by-row DO $$ ... $$ fallback — but that fallback has no RETURNING equivalent and doesn't populate newRows. As a result, every access row that's successfully persisted in the recovery path emits zero PermissionAdded change records, and extractResult.newChanges will under-report.

Concretely: if the first scrape touches a brand-new user that happens to be resolvable and the bulk insert succeeds for most rows but hits one FK-violating row, the fallback will insert the good rows silently — the user gets no audit trail for those new permissions.

Options:

  • Have the DO block RAISE NOTICE or write into a temp collector table the ids it successfully inserted, then scan those back into newRows before continuing.
  • Or, after the fallback, re-query config_access for ids that are in the original items list but not in the temp table (i.e., the complement of the "FK error" set) and build result.added from that.

Additionally on this block:

  • Line 163: the SAVEPOINT bulk_insert exec's error is ignored. If savepoint creation fails, the later ROLLBACK TO SAVEPOINT will also fail and the outer tx is left aborted. Worth at least logging.
  • Line 203: fkErrorCount scan error is also ignored — if that scan fails, result.foreignKeyErrors stays 0 and result.saved is unadjusted, silently overstating saved rows.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/permission_changes.go` around lines 162 - 222, The fallback DO block
leaves newRows empty so PermissionAdded events are dropped; fix in the function
handling the bulk upsert by after the fallback path re-populating newRows (or
another collector) from the actual persisted rows — e.g., query config_access
for rows whose ids were in the original items set but no longer exist in
tempTable, and assign those results to newRows before the loop that builds
result.added (references: tempTable, newRows, result.added,
buildPermissionSummary). Also handle errors for tx.Exec("SAVEPOINT bulk_insert")
and the fkErrorCount scan (check and return/log errors rather than ignoring) so
savepoint creation and count failures don’t silently corrupt transaction state
or metrics (references: the SAVEPOINT exec and the fkErrorCount tx.Raw(...).Scan
call); keep existing FK diagnostics/logFKDiagnostics behavior.
api/v1/interface.go (2)

612-626: ⚠️ Potential issue | 🟠 Major

Merge the new top-level scrape diagnostics.

Merge currently drops OrphanedChanges, FKErrorChanges, Warnings, and State from other, so combined summaries lose the diagnostics this PR adds.

Proposed fix
 func (s *ScrapeSummary) Merge(other ScrapeSummary) {
 	s.initConfigTypes()
 	for k, v := range other.ConfigTypes {
 		if existing, ok := s.ConfigTypes[k]; ok {
 			s.ConfigTypes[k] = existing.Merge(v)
 		} else {
 			s.ConfigTypes[k] = v
 		}
 	}
 	s.ExternalUsers = s.ExternalUsers.Merge(other.ExternalUsers)
 	s.ExternalGroups = s.ExternalGroups.Merge(other.ExternalGroups)
 	s.ExternalRoles = s.ExternalRoles.Merge(other.ExternalRoles)
 	s.ConfigAccess = s.ConfigAccess.Merge(other.ConfigAccess)
 	s.AccessLogs = s.AccessLogs.Merge(other.AccessLogs)
+	s.OrphanedChanges = append(s.OrphanedChanges, other.OrphanedChanges...)
+	s.FKErrorChanges = append(s.FKErrorChanges, other.FKErrorChanges...)
+	for _, w := range other.Warnings {
+		s.AddScrapeWarning(w)
+	}
+	if len(other.State) > 0 {
+		if s.State == nil {
+			s.State = make(map[string]any, len(other.State))
+		}
+		for k, v := range other.State {
+			s.State[k] = v
+		}
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v1/interface.go` around lines 612 - 626, The Merge method on
ScrapeSummary currently only merges some fields and omits the new diagnostic
fields; update ScrapeSummary.Merge (the method named Merge on type
ScrapeSummary) to also merge OrphanedChanges, FKErrorChanges, Warnings, and
State from the other summary the same way other fields are merged (i.e.,
initialize if needed and call the appropriate Merge method or combine values) so
that diagnostics are preserved when combining summaries.

430-431: ⚠️ Potential issue | 🟠 Major

Include the new summary keys in new-format JSON detection.

A payload like {"warnings":[...]} or {"orphaned_changes":[...]} can omit all existing newFormatKeys, then this falls through to the legacy map[string]ConfigTypeScrapeSummary path and fails or misclassifies the summary.

Proposed fix
-	newFormatKeys := []string{"config_types", "external_users", "external_groups", "external_roles", "config_access", "access_logs"}
+	newFormatKeys := []string{
+		"config_types",
+		"external_users",
+		"external_groups",
+		"external_roles",
+		"config_access",
+		"access_logs",
+		"orphaned_changes",
+		"fk_error_changes",
+		"warnings",
+		"state",
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v1/interface.go` around lines 430 - 431, The new-format detection
currently checks newFormatKeys in the slice defined in api/v1/interface.go but
misses summary-only top-level keys like "warnings" and "orphaned_changes",
causing those payloads to be misclassified as legacy; update the newFormatKeys
slice (the variable named newFormatKeys) to include "warnings" and
"orphaned_changes" so these payloads are detected as new-format JSON during the
payload detection logic.
db/update.go (2)

1734-1747: ⚠️ Potential issue | 🟠 Major

Don’t drop summaries that only contain ignored-by-action counts.

extractChanges records explicit ignores via AddIgnoredByAction, but this gate calls ChangeSummary.IsEmpty(), which currently ignores IgnoredByAction and ForeignKeyErrors. A result with only Action: Ignore changes is skipped here and never reaches the summary/counters.

Proposed fix
 func (t ChangeSummary) IsEmpty() bool {
-	return len(t.Orphaned) == 0 && len(t.Ignored) == 0
+	return len(t.Orphaned) == 0 &&
+		len(t.Ignored) == 0 &&
+		len(t.IgnoredByAction) == 0 &&
+		t.ForeignKeyErrors == 0
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/update.go` around lines 1734 - 1747, The code in extractChanges is
skipping chResult.changeSummary when ChangeSummary.IsEmpty() returns true, but
IsEmpty() currently ignores IgnoredByAction and ForeignKeyErrors so summaries
that only contain ignored-by-action counts get dropped; update the logic so
these ignored-only summaries are preserved—either by changing the predicate here
to consider chResult.changeSummary.HasIgnoredByAction() ||
chResult.changeSummary.HasForeignKeyErrors() in addition to !IsEmpty(), or by
updating ChangeSummary.IsEmpty() to treat IgnoredByAction/ForeignKeyErrors as
non-empty; then ensure extractResult.changeSummary.Merge(configType,
chResult.changeSummary) still runs for those cases (symbols: extractChanges,
chResult.changeSummary, ChangeSummary.IsEmpty, AddIgnoredByAction,
ForeignKeyErrors, extractResult.changeSummary.Merge).

783-786: ⚠️ Potential issue | 🟡 Minor

Count access logs skipped for missing users.

This branch emits a warning and continues, but does not increment summary.AccessLogs.Skipped, so skipped access-log totals are underreported.

Proposed fix
 		if accessLog.ExternalUserID == uuid.Nil {
 			summary.AddWarning("AccessLog", fmt.Sprintf("access log has no user_id aliases=%v %s", accessLog.ExternalUserAliases, accessLog))
+			summary.AccessLogs.Skipped++
 			continue
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/update.go` around lines 783 - 786, The branch that checks
accessLog.ExternalUserID == uuid.Nil emits a warning and continues but fails to
increment the skipped counter, causing underreported totals; update the block
that calls summary.AddWarning(...) to also increment summary.AccessLogs.Skipped
(or the appropriate skipped field on the summary struct) before continuing so
every skipped access log is counted, ensuring you reference
accessLog.ExternalUserID and summary.AccessLogs.Skipped in the same branch.
🧹 Nitpick comments (5)
db/change_traversal.go (1)

17-33: LGTM — clean extraction, but consider surfacing Resolved for the Ignore/Delete/skip paths too.

The helper centralizes construction of ChangeResult.Resolved and is cleanly applied to MoveUp/CopyUp/Move/Copy. A couple of small observations:

  • Fields copied are static; if v1.ChangeResult ever gains additional persisted fields (e.g., Fingerprint, Patches variants, labels), this helper will silently drop them. A short // keep in sync with dutyModels.ConfigChange comment above the struct literal would help future readers.
  • The AI summary mentions db/update.go also calling resolveChange for Ignore/Delete/exclusion/orphan paths — worth confirming those branches set a sensible Action string (e.g., "" or a well-known marker) since the helper requires the caller to provide it.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/change_traversal.go` around lines 17 - 33, The resolveChange helper
constructs ChangeResult.Resolved but can silently omit future fields and relies
on callers to supply a meaningful Action; add a short comment above
resolveChange and the struct literal saying "// keep in sync with
dutyModels.ConfigChange" and/or note which fields must be preserved, and audit
all callers (e.g., usages in MoveUp/CopyUp/Move/Copy and db/update.go branches
for Ignore/Delete/exclusion/orphan paths) to ensure they pass an explicit,
well-known Action string (or a documented empty marker) so
ignored/deleted/skipped changes get a sensible Action value.
db/permission_changes.go (1)

405-412: Top-10 FK reason logging has nondeterministic ordering.

counts is a Go map, so for key, count := range counts yields a different ordering on each run. With a hard cap at 10, two scraper runs against identical data can report different subsets of reasons, making diagnosis harder.

Consider sorting by count desc (most-frequent first) before truncating:

♻️ Proposed fix
-	logged := 0
-	for key, count := range counts {
-		if logged >= 10 {
-			break
-		}
-		ctx.Logger.Warnf("  reason=%s id=%s count=%d", key.Reason, key.FKID, count)
-		logged++
-	}
+	type entry struct {
+		key   groupKey
+		count int
+	}
+	entries := make([]entry, 0, len(counts))
+	for k, c := range counts {
+		entries = append(entries, entry{k, c})
+	}
+	sort.Slice(entries, func(i, j int) bool { return entries[i].count > entries[j].count })
+	for i, e := range entries {
+		if i >= 10 {
+			break
+		}
+		ctx.Logger.Warnf("  reason=%s id=%s count=%d", e.key.Reason, e.key.FKID, e.count)
+	}

Requires adding "sort" to the imports.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/permission_changes.go` around lines 405 - 412, The current loop over
counts (map variable counts) is nondeterministic because Go map iteration order
is random, so the top-10 output (loop using logged, key.Reason, key.FKID, count)
can vary between runs; fix by creating a slice of entries from counts (e.g.,
struct with key and count), sort that slice by count descending using
sort.Slice, then iterate the first min(10, len(slice)) entries and call
ctx.Logger.Warnf for each; remember to add "sort" to the imports.
db/external_entities_test.go (1)

16-94: Coverage gap: two distinct non-nil IDs sharing an alias.

The dedupeByIDWithIndex specs cover (a) all-nil-ID alias-overlap, (b) same-non-nil-ID dedup, and (c) one-nil-one-non-nil alias-overlap. They do not cover the adversarial case where two items carry different non-nil IDs but share an alias. Given the alias-union step at external_entities.go L787-793 unions everyone regardless of ID, that case will silently drop one of the non-nil IDs and the dropped ID never enters the merge function's loser→winner map — which is where downstream ExternalUserID/ExternalGroupID rewrites for config_access come from.

Adding a test that pins down the intended behavior (either "survivor is deterministic and loser ID is preserved via the caller's remap flow" or "this shouldn't happen, we panic/warn") would keep future refactors safe.

It("surfaces a dropped ID when two distinct non-nil IDs share an alias", func() {
    idA := "00000000-0000-0000-0000-0000000000a1"
    idB := "00000000-0000-0000-0000-0000000000a2"
    items := []models.ExternalGroup{
        mk(idA, "shared-alias", "a-only"),
        mk(idB, "shared-alias", "b-only"),
    }
    out, idx := dedupeByIDWithIndex(items, getID, getAliases, setAliases)
    Expect(out).To(HaveLen(1))
    Expect(idx).To(Equal([]int{0, 0}))
    // Pin down which ID wins and assert something about the loser.
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/external_entities_test.go` around lines 16 - 94, Add a spec that covers
two distinct non-nil IDs that share an alias using the existing helpers (getID,
getAliases, setAliases, mk) and call dedupeByIDWithIndex; assert it collapses to
a single survivor (Expect(out).To(HaveLen(1))), assert the survivor ID is
deterministic (for now, expect the first item's ID to win, e.g. idA via
Expect(out[0].ID.String()).To(Equal(idA))) and assert the index map points both
inputs to that survivor (Expect(idx).To(Equal([]int{0,0}))). This pins the
intended deterministic behavior of dedupeByIDWithIndex when two different
non-nil IDs share an alias.
db/external_entities.go (1)

365-429: Merge-failure dumps in ./traces/ will accumulate indefinitely and depend on a writable cwd.

Two operational concerns with the dump mechanism:

  1. writeMergeFailureDump always writes to filepath.Join("traces", ...) relative to the process working directory. In containerized deployments the cwd may be / (read-only) or a runtime-provided ephemeral dir; failures will then silently fail to persist (writeErr is logged but the dump is lost) and you're back to the tracef fallback. Consider making the trace dir configurable via a property (similar to CACHE_TIMEOUT) with a sane default like /tmp/config-db-traces, or falling back to os.TempDir() when MkdirAll fails.
  2. There's no retention/rotation. A scraper that hits this failure mode on every run will write a new JSON file each time — over weeks this can fill the volume. At minimum document the expected retention story, or add a filepath.Glob + mtime-based trim to a last-N files.

Minor: the envelope at L447-454 is string-formatted JSON; if mergeErr.Error() ever contains a raw tab/newline/quote that %q doesn't handle (edge case with non-UTF8 bytes) the file becomes malformed. Building the envelope with encoding/json and marshaling json.RawMessage(tempDump) / json.RawMessage(liveDump) would be more robust and no slower.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/external_entities.go` around lines 365 - 429, The dump routine in
runMergeFunctionWithDump relies on writeMergeFailureDump writing into a
hard-coded "traces" directory in the current working directory and never rotates
files; make writeMergeFailureDump configurable and add retention: add a new
configuration variable (e.g. MergeDumpDir or MERGE_DUMP_DIR) that defaults to a
sane temp location (os.TempDir() or "/tmp/config-db-traces") and use that path
instead of relying on cwd; update writeMergeFailureDump to attempt MkdirAll and,
on failure, fall back to os.TempDir(); additionally implement simple
rotation/retention inside writeMergeFailureDump (or a helper it calls) that
enumerates existing dump files (filepath.Glob) and removes oldest by mtime until
only N most recent remain (configurable or a reasonable constant like 10);
finally, change how the JSON envelope is formed in writeMergeFailureDump to
build a struct and marshal it with encoding/json using json.RawMessage for
tempDump/liveDump to avoid malformed output from raw string formatting.
db/external_cache.go (1)

148-161: Simplify error handling—Pluck never returns ErrRecordNotFound.

The check err != gorm.ErrRecordNotFound is dead code: GORM v2's Pluck (a multi-record method) returns a nil error and an empty slice when no rows match; ErrRecordNotFound is only returned by single-record methods like First, Last, and Take. The subsequent if len(foundIDs) > 0 correctly handles the not-found case.

Additionally, the direct equality comparison is non-idiomatic; use errors.Is() for wrapped-error safety.

Simplify to:

♻️ Proposed simplification
 	var zero T
 	var foundIDs []uuid.UUID
 	err := ctx.DB().Table(zero.TableName()).
 		Select("id").
 		Where("id = ? AND deleted_at IS NULL", id).
 		Limit(1).
 		Pluck("id", &foundIDs).Error
-	if err != nil && err != gorm.ErrRecordNotFound {
+	if err != nil {
 		return nil, fmt.Errorf("failed to query %s by id: %w", zero.TableName(), err)
 	}
 	var found uuid.UUID
 	if len(foundIDs) > 0 {
 		found = foundIDs[0]
 	}

This removes the dead branch and eliminates the unnecessary gorm import.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@db/external_cache.go` around lines 148 - 161, The Pluck call can never return
gorm.ErrRecordNotFound (it returns nil + empty slice on no rows), so in
db/external_cache.go simplify the error handling in the block that queries
zero.TableName(): remove the dead check "err != gorm.ErrRecordNotFound" and just
treat any non-nil err as fatal (return the wrapped error from fmt.Errorf), keep
the existing len(foundIDs) > 0 handling for the not-found case, and after this
change remove the now-unused gorm import; if you ever compare errors elsewhere
prefer errors.Is(...) for wrapped-error checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@db/external_entities.go`:
- Around line 747-838: Update the comment above dedupeByIDWithIndex to clarify
that although the union-find logic (using idToIdx/aliasToIdx and union/find) can
in theory merge two non-nil IDs when they share an alias, this only happens if a
single scraper emits multiple distinct non-nil IDs; in practice that doesn't
occur because scrapers produce keyed maps (e.g.,
map[uuid.UUID]models.ExternalUser) so distinct non-nil IDs are not emitted by
the same scraper, and cross-scraper merges are handled later by the SQL
merge_and_upsert_external_* logic and remapExternalUserGroups.

In `@db/update.go`:
- Around line 791-813: The code advances access-log cursors using
extractResult.configAccessLogs (which may include rows that failed validation)
so last_created_at can jump past rows that weren't persisted; change the loop to
compute max times from the validated rows actually appended (e.g., use
extractResult.resolvedAccessLogs or the slice returned/recorded by
SaveConfigAccessLogs) instead of extractResult.configAccessLogs, updating
summary.ConfigTypes[configType].AccessLogs.LastCreatedAt and
summary.AccessLogs.LastCreatedAt from those validated timestamps; alternatively
modify SaveConfigAccessLogs to return per-configType max persisted CreatedAt
values and use that returned data to set the summary timestamps.
- Around line 660-664: The code silently increments summary.ConfigAccess.Skipped
when both configAccess.ExternalUserID and configAccess.ExternalGroupID are nil;
update that branch to emit a warning log before continuing so skipped rows are
discoverable. Modify the block that checks "if configAccess.ExternalUserID ==
nil && configAccess.ExternalGroupID == nil" to call the existing logger (or
context logger) to warn and include identifying fields from configAccess (e.g.,
ExternalUserID, ExternalGroupID, any config or row identifier present on the
struct) and then increment summary.ConfigAccess.Skipped and continue as before.
Ensure the log message mirrors the style used by the missing-role paths below
for consistency.

---

Outside diff comments:
In `@api/v1/interface.go`:
- Around line 612-626: The Merge method on ScrapeSummary currently only merges
some fields and omits the new diagnostic fields; update ScrapeSummary.Merge (the
method named Merge on type ScrapeSummary) to also merge OrphanedChanges,
FKErrorChanges, Warnings, and State from the other summary the same way other
fields are merged (i.e., initialize if needed and call the appropriate Merge
method or combine values) so that diagnostics are preserved when combining
summaries.
- Around line 430-431: The new-format detection currently checks newFormatKeys
in the slice defined in api/v1/interface.go but misses summary-only top-level
keys like "warnings" and "orphaned_changes", causing those payloads to be
misclassified as legacy; update the newFormatKeys slice (the variable named
newFormatKeys) to include "warnings" and "orphaned_changes" so these payloads
are detected as new-format JSON during the payload detection logic.

In `@db/permission_changes.go`:
- Around line 162-222: The fallback DO block leaves newRows empty so
PermissionAdded events are dropped; fix in the function handling the bulk upsert
by after the fallback path re-populating newRows (or another collector) from the
actual persisted rows — e.g., query config_access for rows whose ids were in the
original items set but no longer exist in tempTable, and assign those results to
newRows before the loop that builds result.added (references: tempTable,
newRows, result.added, buildPermissionSummary). Also handle errors for
tx.Exec("SAVEPOINT bulk_insert") and the fkErrorCount scan (check and return/log
errors rather than ignoring) so savepoint creation and count failures don’t
silently corrupt transaction state or metrics (references: the SAVEPOINT exec
and the fkErrorCount tx.Raw(...).Scan call); keep existing FK
diagnostics/logFKDiagnostics behavior.

In `@db/update.go`:
- Around line 1734-1747: The code in extractChanges is skipping
chResult.changeSummary when ChangeSummary.IsEmpty() returns true, but IsEmpty()
currently ignores IgnoredByAction and ForeignKeyErrors so summaries that only
contain ignored-by-action counts get dropped; update the logic so these
ignored-only summaries are preserved—either by changing the predicate here to
consider chResult.changeSummary.HasIgnoredByAction() ||
chResult.changeSummary.HasForeignKeyErrors() in addition to !IsEmpty(), or by
updating ChangeSummary.IsEmpty() to treat IgnoredByAction/ForeignKeyErrors as
non-empty; then ensure extractResult.changeSummary.Merge(configType,
chResult.changeSummary) still runs for those cases (symbols: extractChanges,
chResult.changeSummary, ChangeSummary.IsEmpty, AddIgnoredByAction,
ForeignKeyErrors, extractResult.changeSummary.Merge).
- Around line 783-786: The branch that checks accessLog.ExternalUserID ==
uuid.Nil emits a warning and continues but fails to increment the skipped
counter, causing underreported totals; update the block that calls
summary.AddWarning(...) to also increment summary.AccessLogs.Skipped (or the
appropriate skipped field on the summary struct) before continuing so every
skipped access log is counted, ensuring you reference accessLog.ExternalUserID
and summary.AccessLogs.Skipped in the same branch.

---

Nitpick comments:
In `@db/change_traversal.go`:
- Around line 17-33: The resolveChange helper constructs ChangeResult.Resolved
but can silently omit future fields and relies on callers to supply a meaningful
Action; add a short comment above resolveChange and the struct literal saying
"// keep in sync with dutyModels.ConfigChange" and/or note which fields must be
preserved, and audit all callers (e.g., usages in MoveUp/CopyUp/Move/Copy and
db/update.go branches for Ignore/Delete/exclusion/orphan paths) to ensure they
pass an explicit, well-known Action string (or a documented empty marker) so
ignored/deleted/skipped changes get a sensible Action value.

In `@db/external_cache.go`:
- Around line 148-161: The Pluck call can never return gorm.ErrRecordNotFound
(it returns nil + empty slice on no rows), so in db/external_cache.go simplify
the error handling in the block that queries zero.TableName(): remove the dead
check "err != gorm.ErrRecordNotFound" and just treat any non-nil err as fatal
(return the wrapped error from fmt.Errorf), keep the existing len(foundIDs) > 0
handling for the not-found case, and after this change remove the now-unused
gorm import; if you ever compare errors elsewhere prefer errors.Is(...) for
wrapped-error checks.

In `@db/external_entities_test.go`:
- Around line 16-94: Add a spec that covers two distinct non-nil IDs that share
an alias using the existing helpers (getID, getAliases, setAliases, mk) and call
dedupeByIDWithIndex; assert it collapses to a single survivor
(Expect(out).To(HaveLen(1))), assert the survivor ID is deterministic (for now,
expect the first item's ID to win, e.g. idA via
Expect(out[0].ID.String()).To(Equal(idA))) and assert the index map points both
inputs to that survivor (Expect(idx).To(Equal([]int{0,0}))). This pins the
intended deterministic behavior of dedupeByIDWithIndex when two different
non-nil IDs share an alias.

In `@db/external_entities.go`:
- Around line 365-429: The dump routine in runMergeFunctionWithDump relies on
writeMergeFailureDump writing into a hard-coded "traces" directory in the
current working directory and never rotates files; make writeMergeFailureDump
configurable and add retention: add a new configuration variable (e.g.
MergeDumpDir or MERGE_DUMP_DIR) that defaults to a sane temp location
(os.TempDir() or "/tmp/config-db-traces") and use that path instead of relying
on cwd; update writeMergeFailureDump to attempt MkdirAll and, on failure, fall
back to os.TempDir(); additionally implement simple rotation/retention inside
writeMergeFailureDump (or a helper it calls) that enumerates existing dump files
(filepath.Glob) and removes oldest by mtime until only N most recent remain
(configurable or a reasonable constant like 10); finally, change how the JSON
envelope is formed in writeMergeFailureDump to build a struct and marshal it
with encoding/json using json.RawMessage for tempDump/liveDump to avoid
malformed output from raw string formatting.

In `@db/permission_changes.go`:
- Around line 405-412: The current loop over counts (map variable counts) is
nondeterministic because Go map iteration order is random, so the top-10 output
(loop using logged, key.Reason, key.FKID, count) can vary between runs; fix by
creating a slice of entries from counts (e.g., struct with key and count), sort
that slice by count descending using sort.Slice, then iterate the first min(10,
len(slice)) entries and call ctx.Logger.Warnf for each; remember to add "sort"
to the imports.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a405ee65-2e8c-4071-830f-12c7a45cdd34

📥 Commits

Reviewing files that changed from the base of the PR and between 0d20c92 and 91bd2b7.

📒 Files selected for processing (10)
  • api/v1/interface.go
  • api/v1/interface_test.go
  • db/change_traversal.go
  • db/external_cache.go
  • db/external_entities.go
  • db/external_entities_test.go
  • db/external_loser_alias_test.go
  • db/permission_changes.go
  • db/update.go
  • scrapers/config_access_test.go

Comment thread db/external_entities.go
Comment thread db/update.go
Comment thread db/update.go Outdated
…external_role_aliases in their config_access entries. The validation at

  db/update.go:666 requires every config_access to have a role — without one, the entries are skipped with the "missing role" warning, resulting in 0 config
  access rows instead of 2.

  Changes made:
  1. fixtures/data/incremental_rbac_full_test.json — added external_roles section with a "Viewer" role, and added external_role_aliases to both config_access
  entries
  2. fixtures/data/incremental_rbac_partial_test.json — same: added role and role alias reference
  3. scrapers/incremental_rbac_test.go — added ExternalRole cleanup in AfterAll to avoid the same FK violation we just fixed
@adityathebe
adityathebe force-pushed the feat/external-entity-dedupe branch from 1aa1f8c to ad3a167 Compare April 20, 2026 16:10
@adityathebe
adityathebe merged commit ebfee75 into main Apr 20, 2026
17 of 18 checks passed
@adityathebe
adityathebe deleted the feat/external-entity-dedupe branch April 20, 2026 16:27
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.

2 participants