Skip to content

refactor(store): wave F — reactive triggers → Go listeners (#301)#302

Merged
PaulDotterer merged 1 commit into
mainfrom
feat/301-reactive-triggers-go
May 16, 2026
Merged

refactor(store): wave F — reactive triggers → Go listeners (#301)#302
PaulDotterer merged 1 commit into
mainfrom
feat/301-reactive-triggers-go

Conversation

@PaulDotterer

@PaulDotterer PaulDotterer commented May 16, 2026

Copy link
Copy Markdown
Contributor

Part of the storage-abstraction tracker (#242), Wave F. Drops the four PL/pgSQL reactive triggers that fired dynamic-group re-evaluation on side-table changes; moves the enqueueing into the Go projector listeners that already process the underlying events.

Closes #301.

Summary

Migration 049 drops:

  • device_labels_change_trigger (on device_labels)
  • device_deleted_trigger (on devices_projection)
  • device_inventory_changed (on device_inventory)
  • user_attribute_change_trigger (on users_projection)

Plus 7 supporting PL/pgSQL functions (trigger handlers + queue helpers).

Go wiring

  • 4 new sqlc queries (EnqueueAllDynamicDeviceGroups / EnqueueAllDynamicUserGroups with caller-supplied reason, ListDeviceGroupMembershipsByDevice, DeleteDeviceGroupMembershipsForDevice). Removed: QueueAllDynamicGroups (callerless after this PR).
  • enqueueDynamicDeviceGroupsForDevice / enqueueDynamicUserGroupsForUser helpers in internal/projectors. Reason format preserved ('device_changed' / 'user_changed').
  • Device-deleted cascade scope-tightened: recount only the affected groups, not every dynamic device group (PL/pgSQL trigger's wholesale sweep).
  • Periodic safety-net sweep in cmd/control/periodic.go: one typed enqueue per side (device + user) instead of the SELECT queue_all_dynamic_groups() shim.

DoD checks

  • go build + go vet clean
  • gofmt clean
  • Targeted projector tests pass (Device + User + Totp, 326s testcontainer suite)
  • CI integration suite

After Wave F lands, the only remaining roadmap item before validation-backend / NoSQL work is Wave G (EventStore interface).

Summary by CodeRabbit

Release Notes

  • Improvements
    • Enhanced dynamic group re-evaluation to be more responsive and reliable when device inventory, labels, and user attributes change.
    • Improved cascading behavior during device and user deletion to ensure dynamic group memberships are properly recalculated.

Review Change Stack

Drops the four PL/pgSQL reactive triggers and their helper functions
that fired dynamic-group re-evaluation on side-table changes. The
equivalent enqueueing moves into the Go projector listeners that
already process the underlying events.

Migration 049 drops:
- device_labels_change_trigger (on device_labels)
- device_deleted_trigger (on devices_projection)
- device_inventory_changed (on device_inventory)
- user_attribute_change_trigger (on users_projection)

Plus trigger_device_labels_child_change, trigger_device_label_change
(legacy from JSONB days), trigger_device_deleted, trigger_inventory_change,
queue_dynamic_groups_for_device, queue_dynamic_user_groups_on_user_change,
queue_all_dynamic_groups.

Go wiring:
- New sqlc queries: EnqueueAllDynamicDeviceGroups(reason),
  EnqueueAllDynamicUserGroups(reason), ListDeviceGroupMembershipsByDevice,
  DeleteDeviceGroupMembershipsForDevice. The reason text moves from
  SQL-side string concat into the Go caller.
- enqueueDynamicDeviceGroupsForDevice / enqueueDynamicUserGroupsForUser
  helpers in the projector package. Reason format preserved
  ('device_<id>_changed' / 'user_<id>_changed').
- Device projector wires the helper into applyDeviceLabelSet /
  applyDeviceLabelRemoved / applyDeviceLabelsUpdated / applyDeviceRegistered.
- Device-deleted listener cascades: list affected group_ids, delete
  device_group_members rows, recount only the affected groups
  (scope-tightening vs the dropped trigger's full-table sweep).
- Inbox-worker's handleInventoryUpdate explicitly enqueues device
  groups after the inventory upsert chain (replaces device_inventory_changed).
- User projector wires the helper into applyUserCreatedWithRoles /
  applyUserProfileUpdated / applyUserEmailChanged /
  applyUserPasswordChanged / applyUserDisabled / applyUserEnabled /
  applyUserDeleted. TOTP listener fires it on TOTPVerified / TOTPDisabled.
- cmd/control/periodic.go's QueueAllDynamicGroups call replaced with
  the two new typed enqueue queries (one device-side, one user-side)
  so the safety-net sweep still primes both queues every 24h.
- QueueAllDynamicGroups sqlc shim deleted (callerless after the
  periodic rewire).

Refs #242.
@coderabbitai

coderabbitai Bot commented May 16, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Reactive PostgreSQL triggers that re-evaluated dynamic groups on device/user mutations are removed and migrated to explicit Go enqueue calls in the event projectors. New sqlc queries bulk-enqueue device and user groups with caller-supplied reasons; device deletion cascade is expanded; periodic and inventory-worker re-eval paths are updated to use the split typed enqueue queries.

Changes

Wave F: Reactive trigger removal and Go listener enqueue migration

Layer / File(s) Summary
Database schema and bulk-enqueue queries
internal/store/migrations/049_drop_reactive_triggers.sql, internal/store/queries/device_groups.sql, internal/store/queries/user_groups.sql
Four reactive triggers (device_labels_change_trigger, device_deleted_trigger, device_inventory_changed, user_attribute_change_trigger) and their PL/pgSQL helper functions are dropped. New sqlc queries EnqueueAllDynamicDeviceGroups and EnqueueAllDynamicUserGroups bulk-insert queue entries with parameterized reason text and upsert on conflict. Device cascade helpers ListDeviceGroupMembershipsByDevice and DeleteDeviceGroupMembershipsForDevice support cleanup of affected group memberships on device deletion.
Device projector: label and deletion enqueue wiring
internal/projectors/device_listener.go
DeviceRegistered, DeviceLabelsUpdated, DeviceLabelSet, and DeviceLabelRemoved handlers now enqueue dynamic device-group re-evaluations after their respective label mutations via a new enqueueDynamicDeviceGroupsForDevice helper. DeviceDeleted cascade is expanded to delete assigned-user and assigned-group junction rows, then uses the new cascade helpers to list affected groups, delete memberships, and recount member counts per group (replacing the prior PL/pgSQL trigger behavior).
User projector: attribute and deletion enqueue wiring
internal/projectors/user_listener.go
UserCreatedWithRoles, UserProfileUpdated, UserEmailChanged, UserPasswordChanged, UserDisabled, and UserEnabled handlers now enqueue dynamic user-group re-evaluations after projection writes via a new enqueueDynamicUserGroupsForUser helper. UserDeleted handler explicitly deletes identity links with error handling, then enqueues dynamic user-group re-evaluation before returning.
TOTP projector: user-group enqueue on TOTP state change
internal/projectors/totp_listener.go
TOTPVerified and TOTPDisabled event handlers enqueue dynamic user-group re-evaluations for the affected user after updating totp_enabled state in the user projection, with warning logging on enqueue failure.
Periodic full re-eval and inventory worker: typed enqueue calls
cmd/control/periodic.go, internal/control/inbox_worker.go
Periodic full re-evaluation goroutine replaces a single unified enqueue call with two typed calls (EnqueueAllDynamicDeviceGroups, EnqueueAllDynamicUserGroups) using a shared periodic_full_evaluation reason constant and separate error logging per call. Inventory worker explicitly enqueues dynamic device groups after inventory upserts with a device-specific queue key, removing dependency on the prior device_inventory_changed trigger.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

  • manchtools/power-manage-server#294: Main PR's changes to inbox_worker.go and periodic.go directly feed the new dynamic-group queue draining logic introduced in this PR.
  • manchtools/power-manage-server#118: Main PR extends the TotpListener introduced in this PR to additionally enqueue dynamic user-group re-evaluations after TOTPVerified and TOTPDisabled.
  • manchtools/power-manage-server#182: Main PR's device listener updates build directly on the device-projector Go listener foundation introduced in this PR.

Poem

🐰 Triggers once fired, now silent and gone,
Go listeners heed where the dancers have shone,
No PL/pgSQL waits in the night,
Just enqueues at the moment, clear and bright! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: migrating PL/pgSQL reactive triggers to Go listeners for dynamic group re-evaluation (Wave F).
Linked Issues check ✅ Passed The PR fully implements all objectives from #301: drops four triggers and supporting functions, adds new sqlc queries with caller-supplied reasons, wires projector enqueue calls, handles device-deleted cascade tightly, and replaces periodic safety-net with typed enqueues.
Out of Scope Changes check ✅ Passed All changes are directly aligned with #301 objectives: trigger removal, sqlc query additions, projector wiring, and periodic logic replacement. No unrelated changes detected.

✏️ 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 feat/301-reactive-triggers-go

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@cmd/control/periodic.go`:
- Around line 117-124: The success log "queued full dynamic group re-evaluation"
is emitted even if EnqueueAllDynamicDeviceGroups fails; change the logic in the
block that calls st.Queries().EnqueueAllDynamicDeviceGroups(ctx, reason) and
st.Queries().EnqueueAllDynamicUserGroups(ctx, reason) so the logger.Info is only
called when both calls return nil: capture each error (from
EnqueueAllDynamicDeviceGroups and EnqueueAllDynamicUserGroups), log individual
failures with logger.Error as currently done, and only call logger.Info("queued
full dynamic group re-evaluation") if both errors are nil (i.e., both enqueues
succeeded).

In `@internal/control/inbox_worker.go`:
- Around line 492-497: The call to
w.store.Queries().EnqueueAllDynamicDeviceGroups currently only logs a warning
and swallows the error, preventing the task from being retried; change the
handler so that when EnqueueAllDynamicDeviceGroups (the call with key
"device_"+payload.DeviceID+"_changed") returns a non-nil error you log it
(optional) and then return that error (or wrap it) from the enclosing handler
function so Asynq will retry the task instead of treating it as success.
🪄 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: c39ae573-78c1-4f91-8967-ff3fb907dda4

📥 Commits

Reviewing files that changed from the base of the PR and between 1f39513 and 9fe29f3.

⛔ Files ignored due to path filters (2)
  • internal/store/generated/device_groups.sql.go is excluded by !**/generated/**
  • internal/store/generated/user_groups.sql.go is excluded by !**/generated/**
📒 Files selected for processing (8)
  • cmd/control/periodic.go
  • internal/control/inbox_worker.go
  • internal/projectors/device_listener.go
  • internal/projectors/totp_listener.go
  • internal/projectors/user_listener.go
  • internal/store/migrations/049_drop_reactive_triggers.sql
  • internal/store/queries/device_groups.sql
  • internal/store/queries/user_groups.sql

Comment thread cmd/control/periodic.go
Comment on lines +117 to 124
if err := st.Queries().EnqueueAllDynamicDeviceGroups(ctx, reason); err != nil {
logger.Error("failed to queue full dynamic device-group re-evaluation", "error", err)
}
if err := st.Queries().EnqueueAllDynamicUserGroups(ctx, reason); err != nil {
logger.Error("failed to queue full dynamic user-group re-evaluation", "error", err)
} else {
logger.Info("queued full dynamic group re-evaluation")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Success log can be false-positive when device enqueue fails.

On Line 123, the success message is emitted when only the user enqueue succeeds; a failure on Line 117 still results in an overall “queued full dynamic group re-evaluation” log. Gate the success log on both calls succeeding.

Suggested fix
 	const reason = "periodic_full_evaluation"
-	if err := st.Queries().EnqueueAllDynamicDeviceGroups(ctx, reason); err != nil {
+	deviceQueued := true
+	userQueued := true
+	if err := st.Queries().EnqueueAllDynamicDeviceGroups(ctx, reason); err != nil {
+		deviceQueued = false
 		logger.Error("failed to queue full dynamic device-group re-evaluation", "error", err)
 	}
 	if err := st.Queries().EnqueueAllDynamicUserGroups(ctx, reason); err != nil {
+		userQueued = false
 		logger.Error("failed to queue full dynamic user-group re-evaluation", "error", err)
-	} else {
+	}
+	if deviceQueued && userQueued {
 		logger.Info("queued full dynamic group re-evaluation")
 	}
📝 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
if err := st.Queries().EnqueueAllDynamicDeviceGroups(ctx, reason); err != nil {
logger.Error("failed to queue full dynamic device-group re-evaluation", "error", err)
}
if err := st.Queries().EnqueueAllDynamicUserGroups(ctx, reason); err != nil {
logger.Error("failed to queue full dynamic user-group re-evaluation", "error", err)
} else {
logger.Info("queued full dynamic group re-evaluation")
}
deviceQueued := true
userQueued := true
if err := st.Queries().EnqueueAllDynamicDeviceGroups(ctx, reason); err != nil {
deviceQueued = false
logger.Error("failed to queue full dynamic device-group re-evaluation", "error", err)
}
if err := st.Queries().EnqueueAllDynamicUserGroups(ctx, reason); err != nil {
userQueued = false
logger.Error("failed to queue full dynamic user-group re-evaluation", "error", err)
}
if deviceQueued && userQueued {
logger.Info("queued full dynamic group re-evaluation")
}
🤖 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 `@cmd/control/periodic.go` around lines 117 - 124, The success log "queued full
dynamic group re-evaluation" is emitted even if EnqueueAllDynamicDeviceGroups
fails; change the logic in the block that calls
st.Queries().EnqueueAllDynamicDeviceGroups(ctx, reason) and
st.Queries().EnqueueAllDynamicUserGroups(ctx, reason) so the logger.Info is only
called when both calls return nil: capture each error (from
EnqueueAllDynamicDeviceGroups and EnqueueAllDynamicUserGroups), log individual
failures with logger.Error as currently done, and only call logger.Info("queued
full dynamic group re-evaluation") if both errors are nil (i.e., both enqueues
succeeded).

Comment on lines +492 to +497
if err := w.store.Queries().EnqueueAllDynamicDeviceGroups(ctx, "device_"+payload.DeviceID+"_changed"); err != nil {
w.logger.Warn("failed to enqueue dynamic device groups after inventory update",
"device_id", payload.DeviceID,
"error", err,
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not swallow enqueue failures; return an error to retry the task.

If Line 492 fails, the handler only warns and still returns success, so this inventory change may never enqueue dynamic-group re-evaluation promptly. Return an error here so Asynq retries and preserves expected behavior.

Suggested fix
 	if err := w.store.Queries().EnqueueAllDynamicDeviceGroups(ctx, "device_"+payload.DeviceID+"_changed"); err != nil {
-		w.logger.Warn("failed to enqueue dynamic device groups after inventory update",
-			"device_id", payload.DeviceID,
-			"error", err,
-		)
+		return fmt.Errorf("enqueue dynamic device groups after inventory update for device %s: %w", payload.DeviceID, err)
 	}
📝 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
if err := w.store.Queries().EnqueueAllDynamicDeviceGroups(ctx, "device_"+payload.DeviceID+"_changed"); err != nil {
w.logger.Warn("failed to enqueue dynamic device groups after inventory update",
"device_id", payload.DeviceID,
"error", err,
)
}
if err := w.store.Queries().EnqueueAllDynamicDeviceGroups(ctx, "device_"+payload.DeviceID+"_changed"); err != nil {
return fmt.Errorf("enqueue dynamic device groups after inventory update for device %s: %w", payload.DeviceID, err)
}
🤖 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 `@internal/control/inbox_worker.go` around lines 492 - 497, The call to
w.store.Queries().EnqueueAllDynamicDeviceGroups currently only logs a warning
and swallows the error, preventing the task from being retried; change the
handler so that when EnqueueAllDynamicDeviceGroups (the call with key
"device_"+payload.DeviceID+"_changed") returns a non-nil error you log it
(optional) and then return that error (or wrap it) from the enclosing handler
function so Asynq will retry the task instead of treating it as success.

@PaulDotterer PaulDotterer merged commit 59ad7d3 into main May 16, 2026
5 checks passed
@PaulDotterer PaulDotterer deleted the feat/301-reactive-triggers-go branch May 16, 2026 17:17
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.

refactor(store): wave F — reactive triggers → Go listeners

1 participant