Skip to content

CBG-5472: Make the stats logger independent of the database lock - #8431

Open
bbrks wants to merge 1 commit into
mainfrom
CBG-5472
Open

CBG-5472: Make the stats logger independent of the database lock#8431
bbrks wants to merge 1 commit into
mainfrom
CBG-5472

Conversation

@bbrks

@bbrks bbrks commented Jul 6, 2026

Copy link
Copy Markdown
Member

CBG-5472

Avoid blocking stats logger from database updates and initialisations. Access lock-free version of databases on ServerContext to build stats and minimise the locking required inside DbStats.

Pre-review checklist

Dependencies (if applicable)

Integration Tests

@bbrks bbrks self-assigned this Jul 6, 2026
Base automatically changed from CBG-3658 to main July 7, 2026 13:41
Copilot AI review requested due to automatic review settings July 7, 2026 13:42

This comment was marked as outdated.

@bbrks
bbrks force-pushed the CBG-5472 branch 4 times, most recently from f2a7a17 to 654255a Compare July 17, 2026 09:53
@bbrks
bbrks force-pushed the CBG-5472 branch 3 times, most recently from f27fda1 to 623b119 Compare July 31, 2026 11:27
The stats logger stalled when a config update held _databasesLock for
write across an unbounded index-readiness wait.

- rest: updateCalculatedStats now reads databasesSnapshot, an atomic
  copy of _databases refreshed under the write lock on every mutation,
  so stats collection never blocks on _databasesLock.
- base: SgwStats.String() marshals a shallow copy of the DbStats map,
  and NewDBStats/ClearDBStats do their Prometheus (un)registration
  outside dbStatsMapMutex. The serialized fields moved into an embedded
  sgwStatsFields struct so String() copies them wholesale rather than
  naming each one, and a new stat cannot be dropped from the output.
- base: ClearDBStats holds dbReplicatorStatsMutex while iterating
  DbReplicatorStats. A replication registering during teardown could
  otherwise panic with "concurrent map read and map write".
- base: add PopMapEntry map helper.

DBReplicatorStats holds dbReplicatorStatsMutex for the whole function
again, so an entry is published only once every stat is built. An
earlier version inserted the empty entry first, which let a concurrent
caller receive a struct of nil stats, and cached a half-built entry
forever when registration failed partway.

DatabaseContext.Close() now marks the database offline before tearing
it down. Close() never changed State, so the DBOnline guard in
updateCalculatedStats could not skip a closing database once the reader
stopped taking _databasesLock. _unloadDatabase also drops the database
from the snapshot before closing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@torcolvin torcolvin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Pretty much just readability comments and hoping to screw this up in the future.

Comment thread db/database.go
Comment on lines +807 to +808
// Mark offline before teardown so the lock-free stats reader skips this database (CBG-5472).
atomic.StoreUint32(&context.State, DBOffline)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think conceptually Stopping makes more sense here?

Comment thread rest/server_context.go
_collectionRegistry map[string]string // _collectionRegistry is a map of fully qualified collection name to db name, used for local uniqueness checks
_dbConfigs map[string]*RuntimeDatabaseConfig // _dbConfigs is a map of db name to the RuntimeDatabaseConfig
_databases map[string]*db.DatabaseContext // _databases is a map of dbname to db.DatabaseContext
_databases map[string]*db.DatabaseContext // _databases is a map of dbname to db.DatabaseContext. Mutations must call _updateDatabasesSnapshot

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rather than this comment, I'd be inclined to make this a type with Insert / Delete / Clear for modification and a Snapshot function?

Comment thread base/stats.go
dbStats.unregisterCBLReplicationPushStats()
// DBReplicatorStats() writes this map lazily, and can run while the database is torn down. Hold
// the mutex so iterating here cannot hit "concurrent map read and map write".
dbStats.dbReplicatorStatsMutex.Lock()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'd feel better if this was in a function with a defer in the rare case that there is something that panics when unloading the database - if there is something that goes wrong when unloading a database, locking the stats would make it impossible to debug

Comment thread base/stats_test.go
Comment on lines +332 to +334
if err != nil {
return // reporting the failure to the caller is the correct behaviour
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This doesn't seem right here?

Comment thread base/stats_test.go
}
close(start)
}
wg.Wait()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

use WaitWithTimeout for sync group waiting.

Comment thread base/stats_test.go

// Iterates DbReplicatorStats concurrently with the registrations above.
stats.ClearDBStats(dbName)
wg.Wait()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should you do wait in a defer? and use WaitWithTimeout for sync group waiting.

@torcolvin torcolvin assigned bbrks and unassigned torcolvin Aug 4, 2026
@torcolvin

Copy link
Copy Markdown
Collaborator

I hit a separate race condition and I've developed a test for it in stats_test.go - this PR fixes test but not the other.

