Track user activity in a persistent cache

Add a new Groovy script `track-active-users-1.0.groovy` to track user
activities by entering their `id` and timestamp into
`track-active-users_cache` persistent cache.

The tracking is implemented as a side effect on a new
`GroupBackend` implementation that handles all group `UUID`'s. This way
our backend `membershipsOf(CurrentUser)` method will be called whenever
any group membership resolution is done. This gives us a single
place to get the user activities for all supported entry points (UI,
REST, SSH, git-over-http). The impact of on the user group resolution
should be neglectable and should be equal to the performance of the
persistent cache `get()` and `put()` calls. The user activity timestamp
is stored with one-minute precision.

Bear in mind, that in the case of git-over-http access to public
repositories, even when the user provides authentication details, Gerrit
will not consult any of the `GroupBackend`'s. This means that clones and
fetches of publicly visible repositories will not be counted as user
activity.

Bug: Issue Issue 327730871
Change-Id: I5827288d40bba4c6b1e7b20d9aae822db8eabc29
diff --git a/admin/README.md b/admin/README.md
index 7143588..9cca543 100644
--- a/admin/README.md
+++ b/admin/README.md
@@ -11,3 +11,4 @@
 * [warm-cache-1.0.groovy](/admin/warm-cache-1.0.groovy) - Controls the Gerrit cache warm-up via command-line
 * [readonly-1.0.groovy](/admin/readonly-1.0.groovy) - Set all Gerrit projects in read-only mode during maintenance
 * [stale-packed-refs-1.0.groovy](/admin/stale-packed-refs-1.0.groovy) - Check all specified projects and expose metric with age of `packed-refs.lock` files
+* [track-active-users.groovy](/admin/track-active-users.groovy) - Tracks users login in `track-active-users_cache`
diff --git a/admin/track-active-users-1.0.groovy b/admin/track-active-users-1.0.groovy
new file mode 100644
index 0000000..86a77a8
--- /dev/null
+++ b/admin/track-active-users-1.0.groovy
@@ -0,0 +1,92 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import com.google.common.cache.*
+import com.google.gerrit.common.*
+import com.google.gerrit.entities.*
+import com.google.gerrit.extensions.registration.*
+import com.google.gerrit.server.*
+import com.google.gerrit.server.account.*
+import com.google.gerrit.server.cache.*
+import com.google.gerrit.server.project.*
+import com.google.inject.*
+import com.google.inject.name.*
+
+import java.time.*
+
+import static java.util.concurrent.TimeUnit.*
+
+class TrackActiveUsersCache extends CacheModule {
+  static final NAME = "users_cache"
+  static final DEFAULT_CACHE_TTL = Duration.ofDays(90)
+  static final MAX_SIZE = 300_000
+
+  @Override
+  protected void configure() {
+    persist(NAME, Integer, Long)
+        .diskLimit(MAX_SIZE)
+        .maximumWeight(MAX_SIZE)
+        .expireAfterWrite(DEFAULT_CACHE_TTL)
+  }
+}
+
+@Singleton
+class TrackingGroupBackend implements GroupBackend {
+  @Inject
+  @Named(TrackActiveUsersCache.NAME)
+  Cache<Integer, Long> trackActiveUsersCache
+
+  @Override
+  boolean handles(AccountGroup.UUID uuid) {
+    return true
+  }
+
+  @Override
+  GroupDescription.Basic get(AccountGroup.UUID uuid) {
+    return null
+  }
+
+  @Override
+  Collection<GroupReference> suggest(String name, @Nullable ProjectState project) {
+    return Collections.emptyList()
+  }
+
+  @Override
+  GroupMembership membershipsOf(CurrentUser user) {
+    if (user.identifiedUser) {
+      def accountId = user.accountId.get()
+      def currentMinutes = MILLISECONDS.toMinutes(System.currentTimeMillis())
+      if (trackActiveUsersCache.getIfPresent(accountId) != currentMinutes) {
+        trackActiveUsersCache.put(accountId, currentMinutes)
+      }
+    }
+    return GroupMembership.EMPTY
+  }
+
+  @Override
+  boolean isVisibleToAll(AccountGroup.UUID uuid) {
+    return false
+  }
+}
+
+class TrackActiveUsersModule extends AbstractModule {
+  @Override
+  void configure() {
+    install(new TrackActiveUsersCache())
+
+    DynamicSet.bind(binder(), GroupBackend).to(TrackingGroupBackend)
+  }
+}
+
+modules = [TrackActiveUsersModule]