Skip to content

[management] Replace in-memory expose tracker with SQL-backed operations#5494

Merged
mlsmaycon merged 5 commits intomainfrom
refactor/expose-reaper-with-sql
Mar 4, 2026
Merged

[management] Replace in-memory expose tracker with SQL-backed operations#5494
mlsmaycon merged 5 commits intomainfrom
refactor/expose-reaper-with-sql

Conversation

@mlsmaycon
Copy link
Copy Markdown
Collaborator

@mlsmaycon mlsmaycon commented Mar 3, 2026

Describe your changes

The expose tracker used sync.Map for in-memory TTL tracking of active expose sessions, which broke and lost all sessions on restart.

Replace with SQL-backed operations that reuse the existing meta_last_renewed_at column:

  • Add store methods: RenewEphemeralService, GetExpiredEphemeralServices, CountEphemeralServicesByPeer, EphemeralServiceExists
  • Move duplicate/limit checks inside a transaction with row-level locking (SELECT ... FOR UPDATE) to prevent concurrent bypass
  • Reaper re-checks expiry under row lock to avoid deleting a just-renewed service and prevent duplicate event emission
  • Add composite index on (source, source_peer) for efficient queries
  • Batch-limit and column-select the reaper query to avoid DB/GC spikes
  • Filter out malformed rows with empty source_peer

Issue ticket number and link

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

Summary by CodeRabbit

  • New Features

    • Peer-exposed services are now DB-backed with transactional create/renew/delete and batched TTL-based cleanup; enforces per-peer limits and improves multi-instance reliability.
    • Composite index added to optimize queries for peer-originated services.
  • Bug Fixes

    • Reaper is resilient to concurrent deletes/renewals and logs expirations with domain/peer context.
  • Tests

    • Switched to integration-style tests covering creation, expiration, renewal, concurrency, and reaping.

The expose tracker used sync.Map for in-memory TTL tracking of active
expose sessions, which broke in HA deployments (renew landing on a
different instance) and lost all sessions on restart.

Replace with SQL-backed operations that reuse the existing
meta_last_renewed_at column:

- Add store methods: RenewEphemeralService, GetExpiredEphemeralServices,
CountEphemeralServicesByPeer, EphemeralServiceExists
- Move duplicate/limit checks inside a transaction with row-level
locking (SELECT ... FOR UPDATE) to prevent concurrent bypass
- Reaper re-checks expiry under row lock to avoid deleting a
just-renewed service and prevent duplicate event emission in HA
- Add composite index on (source, source_peer) for efficient queries
- Batch-limit and column-select the reaper query to avoid DB/GC spikes
- Filter out malformed rows with empty source_peer
@mlsmaycon mlsmaycon requested a review from pascal-fischer March 3, 2026 22:24
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces in-memory per-peer ephemeral-service tracking with a DB-backed exposeReaper. Adds store methods to renew, query, count, and check ephemeral services; refactors Manager to persist/delete/renew ephemeral services transactionally; reaper batches expired services and deletes them via the Manager/store. Tests converted to integration-style assertions.

Changes

