diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index cb46f65a3..0dedd51f5 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -20,6 +20,7 @@ import ( "github.com/kurodakayn/mpp-backend/internal/middleware" "github.com/kurodakayn/mpp-backend/internal/models" + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" "github.com/kurodakayn/mpp-backend/internal/services/email" ) @@ -217,15 +218,15 @@ func (h *AuthHandler) generateRandomCode(length int) (string, error) { } func verificationCodeKey(scene, email string) string { - return fmt.Sprintf("auth:code:%s:%s", scene, verificationEmailKeyDigest(email)) + return fmt.Sprintf("auth:code:%s:%s", rediskey.Tag("email", verificationEmailKeyDigest(email)), rediskey.Part(scene)) } func verificationAttemptKey(scene, email string) string { - return fmt.Sprintf("auth:code_attempts:%s:%s", scene, verificationEmailKeyDigest(email)) + return fmt.Sprintf("auth:code_attempts:%s:%s", rediskey.Tag("email", verificationEmailKeyDigest(email)), rediskey.Part(scene)) } func verificationLastSendKey(scene, email string) string { - return fmt.Sprintf("auth:last_send:%s:%s", scene, verificationEmailKeyDigest(email)) + return fmt.Sprintf("auth:last_send:%s:%s", rediskey.Tag("email", verificationEmailKeyDigest(email)), rediskey.Part(scene)) } func canonicalVerificationEmail(email string) string { diff --git a/backend/internal/handlers/auth_test.go b/backend/internal/handlers/auth_test.go index 7f5ddfc10..1f2cd5cac 100644 --- a/backend/internal/handlers/auth_test.go +++ b/backend/internal/handlers/auth_test.go @@ -17,6 +17,7 @@ import ( "gorm.io/gorm" "github.com/kurodakayn/mpp-backend/internal/models" + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" "github.com/kurodakayn/mpp-backend/internal/services/email" ) @@ -49,6 +50,19 @@ func storeVerificationCode(t *testing.T, rdb *redis.Client, scene, email, code s require.NoError(t, rdb.Set(context.Background(), verificationCodeKey(scene, email), code, 0).Err()) } +func TestVerificationRedisKeysShareEmailHashTag(t *testing.T) { + email := "Person@example.com" + + codeKey := verificationCodeKey("register", email) + attemptKey := verificationAttemptKey("register", email) + lastSendKey := verificationLastSendKey("register", email) + + require.True(t, rediskey.ShareTag(codeKey, attemptKey, lastSendKey)) + tag, ok := rediskey.ExtractTag(codeKey) + require.True(t, ok) + require.Equal(t, "email:"+verificationEmailKeyDigest(email), tag) +} + func assertNoRedisKeyContains(t *testing.T, keys []string, values ...string) { t.Helper() for _, key := range keys { diff --git a/backend/internal/pkg/rediskey/rediskey.go b/backend/internal/pkg/rediskey/rediskey.go new file mode 100644 index 000000000..a30edb347 --- /dev/null +++ b/backend/internal/pkg/rediskey/rediskey.go @@ -0,0 +1,65 @@ +package rediskey + +import "strings" + +const Unknown = "unknown" + +func Part(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return Unknown + } + + var builder strings.Builder + lastDash := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == ':' || r == '.' { + builder.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + builder.WriteByte('-') + lastDash = true + } + } + + result := strings.Trim(builder.String(), "-") + if result == "" { + return Unknown + } + return result +} + +func Tag(scope string, value string) string { + return "{" + Part(scope) + ":" + Part(value) + "}" +} + +func ExtractTag(key string) (string, bool) { + start := strings.IndexByte(key, '{') + if start < 0 { + return "", false + } + end := strings.IndexByte(key[start+1:], '}') + if end <= 0 { + return "", false + } + return key[start+1 : start+1+end], true +} + +func ShareTag(keys ...string) bool { + if len(keys) == 0 { + return true + } + expected, ok := ExtractTag(keys[0]) + if !ok { + return false + } + for _, key := range keys[1:] { + tag, ok := ExtractTag(key) + if !ok || tag != expected { + return false + } + } + return true +} diff --git a/backend/internal/pkg/rediskey/rediskey_test.go b/backend/internal/pkg/rediskey/rediskey_test.go new file mode 100644 index 000000000..8b39e16aa --- /dev/null +++ b/backend/internal/pkg/rediskey/rediskey_test.go @@ -0,0 +1,32 @@ +package rediskey + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPartNormalizesUnsafeCharacters(t *testing.T) { + require.Equal(t, "tenant:workspace-1", Part(" Tenant:Workspace 1 ")) + require.Equal(t, Unknown, Part(" @@@ ")) +} + +func TestTagBuildsRedisHashTag(t *testing.T) { + require.Equal(t, "{session:11111111-1111-4111-8111-111111111111}", Tag("session", "11111111-1111-4111-8111-111111111111")) + require.Equal(t, "{tenant:unknown}", Tag("tenant", "")) +} + +func TestShareTagRequiresMatchingHashTags(t *testing.T) { + require.True(t, ShareTag( + "mpp:browser:stream-current:{session:abc}", + "mpp:browser:stream-token:{session:abc}:hash", + )) + require.False(t, ShareTag( + "mpp:browser:stream-current:{session:abc}", + "mpp:browser:stream-token:{session:def}:hash", + )) + require.False(t, ShareTag( + "mpp:browser:stream-current:abc", + "mpp:browser:stream-token:{session:abc}:hash", + )) +} diff --git a/backend/internal/services/browser_session/redis.go b/backend/internal/services/browser_session/redis.go index 503e66c67..3c6fb2ae6 100644 --- a/backend/internal/services/browser_session/redis.go +++ b/backend/internal/services/browser_session/redis.go @@ -11,6 +11,7 @@ import ( "github.com/redis/go-redis/v9" "github.com/kurodakayn/mpp-backend/internal/models" + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" ) type browserSessionLiveState struct { @@ -67,7 +68,7 @@ func (s *BrowserSessionService) cleanupRedisSessionForTenant(ctx context.Context } func browserSessionActiveKey(userID uuid.UUID, platform string) string { - return browserSessionActiveKeyPrefix + userID.String() + ":" + platform + return browserSessionActiveKeyPrefix + rediskey.Tag("user", userID.String()) + ":" + rediskey.Part(platform) } func browserSessionQuotaUserKey(userID uuid.UUID) string { @@ -79,19 +80,19 @@ func browserSessionQuotaTenantKey(tenantID string) string { } func browserSessionKey(sessionID uuid.UUID) string { - return browserSessionKeyPrefix + sessionID.String() + return browserSessionKeyPrefix + rediskey.Tag("session", sessionID.String()) } func browserSessionStreamTokenKey(sessionID uuid.UUID, tokenHash string) string { - return browserSessionStreamTokenPrefix + sessionID.String() + ":" + tokenHash + return browserSessionStreamTokenPrefix + rediskey.Tag("session", sessionID.String()) + ":" + rediskey.Part(tokenHash) } func browserSessionStreamTokenKeyPrefixFor(sessionID uuid.UUID) string { - return browserSessionStreamTokenPrefix + sessionID.String() + ":" + return browserSessionStreamTokenPrefix + rediskey.Tag("session", sessionID.String()) + ":" } func browserSessionStreamCurrentKey(sessionID uuid.UUID) string { - return browserSessionStreamCurrentPrefix + sessionID.String() + return browserSessionStreamCurrentPrefix + rediskey.Tag("session", sessionID.String()) } func browserSessionWorkerHeartbeatKey(workerSessionRef string) string { diff --git a/backend/internal/services/browser_session/redis_keys_test.go b/backend/internal/services/browser_session/redis_keys_test.go new file mode 100644 index 000000000..d469b7d12 --- /dev/null +++ b/backend/internal/services/browser_session/redis_keys_test.go @@ -0,0 +1,32 @@ +package browsersession + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" +) + +func TestBrowserSessionRedisKeysUseApprovedHashTags(t *testing.T) { + sessionID := uuid.New() + userID := uuid.New() + + require.Equal(t, "session:"+sessionID.String(), mustRedisTag(t, browserSessionKey(sessionID))) + require.Equal(t, "user:"+userID.String(), mustRedisTag(t, browserSessionActiveKey(userID, "Douyin"))) + require.True(t, rediskey.ShareTag( + browserSessionKey(sessionID), + browserSessionStreamCurrentKey(sessionID), + browserSessionStreamTokenKey(sessionID, "TOKEN-HASH"), + )) + require.Equal(t, browserSessionStreamTokenPrefix+rediskey.Tag("session", sessionID.String())+":", browserSessionStreamTokenKeyPrefixFor(sessionID)) +} + +func mustRedisTag(t *testing.T, key string) string { + t.Helper() + + tag, ok := rediskey.ExtractTag(key) + require.True(t, ok, key) + return tag +} diff --git a/backend/internal/services/browser_session/service_test.go b/backend/internal/services/browser_session/service_test.go index 33cf2ee5f..e48ae01cd 100644 --- a/backend/internal/services/browser_session/service_test.go +++ b/backend/internal/services/browser_session/service_test.go @@ -102,7 +102,31 @@ func setRedisLiveSession(t *testing.T, client *redis.Client, state map[string]an require.True(t, ok) payload, err := json.Marshal(state) require.NoError(t, err) - require.NoError(t, client.Set(context.Background(), "mpp:browser:session:"+sessionID, payload, ttl).Err()) + require.NoError(t, client.Set(context.Background(), browserSessionTestRedisKey(sessionID), payload, ttl).Err()) +} + +func browserSessionTestRedisKey(sessionID string) string { + return "mpp:browser:session:" + browserSessionTestRedisTag("session", sessionID) +} + +func browserSessionTestActiveKey(userID uuid.UUID, platform string) string { + return "mpp:browser:active:" + browserSessionTestRedisTag("user", userID.String()) + ":" + strings.ToLower(platform) +} + +func browserSessionTestStreamTokenKey(sessionID uuid.UUID, tokenHash string) string { + return browserSessionTestStreamTokenPrefix(sessionID) + strings.ToLower(tokenHash) +} + +func browserSessionTestStreamTokenPrefix(sessionID uuid.UUID) string { + return "mpp:browser:stream-token:" + browserSessionTestRedisTag("session", sessionID.String()) + ":" +} + +func browserSessionTestStreamCurrentKey(sessionID uuid.UUID) string { + return "mpp:browser:stream-current:" + browserSessionTestRedisTag("session", sessionID.String()) +} + +func browserSessionTestRedisTag(scope string, value string) string { + return "{" + strings.ToLower(strings.TrimSpace(scope)) + ":" + strings.ToLower(strings.TrimSpace(value)) + "}" } type dashboardAccountCacheInvalidation struct { @@ -493,12 +517,12 @@ func TestBrowserSessionService_GetSessionReturnsGoneForExpiredRedisSession(t *te ExpiresAt: now.Add(-15 * time.Minute), } require.NoError(t, db.Create(&session).Error) - require.NoError(t, client.Set(context.Background(), "mpp:browser:active:"+userID.String()+":"+platform, session.ID.String(), time.Hour).Err()) + require.NoError(t, client.Set(context.Background(), browserSessionTestActiveKey(userID, platform), session.ID.String(), time.Hour).Err()) _, err := svc.GetSession(context.Background(), userID, session.ID) require.ErrorIs(t, err, browsersession.ErrSessionGone) - assert.Equal(t, int64(0), client.Exists(context.Background(), "mpp:browser:active:"+userID.String()+":"+platform).Val()) + assert.Equal(t, int64(0), client.Exists(context.Background(), browserSessionTestActiveKey(userID, platform)).Val()) var savedSession models.RemoteBrowserSession require.NoError(t, db.First(&savedSession, session.ID).Error) @@ -548,7 +572,7 @@ func TestBrowserSessionService_RedisLiveStateOmitsInternalEndpointRefs(t *testin resp, err := svc.StartSession(context.Background(), userID, platform) require.NoError(t, err) - raw, err := client.Get(context.Background(), "mpp:browser:session:"+resp.SessionID.String()).Bytes() + raw, err := client.Get(context.Background(), browserSessionTestRedisKey(resp.SessionID.String())).Bytes() require.NoError(t, err) var payload map[string]any require.NoError(t, json.Unmarshal(raw, &payload)) @@ -577,15 +601,15 @@ func TestBrowserSessionService_CancelSessionDeletesAllRedisStreamTokens(t *testi resp, err := svc.StartSession(context.Background(), userID, platform) require.NoError(t, err) - strayTokenKey := "mpp:browser:stream-token:" + resp.SessionID.String() + ":stray-token-hash" + strayTokenKey := browserSessionTestStreamTokenKey(resp.SessionID, "stray-token-hash") require.NoError(t, client.Set(context.Background(), strayTokenKey, "{}", time.Hour).Err()) require.NoError(t, svc.CancelSession(context.Background(), userID, resp.SessionID)) - tokenKeys, err := client.Keys(context.Background(), "mpp:browser:stream-token:"+resp.SessionID.String()+":*").Result() + tokenKeys, err := client.Keys(context.Background(), browserSessionTestStreamTokenPrefix(resp.SessionID)+"*").Result() require.NoError(t, err) assert.Empty(t, tokenKeys) - assert.Equal(t, int64(0), client.Exists(context.Background(), "mpp:browser:stream-current:"+resp.SessionID.String()).Val()) + assert.Equal(t, int64(0), client.Exists(context.Background(), browserSessionTestStreamCurrentKey(resp.SessionID)).Val()) } func TestBrowserSessionService_UnsupportedPlatform(t *testing.T) { @@ -746,7 +770,7 @@ func TestBrowserSessionService_StartSessionRecoversStaleRedisActiveLock(t *testi ExpiresAt: time.Now().Add(13 * time.Minute), } require.NoError(t, db.Create(&staleSession).Error) - require.NoError(t, client.Set(context.Background(), "mpp:browser:active:"+userID.String()+":"+platform, staleSession.ID.String(), time.Hour).Err()) + require.NoError(t, client.Set(context.Background(), browserSessionTestActiveKey(userID, platform), staleSession.ID.String(), time.Hour).Err()) setRedisLiveSession(t, client, map[string]any{ "session_id": staleSession.ID.String(), "user_id": userID.String(), @@ -764,10 +788,10 @@ func TestBrowserSessionService_StartSessionRecoversStaleRedisActiveLock(t *testi assert.Equal(t, models.BrowserSessionStatusReady, resp.Status) assert.NotEqual(t, staleSession.ID, resp.SessionID) - activeSessionID, err := client.Get(context.Background(), "mpp:browser:active:"+userID.String()+":"+platform).Result() + activeSessionID, err := client.Get(context.Background(), browserSessionTestActiveKey(userID, platform)).Result() require.NoError(t, err) assert.Equal(t, resp.SessionID.String(), activeSessionID) - assert.Equal(t, int64(0), client.Exists(context.Background(), "mpp:browser:session:"+staleSession.ID.String()).Val()) + assert.Equal(t, int64(0), client.Exists(context.Background(), browserSessionTestRedisKey(staleSession.ID.String())).Val()) var savedStaleSession models.RemoteBrowserSession require.NoError(t, db.First(&savedStaleSession, staleSession.ID).Error) @@ -789,7 +813,7 @@ func TestBrowserSessionService_StartSessionPreservesReachableRedisActiveLock(t * _, err = svc.StartSession(context.Background(), userID, platform) require.ErrorIs(t, err, browsersession.ErrActiveSessionExists) - activeSessionID, err := client.Get(context.Background(), "mpp:browser:active:"+userID.String()+":"+platform).Result() + activeSessionID, err := client.Get(context.Background(), browserSessionTestActiveKey(userID, platform)).Result() require.NoError(t, err) assert.Equal(t, resp.SessionID.String(), activeSessionID) } @@ -864,7 +888,7 @@ func TestBrowserSessionService_GetSessionKeepsLiveRedisStateOnTransientWorkerRea ExpiresAt: time.Now().Add(10 * time.Minute), } require.NoError(t, db.Create(&session).Error) - require.NoError(t, client.Set(context.Background(), "mpp:browser:active:"+userID.String()+":"+platform, session.ID.String(), time.Hour).Err()) + require.NoError(t, client.Set(context.Background(), browserSessionTestActiveKey(userID, platform), session.ID.String(), time.Hour).Err()) require.NoError(t, client.Set(context.Background(), "mpp:browser:worker-heartbeat:"+workerSessionRef, session.ID.String(), time.Hour).Err()) setRedisLiveSession(t, client, map[string]any{ "session_id": session.ID.String(), @@ -889,8 +913,8 @@ func TestBrowserSessionService_GetSessionKeepsLiveRedisStateOnTransientWorkerRea require.NoError(t, db.First(&savedSession, session.ID).Error) assert.Equal(t, models.BrowserSessionStatusReady, savedSession.Status) assert.Equal(t, "stale-token", savedSession.ConnectTokenHash) - assert.Equal(t, int64(1), client.Exists(context.Background(), "mpp:browser:active:"+userID.String()+":"+platform).Val()) - assert.Equal(t, int64(1), client.Exists(context.Background(), "mpp:browser:session:"+session.ID.String()).Val()) + assert.Equal(t, int64(1), client.Exists(context.Background(), browserSessionTestActiveKey(userID, platform)).Val()) + assert.Equal(t, int64(1), client.Exists(context.Background(), browserSessionTestRedisKey(session.ID.String())).Val()) assert.Equal(t, int64(1), client.Exists(context.Background(), "mpp:browser:worker-heartbeat:"+workerSessionRef).Val()) } @@ -928,7 +952,7 @@ func TestBrowserSessionService_CancelSessionRemovesContinuityStateWhenCoordinati require.NoError(t, svc.CancelSession(context.Background(), userID, session.ID)) - assert.Equal(t, int64(0), continuityClient.Exists(context.Background(), "mpp:browser:session:"+session.ID.String()).Val()) + assert.Equal(t, int64(0), continuityClient.Exists(context.Background(), browserSessionTestRedisKey(session.ID.String())).Val()) assert.Equal(t, int64(0), continuityClient.Exists(context.Background(), "mpp:browser:worker-heartbeat:"+workerSessionRef).Val()) assert.Equal(t, int64(0), continuityClient.ZCard(context.Background(), "mpp:browser:cleanup").Val()) diff --git a/backend/internal/services/mediaasset/assets_test.go b/backend/internal/services/mediaasset/assets_test.go index c1188f7ec..94cb9b732 100644 --- a/backend/internal/services/mediaasset/assets_test.go +++ b/backend/internal/services/mediaasset/assets_test.go @@ -653,7 +653,7 @@ func (s *deadlinePresignStorage) PresignGetObject(ctx context.Context, input obj func requireResolvedMediaAssetCacheKeys(t *testing.T, redisServer *miniredis.Miniredis, assetID uuid.UUID, count int) []string { t.Helper() - prefix := "mpp:dashboard:media-assets:resolve:v1:" + assetID.String() + ":" + prefix := "mpp:dashboard:media-assets:resolve:v1:{asset:" + assetID.String() + "}:" keys := make([]string, 0) for _, key := range redisServer.Keys() { if strings.HasPrefix(key, prefix) { diff --git a/backend/internal/services/mediaasset/resolve_cache.go b/backend/internal/services/mediaasset/resolve_cache.go index f26dddda2..5b8035762 100644 --- a/backend/internal/services/mediaasset/resolve_cache.go +++ b/backend/internal/services/mediaasset/resolve_cache.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "strings" "time" @@ -15,6 +14,7 @@ import ( "github.com/kurodakayn/mpp-backend/internal/models" "github.com/kurodakayn/mpp-backend/internal/pkg/cachettl" "github.com/kurodakayn/mpp-backend/internal/pkg/redisdegrade" + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" ) const ( @@ -144,7 +144,7 @@ func (s *Service) invalidateResolvedMediaAssetCache(assetID uuid.UUID) { func deleteResolvedMediaAssetCacheKeys(ctx context.Context, client *redis.Client, guard *redisdegrade.Guard, assetID uuid.UUID) { var cursor uint64 - pattern := resolvedMediaAssetCachePrefix + ":" + assetID.String() + ":*" + pattern := resolvedMediaAssetCachePrefix + ":" + rediskey.Tag("asset", assetID.String()) + ":*" for { type scanResult struct { keys []string @@ -192,5 +192,5 @@ func (s *Service) currentResolvedMediaAssetCacheTTL() time.Duration { } func resolvedMediaAssetCacheKey(assetID uuid.UUID, userID uuid.UUID) string { - return fmt.Sprintf("%s:%s:actor:%s", resolvedMediaAssetCachePrefix, assetID, userID) + return resolvedMediaAssetCachePrefix + ":" + rediskey.Tag("asset", assetID.String()) + ":actor:" + userID.String() } diff --git a/backend/internal/services/mediaasset/resolve_cache_keys_test.go b/backend/internal/services/mediaasset/resolve_cache_keys_test.go new file mode 100644 index 000000000..8967bb2d0 --- /dev/null +++ b/backend/internal/services/mediaasset/resolve_cache_keys_test.go @@ -0,0 +1,22 @@ +package mediaasset + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" +) + +func TestResolvedMediaAssetCacheKeyUsesAssetHashTag(t *testing.T) { + assetID := uuid.New() + userID := uuid.New() + + key := resolvedMediaAssetCacheKey(assetID, userID) + + tag, ok := rediskey.ExtractTag(key) + require.True(t, ok) + require.Equal(t, "asset:"+assetID.String(), tag) + require.Equal(t, resolvedMediaAssetCachePrefix+":"+rediskey.Tag("asset", assetID.String())+":actor:"+userID.String(), key) +} diff --git a/backend/internal/services/project/list_cache.go b/backend/internal/services/project/list_cache.go index 88430afd8..7ff0c0cee 100644 --- a/backend/internal/services/project/list_cache.go +++ b/backend/internal/services/project/list_cache.go @@ -16,10 +16,11 @@ import ( "github.com/kurodakayn/mpp-backend/internal/dto" "github.com/kurodakayn/mpp-backend/internal/pkg/cachettl" "github.com/kurodakayn/mpp-backend/internal/pkg/redisdegrade" + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" ) const dashboardProjectListCachePrefix = "mpp:dashboard:projects:list:v2" -const dashboardProjectListCacheGenerationKey = "mpp:dashboard:projects:list-generation:v2" +const dashboardProjectListCacheGenerationKey = "mpp:dashboard:projects:list-generation:v2:{dashboard:projects-list}" const dashboardProjectListDegradedGeneration = "degraded" const dashboardProjectListRefreshTimeout = 15 * time.Second const dashboardProjectListInvalidateTimeout = 2 * time.Second @@ -294,7 +295,7 @@ func dashboardProjectListCacheKey(params dashboardProjectListCacheParams) string return fmt.Sprintf("%s:%d:%d", dashboardProjectListCachePrefix, params.Page, params.Limit) } sum := sha256.Sum256(encoded) - return dashboardProjectListCachePrefix + ":" + hex.EncodeToString(sum[:]) + return dashboardProjectListCachePrefix + ":" + rediskey.Tag("dashboard", "projects-list") + ":" + hex.EncodeToString(sum[:]) } func uuidStringValue(value *uuid.UUID) string { diff --git a/backend/internal/services/project/list_cache_keys_test.go b/backend/internal/services/project/list_cache_keys_test.go new file mode 100644 index 000000000..3604bee93 --- /dev/null +++ b/backend/internal/services/project/list_cache_keys_test.go @@ -0,0 +1,22 @@ +package project + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" +) + +func TestDashboardProjectListKeysUseFamilyHashTag(t *testing.T) { + cacheKey := dashboardProjectListCacheKey(dashboardProjectListCacheParams{ + Generation: "1", + Page: 1, + Limit: 20, + }) + + require.True(t, rediskey.ShareTag(cacheKey, dashboardProjectListCacheGenerationKey)) + tag, ok := rediskey.ExtractTag(cacheKey) + require.True(t, ok) + require.Equal(t, "dashboard:projects-list", tag) +} diff --git a/backend/internal/services/publish/queue.go b/backend/internal/services/publish/queue.go index 7e2c41ebd..46e5bf4d1 100644 --- a/backend/internal/services/publish/queue.go +++ b/backend/internal/services/publish/queue.go @@ -16,6 +16,7 @@ import ( dbrouter "github.com/kurodakayn/mpp-backend/internal/db" "github.com/kurodakayn/mpp-backend/internal/models" + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" "github.com/kurodakayn/mpp-backend/internal/publisher" platformaccount "github.com/kurodakayn/mpp-backend/internal/services/platform_account" ) @@ -632,7 +633,7 @@ func publicationPublishingStale(pub models.ProjectPlatformPublication) bool { } func publishLockKey(projectID uuid.UUID, platform string) string { - return publishLockKeyPrefix + projectID.String() + ":" + platform + return publishLockKeyPrefix + rediskey.Tag("project", projectID.String()) + ":" + rediskey.Part(platform) } func publishCleanupContext(parent context.Context) (context.Context, context.CancelFunc) { diff --git a/backend/internal/services/publish/queue_test.go b/backend/internal/services/publish/queue_test.go index a2c9eb99a..e0557359d 100644 --- a/backend/internal/services/publish/queue_test.go +++ b/backend/internal/services/publish/queue_test.go @@ -18,12 +18,24 @@ import ( "gorm.io/gorm" "github.com/kurodakayn/mpp-backend/internal/models" + "github.com/kurodakayn/mpp-backend/internal/pkg/rediskey" "github.com/kurodakayn/mpp-backend/internal/publisher" platformaccount "github.com/kurodakayn/mpp-backend/internal/services/platform_account" ) type queueTestPublisher struct{} +func TestPublishLockKeyUsesProjectHashTag(t *testing.T) { + projectID := uuid.New() + + key := publishLockKey(projectID, "Wechat") + + tag, ok := rediskey.ExtractTag(key) + require.True(t, ok) + require.Equal(t, "project:"+projectID.String(), tag) + require.Equal(t, publishLockKeyPrefix+rediskey.Tag("project", projectID.String())+":wechat", key) +} + func (p queueTestPublisher) ValidateConfig(_ []byte) error { return nil } diff --git a/browser-worker/internal/session/redis_state.go b/browser-worker/internal/session/redis_state.go index e4fb1daec..a2aadd012 100644 --- a/browser-worker/internal/session/redis_state.go +++ b/browser-worker/internal/session/redis_state.go @@ -277,13 +277,44 @@ func (s *RedisStateStore) DeleteHeartbeat(ctx context.Context, workerSessionRef } func browserSessionRedisKey(sessionID string) string { - return browserSessionKeyPrefix + sessionID + return browserSessionKeyPrefix + redisHashTag("session", sessionID) } func browserSessionHeartbeatKey(workerSessionRef string) string { return browserSessionHeartbeatPrefix + workerSessionRef } +func redisHashTag(scope string, value string) string { + return "{" + redisKeyPart(scope) + ":" + redisKeyPart(value) + "}" +} + +func redisKeyPart(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "unknown" + } + + var builder strings.Builder + lastDash := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == ':' || r == '.' { + builder.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + builder.WriteByte('-') + lastDash = true + } + } + + result := strings.Trim(builder.String(), "-") + if result == "" { + return "unknown" + } + return result +} + func browserSessionLiveTTL(expiresAt time.Time) time.Duration { ttl := time.Until(expiresAt) + browserSessionRedisGrace if ttl <= 0 { diff --git a/browser-worker/internal/session/redis_state_test.go b/browser-worker/internal/session/redis_state_test.go index 1e0e73d17..57d6ac735 100644 --- a/browser-worker/internal/session/redis_state_test.go +++ b/browser-worker/internal/session/redis_state_test.go @@ -40,6 +40,12 @@ func TestRedisConnectionConfigFromEnvUsesDirectEndpoint(t *testing.T) { require.Equal(t, redisDialerRetryTimeout, options.DialerRetryTimeout) } +func TestBrowserSessionRedisKeyUsesSessionHashTag(t *testing.T) { + sessionID := "11111111-1111-4111-8111-111111111111" + + require.Equal(t, "mpp:browser:session:{session:"+sessionID+"}", browserSessionRedisKey(sessionID)) +} + func TestRedisConnectionConfigFromEnvBuildsTLSOptions(t *testing.T) { clearRedisEnv(t) t.Setenv(redisAddrEnv, "redis.example.invalid:6379") diff --git a/doc/redis-cluster-compatibility-audit.md b/doc/redis-cluster-compatibility-audit.md index 294e687f9..fafcea04c 100644 --- a/doc/redis-cluster-compatibility-audit.md +++ b/doc/redis-cluster-compatibility-audit.md @@ -5,6 +5,9 @@ Issue: #345 This audit records current Redis Cluster blockers and unknowns only. It does not change runtime behavior. +The approved key hash-tag convention for follow-up work lives in +`doc/redis-key-hash-tag-convention.md`. + ## Scope Audited code and operational surfaces: diff --git a/doc/redis-dependency-map.md b/doc/redis-dependency-map.md index 98a4f470a..746e9070d 100644 --- a/doc/redis-dependency-map.md +++ b/doc/redis-dependency-map.md @@ -61,7 +61,7 @@ Risk tags: | Path | Acquire | Refresh | Release | Failover or recovery behavior | | --- | --- | --- | --- | --- | -| Publish lock `mpp:publish:lock:{project_id}:{platform}` | The backend acquires the lock with Redis `SET NX` and an explicit 30-minute TTL. The owner token is the publish job UUID, which is also present in the durable publish event/outbox trail and acts as the ownership fence for duplicate enqueue/replay decisions. | The worker refreshes only when the stored value still equals the job UUID. If refresh observes a different owner, the publish context is canceled so the worker stops assuming it still owns the publication. Refresh errors are logged and retried, but the lock TTL remains the final fail-closed recovery boundary. | Release uses compare-and-delete Lua and removes the key only when the stored value still equals the job UUID. Stale workers cannot release a newer owner's lock. | If Redis loses the lock during failover, a worker may reacquire only when the publication row is still in a retriable queued/publishing/failed state. Duplicate requests replay durable publish events by idempotency key instead of creating another owner silently. | +| Publish lock `mpp:publish:lock:{project:}:{platform}` | The backend acquires the lock with Redis `SET NX` and an explicit 30-minute TTL. The owner token is the publish job UUID, which is also present in the durable publish event/outbox trail and acts as the ownership fence for duplicate enqueue/replay decisions. | The worker refreshes only when the stored value still equals the job UUID. If refresh observes a different owner, the publish context is canceled so the worker stops assuming it still owns the publication. Refresh errors are logged and retried, but the lock TTL remains the final fail-closed recovery boundary. | Release uses compare-and-delete Lua and removes the key only when the stored value still equals the job UUID. Stale workers cannot release a newer owner's lock. | If Redis loses the lock during failover, a worker may reacquire only when the publication row is still in a retriable queued/publishing/failed state. Duplicate requests replay durable publish events by idempotency key instead of creating another owner silently. | | Stream gate leases `mpp:stream:*` | The limiter creates a random connection owner ID, writes a TTL-bound connection key, and records that owner in per-user, tenant, IP, and global sorted sets. Acquire prunes expired owners before checking limits and fails closed when Redis returns an unexpected result. | Stream leases are not refreshed. The configured TTL is the upper bound for stale admission state if a client or backend instance disappears. | Release is owner-checked against the connection-key payload before deleting the connection key or sorted-set members. A stale release is a no-op if the owner payload has changed or the connection key is gone. | After Redis failover or key loss, admission counters rebuild from new acquisitions. Active streams are not granted stronger continuity by Redis; the gate is a concurrency guard and fails closed on Redis errors when enabled. | | Browser-session active lock `mpp:browser:active:{user_id}:{platform}` | Session start uses Redis `SET NX` with an explicit session TTL plus grace. The owner token is the browser session ID and the durable PostgreSQL row stores the same ID. | Active-session locks are not refreshed directly; live session state, heartbeat, and cleanup indexes carry their own TTLs. | Release uses compare-and-delete and only removes the active lock when the stored session ID still matches. Stream tokens are single-use and consumed with Lua that also clears the current-token pointer only when it still references the consumed token. | Start-session recovery reads the active lock owner, live session state, worker heartbeat, and worker API. Missing, expired, terminal, or unreachable owners are expired in PostgreSQL and cleaned from Redis before a new session is admitted. | diff --git a/doc/redis-key-hash-tag-convention.md b/doc/redis-key-hash-tag-convention.md new file mode 100644 index 000000000..51b2ba3d9 --- /dev/null +++ b/doc/redis-key-hash-tag-convention.md @@ -0,0 +1,69 @@ +# Redis Key Hash-Tag Convention + +Issue: #346 + +This convention defines how MPP names Redis keys that may need Redis Cluster +single-slot behavior. It complements the Redis Cluster compatibility audit in +`doc/redis-cluster-compatibility-audit.md`. + +## Rules + +- Use a Redis hash tag when keys can be passed together to a multi-key command, + Lua script, transaction, pipeline, scan-and-delete batch, or any future grouped + operation that requires same-slot behavior. +- Use the shape `{scope:value}`. Approved scopes are `tenant`, `user`, + `session`, `project`, `asset`, `email`, `queue`, and narrow cache-family + names such as `dashboard:projects-list`. +- Choose the smallest stable identifier shared by every key in the group. Do + not choose a tag that changes quota, counter, or cache invalidation semantics. +- Build backend keys with `backend/internal/pkg/rediskey.Tag` and normalize + untrusted key parts with `rediskey.Part`. +- Single-key-only Redis operations may omit a tag, but new keys should still use + a tag when the responsibility area has an approved future grouping dimension. +- Do not put raw user input inside `{...}`. Hash or canonicalize sensitive + values first, as auth verification keys do with the canonical email digest. + +## Examples + +```text +auth:code:{email:}:register +auth:code_attempts:{email:}:register +mpp:browser:session:{session:} +mpp:browser:stream-token:{session:}: +mpp:publish:lock:{project:}:wechat +mpp:dashboard:media-assets:resolve:v1:{asset:}:actor: +``` + +## Audit Strategy Table + +| Audit ID | Area | Approved hash-tag strategy | +| --- | --- | --- | +| RC-05 | Stream gate Lua | No tag is approved for the current five-key Lua shape. User, tenant, IP, and global counters aggregate across different dimensions, so a forced shared tag would change limiter semantics. Split per-scope operations or move global coordination before Cluster cutover. | +| RC-06 | App rate limit | Current Lua calls are single-key. If a future operation groups buckets, tag by the authoritative bucket owner, such as `{user:}` or `{ip:}`. | +| RC-07 | Auth verification | Use `{email:}` across code, attempts, and last-send keys. Implemented in backend auth key builders. | +| RC-08 | Publish locks | Use `{project:}` for publish locks so future project-scoped queue/lock coordination has a stable slot. Implemented in publish lock keys. | +| RC-09 | Asynq queues | Keep Asynq's own queue hash tag, such as `asynq:{publish}:...`, and wire queues through Asynq Cluster options before Cluster cutover. | +| RC-10 | Browser active lock | Use `{user:}` for the active user/platform lock. Implemented in browser-session coordination keys. | +| RC-11 | Browser quota | No shared tag is approved yet. User and tenant quota keys aggregate on different dimensions; tagging both by either side would weaken one quota. Keep this as a design decision for the quota rewrite. | +| RC-12 | Browser live state and heartbeat | Use `{session:}` for live session state. Worker heartbeats remain single-key by worker ref until the worker-ref-to-session index is redesigned. | +| RC-13 | Browser cleanup index | No shared tag is approved for the global cleanup sorted set. It remains a global index until cleanup is moved to DB-backed or sharded ownership. | +| RC-14 | Browser stream tokens | Use `{session:}` across current-token and token keys. Implemented for token keys; declaring every Lua key in `KEYS` remains part of the Cluster command rewrite. | +| RC-15 | Dashboard project-list cache | Use `{dashboard:projects-list}` across list data keys and the generation key. Implemented in project-list cache builders. | +| RC-16 | Content setup cache | No single tag is approved because user-scoped and workspace-scoped invalidations overlap. Prefer generation-only invalidation or split invalidation by a single authoritative scope. | +| RC-17 | Media asset resolve cache | Use `{asset:}` for all actor-specific resolve cache entries for one asset. Implemented in media-asset cache keys. | +| RC-18 | Stats and account caches | Current operations are single-key. Future grouped user caches use `{user:}`; grouped workspace caches use `{workspace:}`; truly global cache families use a narrow family tag. | +| RC-19 | X OAuth2 state | Current `GETDEL` flow is single-key. No tag is required unless a future cleanup groups state keys by user or workspace. | +| RC-20 | Collab pub/sub | Current channels are pub/sub, not multi-key data commands. If sharded pub/sub is adopted, use `{doc:}` in document channels. | +| RC-21 | Traefik gateway rate limit | No MPP key shape is approved until Traefik Cluster behavior is verified for the deployed build. Keep gateway Redis rate-limit keys outside this convention for now. | +| RC-22 | Keyspace inventory | Inventory patterns recognize the implemented tagged keys. Cluster-aware scanning still needs per-primary DB 0 inventory support. | + +## Automated Checks + +- `backend/internal/pkg/rediskey` tests verify tag construction and same-tag + detection. +- Backend package tests assert implemented multi-key groups keep their approved + tags. +- Browser-worker tests assert shared browser live-session keys use the same + `{session:}` tag shape as backend. +- `script/redis/test_keyspace_inventory.rb` verifies the inventory recognizes + tagged key patterns. diff --git a/doc/remote_browser_session.md b/doc/remote_browser_session.md index f30420658..701971521 100644 --- a/doc/remote_browser_session.md +++ b/doc/remote_browser_session.md @@ -39,7 +39,7 @@ Preferred MVP: **one browser container per connection session**. 1. User clicks `Connect` on a platform card. 2. Backend acquires `mpp:browser:active:{user_id}:{platform}` with Redis `SET NX EX`. 3. Backend creates a durable `remote_browser_sessions` audit row with `pending` status. -4. Backend writes `mpp:browser:session:{session_id}` live state to Redis with the session TTL. +4. Backend writes `mpp:browser:session:{session:}` live state to Redis with the session TTL. 5. Backend asks `browser-worker` to start a browser container with: - isolated user data directory - adapter network policy, not only an initial allowlist URL @@ -101,9 +101,9 @@ Use the same Redis client style already used by publish queues and OAuth state. | Key | Type | TTL | Purpose | | --- | --- | --- | --- | | `mpp:browser:active:{user_id}:{platform}` | string `session_id` | session TTL + small grace | One active session per user/platform. Acquired with `SET NX EX`; released by compare-and-delete Lua. | -| `mpp:browser:session:{session_id}` | hash or JSON string | session TTL + grace | Live status, owner, platform, worker refs, current URL, missing cookies, error message, and expiry. | -| `mpp:browser:stream-token:{session_id}:{token_hash}` | JSON string | `min(5 minutes, session remaining)` | Single-use stream token metadata: user ID, platform, purpose, and issued time. | -| `mpp:browser:stream-current:{session_id}` | string `token_hash` | same as stream token | Optional pointer used to rotate/revoke the latest unconsumed token. | +| `mpp:browser:session:{session:}` | hash or JSON string | session TTL + grace | Live status, owner, platform, worker refs, current URL, missing cookies, error message, and expiry. | +| `mpp:browser:stream-token:{session:}:{token_hash}` | JSON string | `min(5 minutes, session remaining)` | Single-use browser stream token metadata. | +| `mpp:browser:stream-current:{session:}` | string `token_hash` | same as stream token | Optional pointer used to rotate/revoke the latest unconsumed token. | | `mpp:browser:cleanup` | sorted set | none | `session_id` scored by `expires_at` for deterministic cleanup sweeps. Do not rely only on keyspace notifications. | | `mpp:browser:worker-heartbeat:{worker_session_ref}` | string | 30-60 seconds | Detect worker/container loss before the session TTL expires. | @@ -253,9 +253,9 @@ Response: Rules: - Stop the worker session if it is still running. -- Delete outstanding stream token keys and `mpp:browser:stream-current:{session_id}`. +- Delete outstanding stream token keys and `mpp:browser:stream-current:{session:}`. - Release `mpp:browser:active:{user_id}:{platform}` by compare-and-delete. -- Remove `mpp:browser:session:{session_id}` and its cleanup sorted-set member. +- Remove `mpp:browser:session:{session:}` and its cleanup sorted-set member. - Keep completed sessions as audit records; do not delete rows in the request path. ### Stream @@ -288,7 +288,7 @@ type StreamToken struct { Generation and storage: - Generate at least 32 random bytes and encode with unpadded URL-safe base64. -- Store only `SHA-256(token)` or `HMAC-SHA-256(token, STREAM_TOKEN_HASH_KEY)` in Redis under `mpp:browser:stream-token:{session_id}:{token_hash}`. +- Store only `SHA-256(token)` or `HMAC-SHA-256(token, STREAM_TOKEN_HASH_KEY)` in Redis under `mpp:browser:stream-token:{session:}:{token_hash}`. - Store token metadata as JSON: `session_id`, `user_id`, `platform`, `purpose: "stream"`, `issued_at`, and `expires_at`. - Set token TTL to `min(5 minutes, session.expires_at - now)`. - Never log token values or include them in worker requests. @@ -298,7 +298,7 @@ Consumption: - Compare token hashes in constant time. - Reject expired or already consumed tokens by reading Redis, not PostgreSQL. - Consume the Redis token key only after authentication and just before/while accepting the WebSocket upgrade. -- Clear `mpp:browser:stream-current:{session_id}` when the consumed hash matches the current pointer. +- Clear `mpp:browser:stream-current:{session:}` when the consumed hash matches the current pointer. - For reconnect, the frontend calls `GET /api/user/dashboard/browser-sessions/:id` and receives a rotated token if the session is still active. - Token rotation should delete the previous current token key when it is still unconsumed. Use a Lua script so the current pointer and token key change atomically. @@ -309,7 +309,7 @@ The worker API is internal only. It can be implemented as HTTP/gRPC or as an in- Redis responsibilities: - Backend creates the session Redis keys before calling the worker. -- Worker updates `mpp:browser:session:{session_id}` with `status`, `current_url`, `login_detected`, `missing_cookies`, and `message` while polling CDP. +- Worker updates `mpp:browser:session:{session:}` with `status`, `current_url`, `login_detected`, `missing_cookies`, and `message` while polling CDP. - Worker refreshes `mpp:browser:worker-heartbeat:{worker_session_ref}` while the container is alive. - Backend cleanup sweeps `mpp:browser:cleanup` and calls `DELETE /internal/browser-sessions/:worker_session_ref` for expired sessions. The worker may also self-expire, but backend cleanup is the source of deterministic recovery. @@ -388,7 +388,7 @@ Response: Rules: - Return fresh CDP-derived state when available. -- Also write that state to `mpp:browser:session:{session_id}` so normal frontend polling does not need to call the worker every time. +- Also write that state to `mpp:browser:session:{session:}` so normal frontend polling does not need to call the worker every time. - If the worker heartbeat is missing or the container is gone, backend should mark the Redis session and PostgreSQL audit row `failed` or `expired` depending on whether the TTL elapsed. ### Capture Worker Session @@ -594,7 +594,7 @@ Redis key expiration removes stale live metadata, but cleanup must still stop co 1. On start, add `session_id` to `mpp:browser:cleanup` with score `expires_at_unix_ms`. 2. A backend cleanup loop periodically reads due members with `ZRANGEBYSCORE`. -3. For each due session, read `mpp:browser:session:{session_id}`. If Redis already evicted it, fall back to the PostgreSQL audit row to recover `worker_session_ref`. +3. For each due session, read `mpp:browser:session:{session:}`. If Redis already evicted it, fall back to the PostgreSQL audit row to recover `worker_session_ref`. 4. Mark PostgreSQL `remote_browser_sessions.status = expired` unless the session already completed, failed, or was revoked. 5. Delete stream-token keys, the active lock, the live session key, heartbeat key, and the cleanup sorted-set member using compare-and-delete where ownership matters. 6. If backend crashes, Redis TTLs release locks automatically; the cleanup sorted set lets the next backend instance find containers that still need explicit stop calls. diff --git a/script/redis/fixtures/keyspace_inventory_sample.json b/script/redis/fixtures/keyspace_inventory_sample.json index f81396369..cfb46d4f9 100644 --- a/script/redis/fixtures/keyspace_inventory_sample.json +++ b/script/redis/fixtures/keyspace_inventory_sample.json @@ -1,7 +1,7 @@ { "keys": [ { - "key": "auth:code:register:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "key": "auth:code:{email:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa}:register", "type": "string", "ttl_ms": 590000, "memory_bytes": 96 @@ -13,25 +13,25 @@ "memory_bytes": 184 }, { - "key": "mpp:browser:session:11111111-1111-4111-8111-111111111111", + "key": "mpp:browser:session:{session:11111111-1111-4111-8111-111111111111}", "type": "string", "ttl_ms": 900000, "memory_bytes": 1200 }, { - "key": "mpp:dashboard:projects:list:v2:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "key": "mpp:dashboard:projects:list:v2:{dashboard:projects-list}:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "type": "string", "ttl_ms": 14000, "memory_bytes": 1024 }, { - "key": "mpp:dashboard:projects:list:v2:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "key": "mpp:dashboard:projects:list:v2:{dashboard:projects-list}:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "type": "string", "ttl_ms": 10000, "memory_bytes": 1024 }, { - "key": "mpp:publish:lock:22222222-2222-4222-8222-222222222222:wechat", + "key": "mpp:publish:lock:{project:22222222-2222-4222-8222-222222222222}:wechat", "type": "string", "ttl_ms": 1780000, "memory_bytes": 128 diff --git a/script/redis/keyspace_inventory.rb b/script/redis/keyspace_inventory.rb index 3d55d6a96..ec927ab99 100644 --- a/script/redis/keyspace_inventory.rb +++ b/script/redis/keyspace_inventory.rb @@ -102,8 +102,8 @@ module RedisKeyspaceInventory DECLARED_PATTERNS = [ { - pattern: "auth:code:{scene}:{email_hash}", - regex: /\Aauth:code:[^:]+:[0-9a-f]{64}\z/, + pattern: "auth:code:{email_hash_tag}:{scene}", + regex: /\Aauth:code:\{email:[0-9a-f]{64}\}:[^:]+\z/, owner: "backend auth handler", reads: ["backend"], writes: ["backend"], @@ -111,8 +111,8 @@ module RedisKeyspaceInventory notes: "Email verification and password reset code.", }.merge(AUTH_VERIFICATION_RESPONSIBILITY), { - pattern: "auth:code_attempts:{scene}:{email_hash}", - regex: /\Aauth:code_attempts:[^:]+:[0-9a-f]{64}\z/, + pattern: "auth:code_attempts:{email_hash_tag}:{scene}", + regex: /\Aauth:code_attempts:\{email:[0-9a-f]{64}\}:[^:]+\z/, owner: "backend auth handler", reads: ["backend"], writes: ["backend"], @@ -120,8 +120,8 @@ module RedisKeyspaceInventory notes: "Failed verification attempt counter.", }.merge(AUTH_VERIFICATION_RESPONSIBILITY), { - pattern: "auth:last_send:{scene}:{email_hash}", - regex: /\Aauth:last_send:[^:]+:[0-9a-f]{64}\z/, + pattern: "auth:last_send:{email_hash_tag}:{scene}", + regex: /\Aauth:last_send:\{email:[0-9a-f]{64}\}:[^:]+\z/, owner: "backend auth handler", reads: ["backend"], writes: ["backend"], @@ -183,8 +183,8 @@ module RedisKeyspaceInventory notes: "Global stream concurrency zset.", }.merge(STREAM_GATE_RESPONSIBILITY), { - pattern: "mpp:browser:active:{user_id}:{platform}", - regex: /\Ampp:browser:active:[0-9a-f-]{36}:[^:]+\z/, + pattern: "mpp:browser:active:{user_id_tag}:{platform}", + regex: /\Ampp:browser:active:\{user:[0-9a-f-]{36}\}:[^:]+\z/, owner: "backend browser session service", reads: ["backend"], writes: ["backend"], @@ -192,8 +192,8 @@ module RedisKeyspaceInventory notes: "One active remote browser session per user and platform.", }.merge(BROWSER_COORDINATION_RESPONSIBILITY), { - pattern: "mpp:browser:session:{session_id}", - regex: /\Ampp:browser:session:[0-9a-f-]{36}\z/, + pattern: "mpp:browser:session:{session_id_tag}", + regex: /\Ampp:browser:session:\{session:[0-9a-f-]{36}\}\z/, owner: "backend/browser-worker browser session state", reads: ["backend", "browser-worker"], writes: ["backend", "browser-worker"], @@ -201,8 +201,8 @@ module RedisKeyspaceInventory notes: "Remote browser live session JSON state.", }.merge(BROWSER_SESSION_RESPONSIBILITY), { - pattern: "mpp:browser:stream-token:{session_id}:{token_hash}", - regex: /\Ampp:browser:stream-token:[0-9a-f-]{36}:[^:]+\z/, + pattern: "mpp:browser:stream-token:{session_id_tag}:{token_hash}", + regex: /\Ampp:browser:stream-token:\{session:[0-9a-f-]{36}\}:[^:]+\z/, owner: "backend browser session service", reads: ["backend"], writes: ["backend"], @@ -210,8 +210,8 @@ module RedisKeyspaceInventory notes: "Single-use browser stream token metadata.", }.merge(BROWSER_STREAM_TOKEN_RESPONSIBILITY), { - pattern: "mpp:browser:stream-current:{session_id}", - regex: /\Ampp:browser:stream-current:[0-9a-f-]{36}\z/, + pattern: "mpp:browser:stream-current:{session_id_tag}", + regex: /\Ampp:browser:stream-current:\{session:[0-9a-f-]{36}\}\z/, owner: "backend browser session service", reads: ["backend"], writes: ["backend"], @@ -255,8 +255,8 @@ module RedisKeyspaceInventory notes: "Per-tenant remote browser session concurrency zset.", }.merge(BROWSER_COORDINATION_RESPONSIBILITY), { - pattern: "mpp:publish:lock:{project_id}:{platform}", - regex: /\Ampp:publish:lock:[0-9a-f-]{36}:[^:]+\z/, + pattern: "mpp:publish:lock:{project_id_tag}:{platform}", + regex: /\Ampp:publish:lock:\{project:[0-9a-f-]{36}\}:[^:]+\z/, owner: "backend publish service", reads: ["backend", "publish-worker"], writes: ["backend", "publish-worker"], @@ -264,8 +264,8 @@ module RedisKeyspaceInventory notes: "Publish job idempotency and mutual-exclusion lock.", }.merge(PUBLISH_LOCK_RESPONSIBILITY), { - pattern: "mpp:dashboard:projects:list:v2:{params_hash}", - regex: /\Ampp:dashboard:projects:list:v2:[0-9a-f]{64}\z/, + pattern: "mpp:dashboard:projects:list:v2:{project_list_tag}:{params_hash}", + regex: /\Ampp:dashboard:projects:list:v2:\{dashboard:projects-list\}:[0-9a-f]{64}\z/, owner: "backend project service", reads: ["backend"], writes: ["backend"], @@ -273,8 +273,8 @@ module RedisKeyspaceInventory notes: "Dashboard project list cache.", }.merge(DASHBOARD_CACHE_RESPONSIBILITY), { - pattern: "mpp:dashboard:projects:list-generation:v2", - regex: /\Ampp:dashboard:projects:list-generation:v2\z/, + pattern: "mpp:dashboard:projects:list-generation:v2:{project_list_tag}", + regex: /\Ampp:dashboard:projects:list-generation:v2:\{dashboard:projects-list\}\z/, owner: "backend project service", reads: ["backend"], writes: ["backend"], @@ -372,8 +372,8 @@ module RedisKeyspaceInventory notes: "Pending X OAuth2 state and PKCE verifier.", }.merge(OAUTH2_STATE_RESPONSIBILITY), { - pattern: "mpp:dashboard:media-assets:resolve:v1:{asset_id}:actor:{user_id}", - regex: /\Ampp:dashboard:media-assets:resolve:v1:[0-9a-f-]{36}:actor:[0-9a-f-]{36}\z/, + pattern: "mpp:dashboard:media-assets:resolve:v1:{asset_id_tag}:actor:{user_id}", + regex: /\Ampp:dashboard:media-assets:resolve:v1:\{asset:[0-9a-f-]{36}\}:actor:[0-9a-f-]{36}\z/, owner: "backend media asset service", reads: ["backend"], writes: ["backend"], diff --git a/script/redis/test_keyspace_inventory.rb b/script/redis/test_keyspace_inventory.rb index 9c2cf6712..3067f5b9a 100644 --- a/script/redis/test_keyspace_inventory.rb +++ b/script/redis/test_keyspace_inventory.rb @@ -29,7 +29,7 @@ def test_groups_declared_patterns_with_stable_report_fields assert_equal 7, report.dig("summary", "patterns_observed") assert_empty report.fetch("warnings").grep(/scan stopped/) - project_cache = pattern(report, "mpp:dashboard:projects:list:v2:{params_hash}") + project_cache = pattern(report, "mpp:dashboard:projects:list:v2:{project_list_tag}:{params_hash}") assert_equal "backend project service", project_cache.fetch("owner") assert_equal "declared", project_cache.fetch("owner_source") assert_equal "R2", project_cache.fetch("responsibility_tier") @@ -110,7 +110,7 @@ def test_cli_renders_fixture_report report = JSON.parse(stdout) assert_equal "fixture:#{FIXTURE}", report.fetch("source") assert_equal 8, report.dig("summary", "keys_observed") - assert pattern(report, "auth:code:{scene}:{email_hash}") + assert pattern(report, "auth:code:{email_hash_tag}:{scene}") end def test_live_scanner_uses_read_only_redis_metadata_commands @@ -134,7 +134,7 @@ def test_live_scanner_uses_read_only_redis_metadata_commands ) samples = scanner.scan - assert_equal ["mpp:dashboard:projects:list:v2:#{'a' * 64}", "mpp:browser:cleanup"], samples.map(&:key) + assert_equal ["mpp:dashboard:projects:list:v2:{dashboard:projects-list}:#{'a' * 64}", "mpp:browser:cleanup"], samples.map(&:key) assert_equal ["string", "zset"], samples.map(&:type) assert_equal [12_000, -1], samples.map(&:ttl_ms) assert_equal [128, 256], samples.map(&:memory_bytes) @@ -180,7 +180,7 @@ def test_live_scanner_passes_tls_ca_file_and_sni_to_redis_cli samples = scanner.scan - assert_equal ["mpp:dashboard:projects:list:v2:#{'a' * 64}"], samples.map(&:key) + assert_equal ["mpp:dashboard:projects:list:v2:{dashboard:projects-list}:#{'a' * 64}"], samples.map(&:key) commands = File.readlines(command_log, chomp: true) assert_includes commands, "SCAN 0 MATCH mpp:* COUNT 2" ensure @@ -271,15 +271,15 @@ def fake_redis_cli(command_log:, expected_options: {}, ca_log: nil) case command when ["SCAN", "0", "MATCH", "mpp:*", "COUNT", "2"] puts "1" - puts "mpp:dashboard:projects:list:v2:#{"a" * 64}" + puts "mpp:dashboard:projects:list:v2:{dashboard:projects-list}:#{"a" * 64}" when ["SCAN", "1", "MATCH", "mpp:*", "COUNT", "2"] puts "0" puts "mpp:browser:cleanup" - when ["TYPE", "mpp:dashboard:projects:list:v2:#{"a" * 64}"] + when ["TYPE", "mpp:dashboard:projects:list:v2:{dashboard:projects-list}:#{"a" * 64}"] puts "string" - when ["PTTL", "mpp:dashboard:projects:list:v2:#{"a" * 64}"] + when ["PTTL", "mpp:dashboard:projects:list:v2:{dashboard:projects-list}:#{"a" * 64}"] puts "12000" - when ["MEMORY", "USAGE", "mpp:dashboard:projects:list:v2:#{"a" * 64}"] + when ["MEMORY", "USAGE", "mpp:dashboard:projects:list:v2:{dashboard:projects-list}:#{"a" * 64}"] puts "128" when ["TYPE", "mpp:browser:cleanup"] puts "zset"