fix(session, theta, artifacts): fix P1 bugs from Codex review

- P1-1: Set currentPhase at start of execute() instead of end
- P1-2: Add AbortController support to timeout execution
- P1-3: Add createdAt to DynamoDB AttributeDefinitions for GSI
- P1-4: Remove PendingIndex to avoid hot key problem
- P1-5: Add conditional update to prevent recovery race condition
This commit is contained in:
Shunsuke Hayashi 2026-01-26 07:35:38 +09:00
parent da1c5b26e0
commit 415a52055c
4 changed files with 139 additions and 37 deletions

View File

@ -59,12 +59,14 @@ export async function initializeTable(): Promise<void> {
await ddbClient.send(
new CreateTableCommand({
TableName: ARTIFACTS_TABLE,
// P1-3修正: GSIで使用する全属性をAttributeDefinitionsに追加
AttributeDefinitions: [
{ AttributeName: "id", AttributeType: "S" },
{ AttributeName: "sessionId", AttributeType: "S" },
{ AttributeName: "userId", AttributeType: "S" },
{ AttributeName: "type", AttributeType: "S" },
{ AttributeName: "expiresAt", AttributeType: "N" },
{ AttributeName: "createdAt", AttributeType: "N" }, // GSI用
{ AttributeName: "expiresAt", AttributeType: "N" }, // GSI用
],
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
GlobalSecondaryIndexes: [

View File

@ -13,6 +13,7 @@ import {
UpdateCommand,
QueryCommand,
DeleteCommand,
ScanCommand,
} from "@aws-sdk/lib-dynamodb";
import type {
PersistedSessionState,
@ -74,14 +75,8 @@ export async function initializeTable(): Promise<void> {
],
Projection: { ProjectionType: "ALL" },
},
{
IndexName: "PendingIndex",
KeySchema: [
{ AttributeName: "status", KeyType: "HASH" },
{ AttributeName: "expiresAt", KeyType: "RANGE" },
],
Projection: { ProjectionType: "ALL" },
},
// P1-4修正: PendingIndexを削除ホットキー問題回避
// 代わりにUserIndex/GuildIndexでstatus=RUNNINGをクエリ可能
],
BillingMode: "PAY_PER_REQUEST",
// Note: TTL is configured separately via UpdateTimeToLiveCommand
@ -165,7 +160,10 @@ export async function restoreState(sessionId: string): Promise<RestoredSession |
/**
*
*
* @param filter -
* P1-4修正: PendingIndex削除に伴いuserIdまたはguildId必須に変更
*
*
* @param filter - (userIdまたはguildId必須)
* @returns
*/
export async function getPendingSessions(
@ -173,6 +171,12 @@ export async function getPendingSessions(
): Promise<PersistedSessionState[]> {
const now = Math.floor(Date.now() / 1000);
// P1-4修正: userIdまたはguildIdが必須
if (!filter.userId && !filter.guildId) {
console.warn("[SessionManager] getPendingSessions requires userId or guildId");
return [];
}
// ステータスが指定されている場合はGSIを使用
if (filter.status) {
let indexName: string | undefined;
@ -190,9 +194,6 @@ export async function getPendingSessions(
indexName = "GuildIndex";
keyCondition = "guildId = :guildId AND #status = :status";
expressionValues[":guildId"] = filter.guildId;
} else {
indexName = "PendingIndex";
keyCondition = "#status = :status AND expiresAt > :now";
}
const response = await docClient.send(
@ -210,19 +211,31 @@ export async function getPendingSessions(
return (response.Items as unknown as PersistedSessionState[]) || [];
}
// フィルタなしで全未完了セッションを取得
// userIdまたはguildIdでRUNNINGセッションを取得
const indexName = filter.userId ? "UserIndex" : "GuildIndex";
const keyCondition = filter.userId
? "userId = :userId AND #status = :status"
: "guildId = :guildId AND #status = :status";
const expressionValues: Record<string, unknown> = {
":status": Status.RUNNING,
":now": now,
};
if (filter.userId) {
expressionValues[":userId"] = filter.userId;
} else {
expressionValues[":guildId"] = filter.guildId;
}
const response = await docClient.send(
new QueryCommand({
TableName: SESSIONS_TABLE,
IndexName: "PendingIndex",
KeyConditionExpression: "#status = :status AND expiresAt > :now",
IndexName: indexName,
KeyConditionExpression: keyCondition,
ExpressionAttributeNames: {
"#status": "status",
},
ExpressionAttributeValues: {
":status": Status.RUNNING,
":now": now,
},
ExpressionAttributeValues: expressionValues,
}),
);
@ -274,19 +287,68 @@ export async function updateStatus(sessionId: string, status: SessionStatus): Pr
);
}
/**
*
*
* P1-5修正: 現在のステータスが指定値の場合のみ更新
*
* @param sessionId - ID
* @param newStatus -
* @param expectedCurrentStatus -
* @returns
*/
export async function updateStatusIf(
sessionId: string,
newStatus: SessionStatus,
expectedCurrentStatus: SessionStatus,
): Promise<boolean> {
try {
await docClient.send(
new UpdateCommand({
TableName: SESSIONS_TABLE,
Key: { sessionId },
UpdateExpression: "SET #status = :newStatus, #updated = :updated",
ConditionExpression: "#status = :expectedStatus",
ExpressionAttributeNames: {
"#status": "status",
"#updated": "lastUpdateTime",
},
ExpressionAttributeValues: {
":newStatus": newStatus,
":expectedStatus": expectedCurrentStatus,
":updated": Date.now(),
},
}),
);
return true; // 更新成功
} catch (error: unknown) {
// ConditionalCheckFailedExceptionは正常他のプロセスが既に更新
const err = error as { name: string; code: string };
if (err.code === "ConditionalCheckFailedException") {
return false; // 条件不一致で更新スキップ
}
throw error; // その他のエラーは再スロー
}
}
/**
*
*
* TTLにより自動削除される
*
* P1-4修正: PendingIndex削除に伴いScanを使用
*
* NOTE: userId/guildIdフィルタは削除
* getPendingSessionsでユーザー/
*/
export async function cleanupExpiredSessions(): Promise<number> {
const now = Math.floor(Date.now() / 1000);
// Scan操作高コストなので注意して使用
const response = await docClient.send(
new QueryCommand({
new ScanCommand({
TableName: SESSIONS_TABLE,
IndexName: "PendingIndex",
KeyConditionExpression: "#status = :status AND expiresAt < :now",
FilterExpression: "#status = :status AND expiresAt < :now",
ExpressionAttributeNames: {
"#status": "status",
},
@ -299,7 +361,10 @@ export async function cleanupExpiredSessions(): Promise<number> {
const expired = response.Items || [];
for (const item of expired) {
await deleteSession(item.sessionId as string);
const sessionId = (item as unknown as { sessionId: string }).sessionId;
if (sessionId) {
await deleteSession(sessionId);
}
}
return expired.length;

View File

@ -5,7 +5,7 @@
*/
import type { PersistedSessionState, PendingSessionsFilter } from "./types.js";
import { getPendingSessions, updateStatus, deleteSession } from "./manager.js";
import { getPendingSessions, updateStatus, updateStatusIf, deleteSession } from "./manager.js";
import { SessionStatus } from "./types.js";
/**
@ -87,8 +87,21 @@ export async function recoverPendingSessions(
}
try {
// ステータスを一時停止に変更(二重実行防止)
await updateStatus(session.metadata.sessionId, SessionStatus.PAUSED);
// P1-5修正: 条件付き更新現在のステータスがRUNNINGの場合のみ
// 他のリカバリプロセスが既に処理していた場合はスキップ
const updated = await updateStatusIf(
session.metadata.sessionId,
SessionStatus.PAUSED,
SessionStatus.RUNNING,
);
if (!updated) {
console.log(
`[Recovery] Session ${session.metadata.sessionId} already being recovered by another process`,
);
result.skipped++;
continue;
}
// コールバック実行
if (onRestore) {

View File

@ -38,12 +38,15 @@ const DEFAULT_OPTIONS: ExecuteOptions = {
export async function execute<T = unknown>(
state: ThetaCycleState,
decision: DecisionResult,
executor: (params: Record<string, unknown>) => Promise<T>,
executor: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<T>,
options?: Partial<ExecuteOptions>,
): Promise<{ state: ThetaCycleState; result: ExecutionResult }> {
const { runId } = state;
const opts = { ...DEFAULT_OPTIONS, ...options };
// P1-1修正: 実行開始時にcurrentPhaseを設定
state.currentPhase = ThetaPhase.EXECUTE;
// フェーズ開始イベント
emitPhaseEvent(runId, ThetaEventType.PHASE_START, {
phase: ThetaPhase.EXECUTE,
@ -82,7 +85,6 @@ export async function execute<T = unknown>(
};
state.events.push(executionEvent);
state.currentPhase = ThetaPhase.EXECUTE;
state.context.set("execute.result", result);
// Agentイベントを発行
@ -140,9 +142,11 @@ export async function execute<T = unknown>(
/**
*
*
* P1-2修正: AbortControllerを使用してタイムアウト時に実行を確実にキャンセル
*/
async function executeWithRetry<T>(
executor: (params: Record<string, unknown>) => Promise<T>,
executor: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<T>,
params: Record<string, unknown>,
timeout: number,
retries: number,
@ -151,14 +155,20 @@ async function executeWithRetry<T>(
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
// 各試行ごとに新しいAbortControllerを作成
const controller = new AbortController();
const signal = controller.signal;
try {
// 進度通知
onProgress?.(attempt / (retries + 1));
// タイムアウト付き実行
const result = await withTimeout(executor(params), timeout);
// タイムアウト付き実行AbortSignalを渡す
const result = await withTimeout(executor(params, signal), timeout, controller);
return result;
} catch (error) {
// タイムアウトの場合はAbortControllerでキャンセル済み
// それ以外のエラーも記録
lastError = error instanceof Error ? error : new Error(String(error));
// 最後の試行で失敗した場合はエラーを投げる
@ -177,13 +187,25 @@ async function executeWithRetry<T>(
/**
*
*
* P1-2修正: AbortControllerで確実にキャンセル
*/
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
controller: AbortController,
): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs),
),
new Promise<T>((_, reject) => {
const timeoutId = setTimeout(() => {
controller.abort(); // 実行をキャンセル
reject(new Error(`Timeout after ${timeoutMs}ms`));
}, timeoutMs);
// Promiseが解決したらクリアンアップキャンセルされても実行
promise.finally?.(() => clearTimeout(timeoutId));
}),
]);
}
@ -231,7 +253,7 @@ function emitPhaseEvent(runId: string, type: ThetaEventType, data: Record<string
export function executeBackground<T = unknown>(
state: ThetaCycleState,
decision: DecisionResult,
executor: (params: Record<string, unknown>) => Promise<T>,
executor: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<T>,
options?: Partial<ExecuteOptions>,
): string {
const executionId = `bg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;