Cohort / File(s) Summary
Expose reaper & tests
management/internals/modules/reverseproxy/service/manager/expose_tracker.go, management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go
Removes in-memory exposeTracker and tracked-expose logic; introduces exposeReaper that periodically queries GetExpiredEphemeralServices and delegates deletions to Manager; tests converted from unit-tracker to integration-style reaper/store tests simulating expirations.
Manager lifecycle & tests
management/internals/modules/reverseproxy/service/manager/manager.go, management/internals/modules/reverseproxy/service/manager/manager_test.go
Replaces tracker interactions with DB-backed lifecycle: adds persistNewEphemeralService, deleteExpiredPeerService; updates CreateServiceFromPeer, RenewServiceFromPeer, StopServiceFromPeer to use store transactions and events; tests updated to assert store counts/existence.
Service model index
management/internals/modules/reverseproxy/service/service.go
Adds composite GORM index idx_service_source_peer on Source and SourcePeer to support efficient ephemeral-service queries.
Store API, SQL impl & mocks
management/server/store/store.go, management/server/store/sql_store.go, management/server/store/store_mock.go
Adds store methods: RenewEphemeralService, GetExpiredEphemeralServices, CountEphemeralServicesByPeer, EphemeralServiceExists; implements SQL-backed behavior and expands mocks for transactional/reaper tests.
Misc tests & wiring
management/internals/modules/reverseproxy/service/manager/..._test.go, management/internals/modules/reverseproxy/service/manager/...
Tests and manager initialization updated to use exposeReaper and store-backed assertions (e.g., CountEphemeralServicesByPeer, GetServiceByDomain) instead of internal tracker state.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Reaper as Reaper (exposeReaper) style Reaper fill:rgba(135,206,250,0.5)
participant Manager as Manager style Manager fill:rgba(144,238,144,0.5)
participant Store as Store/SqlStore style Store fill:rgba(255,223,186,0.5)
participant DB as Database style DB fill:rgba(221,160,221,0.5)
participant Cluster as ClusterNotifier style Cluster fill:rgba(240,230,140,0.5)

Reaper->>Store: GetExpiredEphemeralServices(ttl, limit)
Store->>DB: SELECT ... WHERE meta_last_renewed_at < cutoff
DB-->>Store: expired rows
Store-->>Reaper: []*Service
loop per expired service
Reaper->>Manager: deleteExpiredPeerService(service)
Manager->>Store: EphemeralServiceExists(..., FOR UPDATE)
Store->>DB: SELECT ... FOR UPDATE / DELETE
DB-->>Store: deletion result / NotFound
Store-->>Manager: deletion outcome
Manager->>Cluster: Notify service deletion event
Cluster-->>Manager: Ack
end

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • pascal-fischer
  • crn4

Poem

🐇 I hopped from memory to rows of clay,

Ephemeral blooms now audited by day.
The reaper hums softly in batched delight,
Transactions tuck services into night,
Cluster bells chime — all tidy and right.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% 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
Title check ✅ Passed The title accurately and concisely describes the main change: replacing an in-memory expose tracker with SQL-backed operations, which is the core refactor across all modified files.
Description check ✅ Passed The description is comprehensive and includes all template requirements: detailed explanation of changes, classification of work type (feature and refactor), and documentation decision, though issue ticket number is missing.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/expose-reaper-with-sql

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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go (1)

67-103: Rename this test to match what it actually verifies.

TestConcurrentReapAndRenew currently performs concurrent reap + count, not renew.

