Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .devcontainer/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@ services:
dragonfly
--maxmemory=128mb
--proactor_threads=1
--cache_mode=true
--default_lua_flags=allow-undeclared-keys
ports:
- "6379:6379"
Expand Down
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,10 @@ ENABLE_REGISTRATION=true

# Redis URL for caching and job queues
# Required for ActivityPub delivery queue in production
# (DRAGONFLY_URL is also accepted as a fallback)
REDIS_URL=redis://localhost:6379
# Defaults to redis://localhost:6379. DRAGONFLY_URL is also accepted as a
# fallback; docker/compose.yml and the devcontainer set DRAGONFLY_URL, so leave
# this commented out there (REDIS_URL takes precedence over DRAGONFLY_URL)
# REDIS_URL=redis://localhost:6379

# Disable queue and use synchronous delivery (default: false)
# Set to "true" to disable BullMQ queue (useful when Redis/Dragonfly has issues)
Expand Down
3 changes: 0 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion docker/compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ services:
dragonfly
--maxmemory=128mb
--proactor_threads=1
--cache_mode=true
--default_lua_flags=allow-undeclared-keys
ports:
- "6379:6379"
Expand Down
3 changes: 1 addition & 2 deletions docker/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ services:
postgres:
condition: service_healthy
dragonfly:
condition: service_started
condition: service_healthy
environment:
- NODE_ENV=production
- PORT=3000
Expand Down Expand Up @@ -195,7 +195,6 @@ services:
dragonfly
--maxmemory=256mb
--proactor_threads=2
--cache_mode=true
--default_lua_flags=allow-undeclared-keys
volumes:
- dragonfly-data:/data
Expand Down
1 change: 0 additions & 1 deletion packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"@sentry/bun": "^11.7.0",
"@sentry/core": "11.7.0",
"@simplewebauthn/server": "^14.0.3",
"@simplewebauthn/types": "^12.0.0",
"blurhash": "^2.0.5",
"bullmq": "^6.3.14",
"drizzle-orm": "^0.45.4",
Expand Down
28 changes: 10 additions & 18 deletions packages/backend/src/adapters/cache/DragonflyCacheAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
* Dragonfly Cache Adapter
*
* Implements ICacheService using Dragonfly (Redis-compatible) as the backend.
* Uses ioredis for connection management.
* Uses ioredis for connection management (see lib/redis.ts).
*
* @module adapters/cache/DragonflyCacheAdapter
*/

import { Redis } from "ioredis";
import type { Redis } from "ioredis";
import type { ICacheService, CacheSetOptions } from "../../interfaces/ICacheService.js";
import { logger } from "../../lib/logger.js";
import { createRedisClient, waitForRedisReady } from "../../lib/redis.js";
import { recordCacheOperation } from "../../lib/metrics.js";

