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:
parent
da1c5b26e0
commit
415a52055c
@ -59,12 +59,14 @@ export async function initializeTable(): Promise<void> {
|
|||||||
await ddbClient.send(
|
await ddbClient.send(
|
||||||
new CreateTableCommand({
|
new CreateTableCommand({
|
||||||
TableName: ARTIFACTS_TABLE,
|
TableName: ARTIFACTS_TABLE,
|
||||||
|
// P1-3修正: GSIで使用する全属性をAttributeDefinitionsに追加
|
||||||
AttributeDefinitions: [
|
AttributeDefinitions: [
|
||||||
{ AttributeName: "id", AttributeType: "S" },
|
{ AttributeName: "id", AttributeType: "S" },
|
||||||
{ AttributeName: "sessionId", AttributeType: "S" },
|
{ AttributeName: "sessionId", AttributeType: "S" },
|
||||||
{ AttributeName: "userId", AttributeType: "S" },
|
{ AttributeName: "userId", AttributeType: "S" },
|
||||||
{ AttributeName: "type", 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" }],
|
KeySchema: [{ AttributeName: "id", KeyType: "HASH" }],
|
||||||
GlobalSecondaryIndexes: [
|
GlobalSecondaryIndexes: [
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
UpdateCommand,
|
UpdateCommand,
|
||||||
QueryCommand,
|
QueryCommand,
|
||||||
DeleteCommand,
|
DeleteCommand,
|
||||||
|
ScanCommand,
|
||||||
} from "@aws-sdk/lib-dynamodb";
|
} from "@aws-sdk/lib-dynamodb";
|
||||||
import type {
|
import type {
|
||||||
PersistedSessionState,
|
PersistedSessionState,
|
||||||
@ -74,14 +75,8 @@ export async function initializeTable(): Promise<void> {
|
|||||||
],
|
],
|
||||||
Projection: { ProjectionType: "ALL" },
|
Projection: { ProjectionType: "ALL" },
|
||||||
},
|
},
|
||||||
{
|
// P1-4修正: PendingIndexを削除(ホットキー問題回避)
|
||||||
IndexName: "PendingIndex",
|
// 代わりにUserIndex/GuildIndexでstatus=RUNNINGをクエリ可能
|
||||||
KeySchema: [
|
|
||||||
{ AttributeName: "status", KeyType: "HASH" },
|
|
||||||
{ AttributeName: "expiresAt", KeyType: "RANGE" },
|
|
||||||
],
|
|
||||||
Projection: { ProjectionType: "ALL" },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
BillingMode: "PAY_PER_REQUEST",
|
BillingMode: "PAY_PER_REQUEST",
|
||||||
// Note: TTL is configured separately via UpdateTimeToLiveCommand
|
// 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 未完了セッション一覧
|
* @returns 未完了セッション一覧
|
||||||
*/
|
*/
|
||||||
export async function getPendingSessions(
|
export async function getPendingSessions(
|
||||||
@ -173,6 +171,12 @@ export async function getPendingSessions(
|
|||||||
): Promise<PersistedSessionState[]> {
|
): Promise<PersistedSessionState[]> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
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を使用
|
// ステータスが指定されている場合はGSIを使用
|
||||||
if (filter.status) {
|
if (filter.status) {
|
||||||
let indexName: string | undefined;
|
let indexName: string | undefined;
|
||||||
@ -190,9 +194,6 @@ export async function getPendingSessions(
|
|||||||
indexName = "GuildIndex";
|
indexName = "GuildIndex";
|
||||||
keyCondition = "guildId = :guildId AND #status = :status";
|
keyCondition = "guildId = :guildId AND #status = :status";
|
||||||
expressionValues[":guildId"] = filter.guildId;
|
expressionValues[":guildId"] = filter.guildId;
|
||||||
} else {
|
|
||||||
indexName = "PendingIndex";
|
|
||||||
keyCondition = "#status = :status AND expiresAt > :now";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await docClient.send(
|
const response = await docClient.send(
|
||||||
@ -210,19 +211,31 @@ export async function getPendingSessions(
|
|||||||
return (response.Items as unknown as PersistedSessionState[]) || [];
|
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(
|
const response = await docClient.send(
|
||||||
new QueryCommand({
|
new QueryCommand({
|
||||||
TableName: SESSIONS_TABLE,
|
TableName: SESSIONS_TABLE,
|
||||||
IndexName: "PendingIndex",
|
IndexName: indexName,
|
||||||
KeyConditionExpression: "#status = :status AND expiresAt > :now",
|
KeyConditionExpression: keyCondition,
|
||||||
ExpressionAttributeNames: {
|
ExpressionAttributeNames: {
|
||||||
"#status": "status",
|
"#status": "status",
|
||||||
},
|
},
|
||||||
ExpressionAttributeValues: {
|
ExpressionAttributeValues: expressionValues,
|
||||||
":status": Status.RUNNING,
|
|
||||||
":now": now,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -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により自動削除される。
|
* 手動クリーンアップ用。通常はTTLにより自動削除される。
|
||||||
|
*
|
||||||
|
* P1-4修正: PendingIndex削除に伴い、Scanを使用(高コストなので注意)
|
||||||
|
*
|
||||||
|
* NOTE: userId/guildIdフィルタは削除(型定義の複雑化を回避)
|
||||||
|
* 必要に応じてgetPendingSessionsでユーザー/ギルド単位で実行してください
|
||||||
*/
|
*/
|
||||||
export async function cleanupExpiredSessions(): Promise<number> {
|
export async function cleanupExpiredSessions(): Promise<number> {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Scan操作(高コストなので注意して使用)
|
||||||
const response = await docClient.send(
|
const response = await docClient.send(
|
||||||
new QueryCommand({
|
new ScanCommand({
|
||||||
TableName: SESSIONS_TABLE,
|
TableName: SESSIONS_TABLE,
|
||||||
IndexName: "PendingIndex",
|
FilterExpression: "#status = :status AND expiresAt < :now",
|
||||||
KeyConditionExpression: "#status = :status AND expiresAt < :now",
|
|
||||||
ExpressionAttributeNames: {
|
ExpressionAttributeNames: {
|
||||||
"#status": "status",
|
"#status": "status",
|
||||||
},
|
},
|
||||||
@ -299,7 +361,10 @@ export async function cleanupExpiredSessions(): Promise<number> {
|
|||||||
|
|
||||||
const expired = response.Items || [];
|
const expired = response.Items || [];
|
||||||
for (const item of expired) {
|
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;
|
return expired.length;
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PersistedSessionState, PendingSessionsFilter } from "./types.js";
|
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";
|
import { SessionStatus } from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,8 +87,21 @@ export async function recoverPendingSessions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ステータスを一時停止に変更(二重実行防止)
|
// P1-5修正: 条件付き更新(現在のステータスがRUNNINGの場合のみ)
|
||||||
await updateStatus(session.metadata.sessionId, SessionStatus.PAUSED);
|
// 他のリカバリプロセスが既に処理していた場合はスキップ
|
||||||
|
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) {
|
if (onRestore) {
|
||||||
|
|||||||
@ -38,12 +38,15 @@ const DEFAULT_OPTIONS: ExecuteOptions = {
|
|||||||
export async function execute<T = unknown>(
|
export async function execute<T = unknown>(
|
||||||
state: ThetaCycleState,
|
state: ThetaCycleState,
|
||||||
decision: DecisionResult,
|
decision: DecisionResult,
|
||||||
executor: (params: Record<string, unknown>) => Promise<T>,
|
executor: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<T>,
|
||||||
options?: Partial<ExecuteOptions>,
|
options?: Partial<ExecuteOptions>,
|
||||||
): Promise<{ state: ThetaCycleState; result: ExecutionResult }> {
|
): Promise<{ state: ThetaCycleState; result: ExecutionResult }> {
|
||||||
const { runId } = state;
|
const { runId } = state;
|
||||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
|
||||||
|
// P1-1修正: 実行開始時にcurrentPhaseを設定
|
||||||
|
state.currentPhase = ThetaPhase.EXECUTE;
|
||||||
|
|
||||||
// フェーズ開始イベント
|
// フェーズ開始イベント
|
||||||
emitPhaseEvent(runId, ThetaEventType.PHASE_START, {
|
emitPhaseEvent(runId, ThetaEventType.PHASE_START, {
|
||||||
phase: ThetaPhase.EXECUTE,
|
phase: ThetaPhase.EXECUTE,
|
||||||
@ -82,7 +85,6 @@ export async function execute<T = unknown>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
state.events.push(executionEvent);
|
state.events.push(executionEvent);
|
||||||
state.currentPhase = ThetaPhase.EXECUTE;
|
|
||||||
state.context.set("execute.result", result);
|
state.context.set("execute.result", result);
|
||||||
|
|
||||||
// Agentイベントを発行
|
// Agentイベントを発行
|
||||||
@ -140,9 +142,11 @@ export async function execute<T = unknown>(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* リトライ付きで実行する
|
* リトライ付きで実行する
|
||||||
|
*
|
||||||
|
* P1-2修正: AbortControllerを使用してタイムアウト時に実行を確実にキャンセル
|
||||||
*/
|
*/
|
||||||
async function executeWithRetry<T>(
|
async function executeWithRetry<T>(
|
||||||
executor: (params: Record<string, unknown>) => Promise<T>,
|
executor: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<T>,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
retries: number,
|
retries: number,
|
||||||
@ -151,14 +155,20 @@ async function executeWithRetry<T>(
|
|||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
// 各試行ごとに新しいAbortControllerを作成
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 進度通知
|
// 進度通知
|
||||||
onProgress?.(attempt / (retries + 1));
|
onProgress?.(attempt / (retries + 1));
|
||||||
|
|
||||||
// タイムアウト付き実行
|
// タイムアウト付き実行(AbortSignalを渡す)
|
||||||
const result = await withTimeout(executor(params), timeout);
|
const result = await withTimeout(executor(params, signal), timeout, controller);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// タイムアウトの場合はAbortControllerでキャンセル済み
|
||||||
|
// それ以外のエラーも記録
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
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([
|
return Promise.race([
|
||||||
promise,
|
promise,
|
||||||
new Promise<T>((_, reject) =>
|
new Promise<T>((_, reject) => {
|
||||||
setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs),
|
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>(
|
export function executeBackground<T = unknown>(
|
||||||
state: ThetaCycleState,
|
state: ThetaCycleState,
|
||||||
decision: DecisionResult,
|
decision: DecisionResult,
|
||||||
executor: (params: Record<string, unknown>) => Promise<T>,
|
executor: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<T>,
|
||||||
options?: Partial<ExecuteOptions>,
|
options?: Partial<ExecuteOptions>,
|
||||||
): string {
|
): string {
|
||||||
const executionId = `bg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
const executionId = `bg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user