✏️ Suggested rename
-func TestConcurrentReapAndRenew(t *testing.T) {
+func TestConcurrentReapAndCount(t *testing.T) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go`
around lines 67 - 103, The test function TestConcurrentReapAndRenew is misnamed
because it runs a concurrent reap and a CountEphemeralServicesByPeer call (not a
renew); rename the test to something accurate (e.g., TestConcurrentReapAndCount
or TestConcurrentReapAndCountEphemeralServicesByPeer) by updating the function
declaration, keeping the body intact; this affects the function named
TestConcurrentReapAndRenew and references to mgr.exposeReaper.reapExpiredExposes
and mgr.store.CountEphemeralServicesByPeer so reviewers can locate the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@management/internals/modules/reverseproxy/service/manager/expose_tracker.go`:
- Around line 49-56: The code calls status.FromError(err) and dereferences
s.ErrorType without checking s for nil; update the deleteExpiredPeerService
error handling in expose_tracker.go (the block where err :=
r.manager.deleteExpiredPeerService(...) and s, _ := status.FromError(err) are
used) to first check err==nil, then if err!=nil call status.FromError and guard
s!=nil before accessing s.ErrorType; treat non-status errors (s==nil) as generic
errors (log or handle appropriately) and keep the existing NotFound branch when
s!=nil and s.ErrorType == status.NotFound so the reaper goroutine cannot panic
on non-status errors.

In `@management/internals/modules/reverseproxy/service/manager/manager.go`:
- Around line 222-237: In persistNewEphemeralService: the per-peer expose limit
can be bypassed due to zero-row gap races because EphemeralServiceExists and
CountEphemeralServicesByPeer don't lock when no rows exist; to fix, inside the
transaction call transaction.GetPeerByID(ctx, store.LockingStrengthUpdate,
accountID, peerID) before invoking
EphemeralServiceExists/CountEphemeralServicesByPeer to acquire a FOR UPDATE lock
on the peer row and serialize concurrent create attempts for that peer, and
handle/return any error from GetPeerByID (e.g., not found or DB error) before
proceeding with the existing checks.

---

Nitpick comments:
In
`@management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go`:
- Around line 67-103: The test function TestConcurrentReapAndRenew is misnamed
because it runs a concurrent reap and a CountEphemeralServicesByPeer call (not a
renew); rename the test to something accurate (e.g., TestConcurrentReapAndCount
or TestConcurrentReapAndCountEphemeralServicesByPeer) by updating the function
declaration, keeping the body intact; this affects the function named
TestConcurrentReapAndRenew and references to mgr.exposeReaper.reapExpiredExposes
and mgr.store.CountEphemeralServicesByPeer so reviewers can locate the logic.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d7c8e37 and c4e3f09.

📒 Files selected for processing (8)
  • management/internals/modules/reverseproxy/service/manager/expose_tracker.go
  • management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go
  • management/internals/modules/reverseproxy/service/manager/manager.go
  • management/internals/modules/reverseproxy/service/manager/manager_test.go
  • management/internals/modules/reverseproxy/service/service.go
  • management/server/store/sql_store.go
  • management/server/store/store.go
  • management/server/store/store_mock.go

…bypass

- Serialize concurrent service creation for the same peer using row-level locking on the peer table.
- Adjust error handling in expose tracker to handle deletion results more robustly.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
management/internals/modules/reverseproxy/service/manager/expose_tracker.go (1)

46-59: Consider short-circuiting the reap loop when context is canceled.

If shutdown happens mid-batch, Line 49 will execute with a canceled context and may emit noisy errors for the rest of the batch. A fast ctx.Err() check inside the loop makes teardown quieter.

Suggested tweak
 for _, svc := range expired {
+    if ctx.Err() != nil {
+        return
+    }
+
     log.Infof("reaping expired expose session for peer %s, domain %s", svc.SourcePeer, svc.Domain)

     err := r.manager.deleteExpiredPeerService(ctx, svc.AccountID, svc.SourcePeer, svc.ID)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@management/internals/modules/reverseproxy/service/manager/expose_tracker.go`
around lines 46 - 59, The loop in expose_tracker.go should short-circuit when
the context is canceled to avoid noisy errors during shutdown: inside the for _,
svc := range expired loop, check ctx.Err() (or ctx.Done()) before calling
r.manager.deleteExpiredPeerService and break/return if it is non-nil, so that
deleteExpiredPeerService is not invoked with a canceled context; update the
block around the call to r.manager.deleteExpiredPeerService(ctx, svc.AccountID,
svc.SourcePeer, svc.ID) accordingly to stop processing remaining svc entries
when ctx is canceled.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@management/internals/modules/reverseproxy/service/manager/manager.go`:
- Around line 885-912: The second TTL check is unreliable for inferring
deletion; modify the transaction block around transaction.DeleteService to set a
local boolean (e.g., deleted := false then deleted = true only when
transaction.DeleteService succeeds) and return that state from the transaction
context to the outer scope, then guard the post-transaction side effects (calls
to m.store.GetPeerByID, addPeerInfoToEventMeta, m.accountManager.StoreEvent,
m.proxyController.SendServiceUpdateToCluster, and
m.accountManager.UpdateAccountPeers) behind that deleted flag instead of
re-checking svc.Meta.LastRenewedAt against exposeTTL; ensure the flag is only
set on successful deletion and used to decide whether to emit events/send
cluster updates.

---

Nitpick comments:
In `@management/internals/modules/reverseproxy/service/manager/expose_tracker.go`:
- Around line 46-59: The loop in expose_tracker.go should short-circuit when the
context is canceled to avoid noisy errors during shutdown: inside the for _, svc
:= range expired loop, check ctx.Err() (or ctx.Done()) before calling
r.manager.deleteExpiredPeerService and break/return if it is non-nil, so that
deleteExpiredPeerService is not invoked with a canceled context; update the
block around the call to r.manager.deleteExpiredPeerService(ctx, svc.AccountID,
svc.SourcePeer, svc.ID) accordingly to stop processing remaining svc entries
when ctx is canceled.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 44dd1e57-1e36-4943-bd2b-99513b560c60

📥 Commits

Reviewing files that changed from the base of the PR and between c4e3f09 and 37335fc.

📒 Files selected for processing (2)
  • management/internals/modules/reverseproxy/service/manager/expose_tracker.go
  • management/internals/modules/reverseproxy/service/manager/manager.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
management/internals/modules/reverseproxy/service/manager/manager.go (1)

723-729: Consider removing the extra peer read in the create flow.

CreateServiceFromPeer reads the peer at Line 718, and persistNewEphemeralService reads/locks it again at Line 228. You can likely keep only one read (post-persist for event metadata) to reduce DB round-trips.

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

In `@management/internals/modules/reverseproxy/service/manager/manager.go` around
lines 723 - 729, CreateServiceFromPeer currently reads/locks the peer before
persisting but persistNewEphemeralService reads/locks it again; remove the
pre-persist peer read to eliminate the extra DB round-trip by only setting
svc.SourcePeer = peerID and letting persistNewEphemeralService perform the peer
load/lock and return any needed peer metadata/event info. Concretely, stop
calling the peer-read in CreateServiceFromPeer (leave svc.SourcePeer = peerID
and LastRenewedAt set), update persistNewEphemeralService to accept the peerID
(or keep its signature but perform the single read internally) and return the
loaded peer or event fields so CreateServiceFromPeer can use them for subsequent
event/logging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@management/internals/modules/reverseproxy/service/manager/manager.go`:
- Around line 723-729: CreateServiceFromPeer currently reads/locks the peer
before persisting but persistNewEphemeralService reads/locks it again; remove
the pre-persist peer read to eliminate the extra DB round-trip by only setting
svc.SourcePeer = peerID and letting persistNewEphemeralService perform the peer
load/lock and return any needed peer metadata/event info. Concretely, stop
calling the peer-read in CreateServiceFromPeer (leave svc.SourcePeer = peerID
and LastRenewedAt set), update persistNewEphemeralService to accept the peerID
(or keep its signature but perform the single read internally) and return the
loaded peer or event fields so CreateServiceFromPeer can use them for subsequent
event/logging.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2a522487-413e-48af-8f95-571818425f8d

📥 Commits

Reviewing files that changed from the base of the PR and between 37335fc and b6f573c.

📒 Files selected for processing (1)
  • management/internals/modules/reverseproxy/service/manager/manager.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
management/internals/modules/reverseproxy/service/manager/expose_tracker.go (1)

52-53: Avoid success-sounding info log before deletion outcome is known.

Line 52 logs as if reaping is happening, but deletion can legitimately no-op after lock re-check (recent renew). Consider debug wording like “attempting reap”.

📝 Suggested tweak
-		log.Infof("reaping expired expose session for peer %s, domain %s", svc.SourcePeer, svc.Domain)
+		log.Debugf("attempting to reap expired expose candidate for peer %s, domain %s", svc.SourcePeer, svc.Domain)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@management/internals/modules/reverseproxy/service/manager/expose_tracker.go`
around lines 52 - 53, The info log "reaping expired expose session for peer %s,
domain %s" should not assert success before deletion; change this log (at the
point where svc is checked and before deletion/lock re-check) to a
debug/trace-level message like "attempting to reap expired expose session for
peer %s, domain %s" and only emit an info-level message after you actually
remove the session (or emit a debug/no-op message if the lock re-check prevents
deletion); update the log call that references svc.SourcePeer and svc.Domain
accordingly so the pre-delete message doesn't sound like a completed action.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@management/internals/modules/reverseproxy/service/manager/expose_tracker.go`:
- Around line 27-29: Replace the blocking time.Sleep call so startup jitter
respects context cancellation: after computing rn := rand.IntN(10), create a
timer for time.Duration(rn)*time.Second and use a select that waits on either
the timer.C or ctx.Done(); on ctx.Done() stop the timer and return/abort the
startup path to avoid delaying shutdown. Target the rn := rand.IntN(10) and
time.Sleep(...) site and ensure you stop the timer to avoid leaks.

---

Nitpick comments:
In `@management/internals/modules/reverseproxy/service/manager/expose_tracker.go`:
- Around line 52-53: The info log "reaping expired expose session for peer %s,
domain %s" should not assert success before deletion; change this log (at the
point where svc is checked and before deletion/lock re-check) to a
debug/trace-level message like "attempting to reap expired expose session for
peer %s, domain %s" and only emit an info-level message after you actually
remove the session (or emit a debug/no-op message if the lock re-check prevents
deletion); update the log call that references svc.SourcePeer and svc.Domain
accordingly so the pre-delete message doesn't sound like a completed action.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1098c29a-0148-4529-9f95-99bbc30a796c