// TestDbReplicatorStatsUnsynchronisedAccess covers concurrent access to DbStats.DbReplicatorStats:
// creating a replication's stats while something else walks the stats tree.  Run with -race.
//
// The expvar case is the one reachable in a running Sync Gateway: any metrics or expvar read that
// lands while a replication is initialising.  The teardown case needs two live DatabaseContexts
// sharing a database name, since DbStats is keyed by name.
func TestDbReplicatorStatsUnsynchronisedAccess(t *testing.T) {
	const iterations = 200

	t.Run("expvar read while creating", func(t *testing.T) {
		const dbName = "statsRaceExpvarDb"
		dbStats, err := SyncGatewayStats.NewDBStats(dbName, false, false, false, false, []string{}, []string{})
		require.NoError(t, err)
		defer SyncGatewayStats.ClearDBStats(dbName)

		var wg sync.WaitGroup
		wg.Go(func() {
			for i := range iterations {
				_, err := dbStats.DBReplicatorStats(fmt.Sprintf("replication%d", i))
				assert.NoError(t, err)
			}
		})
		// SgwStats.String marshals the whole tree, including DbReplicatorStats.
		wg.Go(func() {
			for range iterations {
				_ = SyncGatewayStats.String()
			}
		})
		WaitWithTimeout(t, &wg, time.Minute)
	})

	t.Run("stats teardown while creating", func(t *testing.T) {
		const dbName = "statsRaceTeardownDb"
		// One Clear per iteration: it deletes the map entry, so repeated calls on the same entry return
		// early and never reach the unregister loop.  Replication IDs are unique per iteration so stats
		// created after a Clear has walked the map cannot collide with the next iteration's registration.
		for outer := range iterations {
			dbStats, err := SyncGatewayStats.NewDBStats(dbName, false, false, false, false, []string{}, []string{})
			require.NoError(t, err)

			var wg sync.WaitGroup
			wg.Go(func() {
				for i := range 20 {
					_, err := dbStats.DBReplicatorStats(fmt.Sprintf("replication%d-%d", outer, i))
					assert.NoError(t, err)
				}
			})
			// ClearDBStats iterates DbReplicatorStats to unregister them.
			wg.Go(func() {
				SyncGatewayStats.ClearDBStats(dbName)
			})
			WaitWithTimeout(t, &wg, time.Minute)

			SyncGatewayStats.ClearDBStats(dbName)
		}
	})
}

Before I realised this PR was touching the same code, this is my proposed fix https://github.com/couchbase/sync_gateway/pull/new/dbstats-race - see the message for a longer description.

I hit this bug here https://jenkins.sgwdev.com/job/Pipeline/job/PR-8608/1/testReport/junit/(root)/EE-rest_replicatortest/TestReplicationHeartbeatRemovalPushWithConfigReload_versionVector/ on #8608.

@torcolvin

Copy link
Copy Markdown
Collaborator

I hit a separate race condition and I've developed a test for it in stats_test.go - this PR fixes test but not the other.

// TestDbReplicatorStatsUnsynchronisedAccess covers concurrent access to DbStats.DbReplicatorStats:
// creating a replication's stats while something else walks the stats tree.  Run with -race.
//
// The expvar case is the one reachable in a running Sync Gateway: any metrics or expvar read that
// lands while a replication is initialising.  The teardown case needs two live DatabaseContexts
// sharing a database name, since DbStats is keyed by name.
func TestDbReplicatorStatsUnsynchronisedAccess(t *testing.T) {
	const iterations = 200

	t.Run("expvar read while creating", func(t *testing.T) {
		const dbName = "statsRaceExpvarDb"
		dbStats, err := SyncGatewayStats.NewDBStats(dbName, false, false, false, false, []string{}, []string{})
		require.NoError(t, err)
		defer SyncGatewayStats.ClearDBStats(dbName)

		var wg sync.WaitGroup
		wg.Go(func() {
			for i := range iterations {
				_, err := dbStats.DBReplicatorStats(fmt.Sprintf("replication%d", i))
				assert.NoError(t, err)
			}
		})
		// SgwStats.String marshals the whole tree, including DbReplicatorStats.
		wg.Go(func() {
			for range iterations {
				_ = SyncGatewayStats.String()
			}
		})
		WaitWithTimeout(t, &wg, time.Minute)
	})

	t.Run("stats teardown while creating", func(t *testing.T) {
		const dbName = "statsRaceTeardownDb"
		// One Clear per iteration: it deletes the map entry, so repeated calls on the same entry return
		// early and never reach the unregister loop.  Replication IDs are unique per iteration so stats
		// created after a Clear has walked the map cannot collide with the next iteration's registration.
		for outer := range iterations {
			dbStats, err := SyncGatewayStats.NewDBStats(dbName, false, false, false, false, []string{}, []string{})
			require.NoError(t, err)

			var wg sync.WaitGroup
			wg.Go(func() {
				for i := range 20 {
					_, err := dbStats.DBReplicatorStats(fmt.Sprintf("replication%d-%d", outer, i))
					assert.NoError(t, err)
				}
			})
			// ClearDBStats iterates DbReplicatorStats to unregister them.
			wg.Go(func() {
				SyncGatewayStats.ClearDBStats(dbName)
			})
			WaitWithTimeout(t, &wg, time.Minute)

			SyncGatewayStats.ClearDBStats(dbName)
		}
	})
}

Before I realised this PR was touching the same code, this is my proposed fix couchbase/sync_gateway/pull/new/dbstats-race - see the message for a longer description.

I hit this bug here jenkins.sgwdev.com/job/Pipeline/job/PR-8608/1/testReport/junit/(root)/EE-rest_replicatortest/TestReplicationHeartbeatRemovalPushWithConfigReload_versionVector on #8608.

Actually, I know what the problem is here: to test ISGR rebalancing, I create two databases on the same "node" in the same config group. In this case, they have to share the same database name, and this behavior is expected.

However, this would never occur in the real world because a sync gateway production node can't have two databases with the same name on the same node.

This means this problem probably still could be fixed with locking in this code, however it is test only.

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.

3 participants