BF cache-key slug drift on ws_socket_auth_*; resilient User column in watch -v

Handler::cacheAuthenticatedUser() and ::cleanupChannelConnections() were
writing/forgetting ws_socket_auth_<rawSocketId> while
WebsocketService::getAuth() and ::setUserAuthed() have always slugged
("123.456" → "123-456"). Result: the cache write was reachable from the
package's own writer path but not from the service-layer reader, so the
admin tooling (websockets:watch -v) saw cache misses and rendered #<id>
instead of the configured IdentityFormatter output.

Also: WatchStats now batch-loads missing users via the configured auth
provider model in one query per render, so the User column still renders
the full formatter shape even when the per-socket cache blob predates
the writer or got evicted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-04-27 13:56:07 +02:00
parent a46243a706
commit dd6be893a6
2 changed files with 71 additions and 19 deletions

View File

@ -114,9 +114,24 @@ class WatchStats extends Command
return; return;
} }
// Verbose: one summary row per channel, then one sub-row per connection. // Verbose: collect every authed userId whose per-socket cache blob is
// The sub-rows fill the User and Duration columns; the summary fills // missing, batch-load them from the auth provider's user model in one
// Channel and Connections. Dashes mark "not applicable on this row". // query, and reuse the resolved subject when formatting each row. This
// covers two real cases: connections that predate the cache writer
// being added, and Redis evictions under memory pressure.
$missingByUserId = [];
foreach ($perChannel as $sockets) {
foreach ($sockets as $socketId) {
if (! isset($authedUsers[$socketId])) {
continue;
}
if (! WebsocketService::getAuth($socketId)) {
$missingByUserId[$authedUsers[$socketId]] = true;
}
}
}
$usersById = $this->loadAuthUsers(array_keys($missingByUserId));
$now = time(); $now = time();
$rows = []; $rows = [];
foreach ($perChannel as $channel => $sockets) { foreach ($perChannel as $channel => $sockets) {
@ -131,7 +146,7 @@ class WatchStats extends Command
$rows[] = [ $rows[] = [
' <fg=gray>↳</>', ' <fg=gray>↳</>',
"<fg=gray>{$socketId}</>", "<fg=gray>{$socketId}</>",
$this->formatUser($socketId, $authedUsers), $this->formatUser($socketId, $authedUsers, $usersById),
$this->formatDuration($socketId, $now), $this->formatDuration($socketId, $now),
]; ];
} }
@ -140,26 +155,55 @@ class WatchStats extends Command
$this->table(['Channel', 'Connections', 'User', 'Duration'], $rows); $this->table(['Channel', 'Connections', 'User', 'Duration'], $rows);
} }
private function formatUser(string $socketId, array $authedUsers): string /**
* Load users by id from the configured auth provider's model.
*
* Returns [id => model] for found ids; absent ids simply don't appear.
* Returns [] silently on any failure (no auth provider, model missing,
* DB unreachable) this is admin tooling, not a critical path.
*/
private function loadAuthUsers(array $ids): array
{
if (empty($ids)) {
return [];
}
$model = config('auth.providers.users.model');
if (! is_string($model) || ! class_exists($model)) {
return [];
}
try {
return $model::query()
->whereIn('id', $ids)
->get()
->keyBy('id')
->all();
} catch (\Throwable $e) {
return [];
}
}
private function formatUser(string $socketId, array $authedUsers, array $usersById): string
{ {
if (! isset($authedUsers[$socketId])) { if (! isset($authedUsers[$socketId])) {
return '<fg=gray>Guest</>'; return '<fg=gray>Guest</>';
} }
$subject = WebsocketService::getAuth($socketId); $userId = $authedUsers[$socketId];
$subject = WebsocketService::getAuth($socketId) ?: ($usersById[$userId] ?? null);
if (! $subject) { if (! $subject) {
// Authed-but-expired: the socket is in ws_socket_authed_users // Auth provider couldn't resolve the user either (deleted? auth
// but the per-socket user blob has fallen out of cache. Show the // model not Eloquent?). Fall back to the bare id rather than
// bare id from authedUsers so the connection isn't mislabeled // mislabeling as Guest, so debugging stays accurate.
// as a Guest. return '<fg=yellow>#' . $userId . '</>';
return '<fg=yellow>#' . $authedUsers[$socketId] . '</>';
} }
// Delegate formatting to the bound IdentityFormatter so apps with // Delegate to the bound IdentityFormatter so apps with non-User
// non-User subjects (Company, ApiClient, etc.) can render their own // subjects (Company, ApiClient, etc.) can render their own shape
// shape without forking the package. // without forking the package.
$formatter = app(IdentityFormatter::class); return app(IdentityFormatter::class)->format($subject, $socketId);
return $formatter->format($subject, $socketId);
} }
private function formatDuration(string $socketId, int $now): string private function formatDuration(string $socketId, int $now): string

View File

@ -963,7 +963,11 @@ class Handler implements MessageComponentInterface
protected function cleanupChannelConnections(ConnectionInterface $connection): void protected function cleanupChannelConnections(ConnectionInterface $connection): void
{ {
$cacheUpdates = []; $cacheUpdates = [];
$cacheDeletes = ['ws_socket_auth_' . $connection->socketId]; // Slug socketId so this key matches what WebsocketService::getAuth()
// reads and what setUserAuthed() writes; raw socketIds contain dots
// ("123.456") that the slug() call strips ("123-456") and previously
// produced read/write key drift that masked auth state on disconnect.
$cacheDeletes = ['ws_socket_auth_' . str()->slug($connection->socketId)];
$socketId = $connection->socketId; $socketId = $connection->socketId;
foreach ($this->channel_connections as $channel => $connections) { foreach ($this->channel_connections as $channel => $connections) {
@ -1350,9 +1354,13 @@ class Handler implements MessageComponentInterface
$authed_users = cache()->get('ws_socket_authed_users') ?? []; $authed_users = cache()->get('ws_socket_authed_users') ?? [];
$authed_users[$socketId] = $user->id; $authed_users[$socketId] = $user->id;
// Single batched cache write - reduces 3 operations to 1 // Single batched cache write - reduces 3 operations to 1.
// Slug the socketId so this key matches WebsocketService::getAuth()
// and setUserAuthed() — raw socketIds like "123.456" don't survive
// a round-trip with slugged ("123-456") readers, and previously this
// write was effectively unreachable from the rest of the codebase.
cache()->setMultiple([ cache()->setMultiple([
'ws_socket_auth_' . $socketId => $user, 'ws_socket_auth_' . str()->slug($socketId) => $user,
'ws_socket_authed_users' => $authed_users 'ws_socket_authed_users' => $authed_users
]); ]);