📥 Commits

Reviewing files that changed from the base of the PR and between b6f573c and 8cdf32d.

📒 Files selected for processing (1)
  • management/internals/modules/reverseproxy/service/manager/expose_tracker.go

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
management/internals/modules/reverseproxy/service/manager/manager.go (1)

57-59: Consider a nil-guard before starting the reaper.

If Manager is ever created without NewManager (e.g., struct literal in tests), this call can panic. A tiny guard makes startup more robust.

Suggested hardening
 func (m *Manager) StartExposeReaper(ctx context.Context) {
+	if m.exposeReaper == nil {
+		log.WithContext(ctx).Warn("expose reaper is not initialized")
+		return
+	}
 	m.exposeReaper.StartExposeReaper(ctx)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@management/internals/modules/reverseproxy/service/manager/manager.go` around
lines 57 - 59, Add a nil-guard in Manager.StartExposeReaper so it does not panic
when Manager or its exposeReaper field is nil: check that m != nil and
m.exposeReaper != nil before calling m.exposeReaper.StartExposeReaper(ctx), and
return early if either is nil; update any callers/tests if they rely on the
panic behavior. This change targets the Manager.StartExposeReaper method and the
exposeReaper field usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@management/internals/modules/reverseproxy/service/manager/manager.go`:
- Around line 57-59: Add a nil-guard in Manager.StartExposeReaper so it does not
panic when Manager or its exposeReaper field is nil: check that m != nil and
m.exposeReaper != nil before calling m.exposeReaper.StartExposeReaper(ctx), and
return early if either is nil; update any callers/tests if they rely on the
panic behavior. This change targets the Manager.StartExposeReaper method and the
exposeReaper field usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 452adb64-7694-45ce-b9e7-9fc595f881ab

📥 Commits

Reviewing files that changed from the base of the PR and between 8cdf32d and 5888583.

📒 Files selected for processing (1)
  • management/internals/modules/reverseproxy/service/manager/manager.go

@mlsmaycon mlsmaycon merged commit 8e7b016 into main Mar 4, 2026
43 checks passed
@mlsmaycon mlsmaycon deleted the refactor/expose-reaper-with-sql branch March 4, 2026 17:15
@coderabbitai coderabbitai Bot mentioned this pull request Mar 6, 2026
7 tasks
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