/**
Expand Down Expand Up @@ -61,31 +62,22 @@ export class DragonflyCacheAdapter implements ICacheService {
* Initialize Redis connection
*/
private async initialize(): Promise<void> {
// docker/compose.yml と K8s secret は DRAGONFLY_URL を設定しているため両方を受け付ける
const redisUrl = process.env.REDIS_URL || process.env.DRAGONFLY_URL || "redis://localhost:6379";

try {
this.redis = new Redis(redisUrl, {
this.redis = createRedisClient("cache", {
maxRetriesPerRequest: 3,
// ioredis v6 は既定で RESP3 になったため、v5 と同じ RESP2 を明示(Dragonfly/BullMQ 互換性のため)
protocol: 2,
retryStrategy: (times) => {
if (times > 3) return null;
return Math.min(times * 100, 1000);
},
connectTimeout: 5000,
enableOfflineQueue: false,
lazyConnect: true,
enableOfflineQueue: false, // Cache misses while disconnected instead of queueing commands
});

await this.redis.connect();
await this.redis.ping();
// 起動直後は Dragonfly がまだ接続を受け付けていないことがあるため、一定時間は再試行を待つ
await waitForRedisReady(this.redis);

this.available = true;
logger.debug("Dragonfly cache connected");
logger.info("Dragonfly cache connected");
} catch (error) {
logger.warn({ err: error }, "Dragonfly cache not available, caching disabled");
this.available = false;
// 失敗した接続が裏で再接続を続けないように切断する
this.redis?.disconnect();
this.redis = null;
}
}
Expand Down
140 changes: 140 additions & 0 deletions packages/backend/src/lib/redis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Redis / Dragonfly Connection Helpers
*
* Shared connection settings for the Dragonfly cache adapter and the
* ActivityPub delivery queue, so both always talk to the same server with
* the same protocol and reconnect behavior.
*
* @module lib/redis
*/

import { Redis, type RedisOptions } from "ioredis";
import { logger } from "./logger.js";

/** Default Redis URL when neither `REDIS_URL` nor `DRAGONFLY_URL` is set. */
const DEFAULT_REDIS_URL = "redis://localhost:6379";

/** Upper bound for the delay between reconnect attempts. */
const MAX_RECONNECT_DELAY_MS = 5000;

/** How long to wait for the first connection at startup. */
export const DEFAULT_STARTUP_TIMEOUT_MS = 15000;

/**
* Resolve the Redis connection URL.
*
* `REDIS_URL` takes precedence; `DRAGONFLY_URL` is accepted as a fallback
* because docker/compose.yml and the K8s secret set that name.
*
* @returns Redis connection URL
*/
export function getRedisUrl(): string {
return process.env.REDIS_URL || process.env.DRAGONFLY_URL || DEFAULT_REDIS_URL;
}

/**
* Strip credentials from a Redis URL so it can be logged safely.
*
* @param url - Redis connection URL (may contain `user:password@`)
* @returns URL with username and password replaced by `***`
*/
export function redactRedisUrl(url: string): string {
try {
const parsed = new URL(url);
if (parsed.username) parsed.username = "***";
if (parsed.password) parsed.password = "***";
return parsed.toString();
} catch {
return "<invalid redis url>";
}
}

/**
* Create an ioredis client with the shared defaults.
*
* Defaults:
* - `protocol: 2`: ioredis v6 defaults to RESP3; RESP2 keeps v5 behavior
* for Dragonfly/BullMQ compatibility.
* - Never gives up reconnecting (capped backoff), so a Dragonfly restart
* does not permanently disable the cache or the delivery queue.
* - Logs a warning when an established connection drops and an info
* message when it comes back.
*
* @param name - Label used in log messages (e.g. "cache", "queue")
* @param options - ioredis options merged over the defaults
* @returns ioredis client (connecting unless `lazyConnect` is set)
*/
export function createRedisClient(name: string, options: RedisOptions = {}): Redis {
const redis = new Redis(getRedisUrl(), {
protocol: 2,
connectTimeout: 5000,
retryStrategy: (times) => Math.min(times * 200, MAX_RECONNECT_DELAY_MS),
...options,
});

// Log once per outage. "reconnecting" (unlike "close") does not fire on a
// deliberate quit()/disconnect(), so graceful shutdown stays quiet.
let everReady = false;
let connected = false;
redis.on("ready", () => {
if (everReady) {
logger.info({ client: name }, "Redis connection restored");
}
everReady = true;
connected = true;
});
redis.on("reconnecting", () => {
if (connected) {
logger.warn({ client: name }, "Redis connection lost, reconnecting");
connected = false;
}
});
// Without an error listener ioredis prints "Unhandled error event" on
// every reconnect attempt. Connection state is reported via ready/close.
redis.on("error", (err) => {
logger.debug({ err, client: name }, "Redis connection error");
});

return redis;
}

/**
* Wait until a client is ready, tolerating transient connection errors.
*
* Unlike listening for the first `error` event, this keeps waiting while
* ioredis retries, so a backend that starts before Dragonfly accepts
* connections still picks it up.
*
* @param redis - Client to wait for
* @param timeoutMs - Maximum time to wait
* @throws Error if the client is not ready within `timeoutMs`
*/
export async function waitForRedisReady(
redis: Redis,
timeoutMs: number = DEFAULT_STARTUP_TIMEOUT_MS,
): Promise<void> {
if (redis.status === "ready") return;

await new Promise<void>((resolve, reject) => {
const onReady = () => {
cleanup();
resolve();
};
const onEnd = () => {
cleanup();
reject(new Error("Redis connection ended before becoming ready"));
};
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Redis not ready within ${timeoutMs}ms (${redactRedisUrl(getRedisUrl())})`));
}, timeoutMs);
const cleanup = () => {
clearTimeout(timer);
redis.off("ready", onReady);
redis.off("end", onEnd);
};

redis.once("ready", onReady);
redis.once("end", onEnd);
});
}
12 changes: 5 additions & 7 deletions packages/backend/src/services/PasskeyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import {
verifyAuthenticationResponse,
type VerifiedRegistrationResponse,
type VerifiedAuthenticationResponse,
type AuthenticatorTransport,
} from "@simplewebauthn/server";
import { isoBase64URL } from "@simplewebauthn/server/helpers";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/types";
import type { IPasskeyCredentialRepository } from "../interfaces/repositories/IPasskeyCredentialRepository.js";
import type { IPasskeyChallengeRepository } from "../interfaces/repositories/IPasskeyChallengeRepository.js";
import type { IUserRepository } from "../interfaces/repositories/IUserRepository.js";
Expand Down Expand Up @@ -144,7 +144,7 @@ export class PasskeyService {
attestationType: "none", // Don't require attestation
excludeCredentials: existingPasskeys.map((passkey) => ({
id: passkey.credentialId,
transports: (passkey.transports as AuthenticatorTransportFuture[]) || undefined,
transports: (passkey.transports as AuthenticatorTransport[]) || undefined,
})),
authenticatorSelection: {
residentKey: "required", // Required for passkeys
Expand Down Expand Up @@ -283,9 +283,7 @@ export class PasskeyService {
* @returns Authentication options to pass to browser
*/
async generateAuthenticationOptions(username?: string): Promise<AuthenticationOptionsResponse> {
let allowCredentials:
| Array<{ id: string; transports?: AuthenticatorTransportFuture[] }>
| undefined;
let allowCredentials: Array<{ id: string; transports?: AuthenticatorTransport[] }> | undefined;

if (username) {
const user = await this.userRepository.findByUsername(username);
Expand All @@ -294,7 +292,7 @@ export class PasskeyService {
if (passkeys.length > 0) {
allowCredentials = passkeys.map((passkey) => ({
id: passkey.credentialId,
transports: (passkey.transports as AuthenticatorTransportFuture[]) || undefined,
transports: (passkey.transports as AuthenticatorTransport[]) || undefined,
}));
}
}
Expand Down Expand Up @@ -395,7 +393,7 @@ export class PasskeyService {
id: passkey.credentialId,
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
counter: passkey.counter,
transports: passkey.transports as AuthenticatorTransportFuture[] | undefined,
transports: passkey.transports as AuthenticatorTransport[] | undefined,
},
requireUserVerification: false,
});
Expand Down
Loading
Loading