aws-samples / aws-samples/generative-ai-use-cases
findChatById が 1MB の Query 制限を超えたチャットを見つけられず、ヘビーユーザーの新規メッセージが保存されない
- Dominant language
- TypeScript
- Stars
- 1.4k
- Forks
- 433
- Avg merge
- 2h 35m
- Merged PRs (30d)
- 1
Description
## 概要
`packages/cdk/lambda/repository.ts` の `findChatById` は、`id = user#` のパーティションに対して `chatId` の `FilterExpression` を付けた Query を**1回だけ**実行しており、`LastEvaluatedKey` によるページングを行っていません。
DynamoDB の Query は、**フィルタが適用される前の段階で1回あたり最大1MBまで**しか評価しません。そのため、あるユーザーのチャット一覧アイテムが1MBを超えると、その打ち切り位置より後に作成されたチャットは `findChatById` から見えなくなり、`null` が返ります。
`createMessages` はこの `findChatById` を認可チェックとしてのみ使っているため、**403** を返して会話が一切保存されません。それでもユーザーの画面には回答がストリーミング表示され、タイトルも生成されるため、**障害が完全にサイレントになります**。
## 影響
弊社の本番環境では、あるユーザーのチャット一覧が約5,600件に達しました。単発の Query は **5,298件で打ち切られ**、そのタイムスタンプ以降に作成されたチャット(**約2週間で317件**)は、タイトルだけが保存され**メッセージ行がゼロ**の状態になっていました。
「メッセージを送ると途中で画面が落ちる」という申告があるまで、誰も気づきませんでした。失われた会話は復旧できません。メッセージが DynamoDB に一度も書き込まれていないため、ポイントインタイムリカバリでも復元できないためです。
長期運用中の環境にとって、これは特殊なケースではありません。1営業日あたり20〜30チャットを作るユーザーであれば2年弱で閾値に到達します。しかもユーザーにはエラーが表示されず、CloudWatch にもエラーログが残らず(403 を返すパスはログ出力しないため)、メトリクスにも現れません。
## 再現手順
1. チャット一覧アイテムが1MBを超えるまでアカウントを使い込む(弊社データでは1件あたり平均約200バイトで、約5,000件が目安)
2. 新しいチャットを作成してメッセージを送信する
3. 回答は通常どおりストリーミングされ、タイトルも生成される
4. ページをリロードすると、そのチャットは履歴一覧に表示されるが**中身は空**。`POST /chats/{chatId}/messages` は `403 "You do not have permission to post messages in the chat."` を返している
打ち切り位置は次のように直接確認できます。
```python
r = ddb.query(TableName=TABLE, KeyConditionExpression="id = :i",
ExpressionAttributeValues={":i": {"S": "user#"}})
print(r["Count"], "LastEvaluatedKey" in r) # 実際のアイテム数よりかなり手前で打ち切られる
```
## 原因
```typescript
// packages/cdk/lambda/repository.ts
export const findChatById = async (_userId, _chatId): Promise => {
const res = await dynamoDbDocument.send(
new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: '#id = :id',
FilterExpression: '#chatId = :chatId', // フィルタは1MB読み込みの「後」に適用される
// ExclusiveStartKey のループがない
// ScanIndexForward の指定がない -> 既定値 true(古い順)
})
);
if (!res.Items || res.Items.length === 0) return null;
return res.Items[0] as Chat;
};
```
2つの問題が重なっています。
1. **ページングがない**。`LastEvaluatedKey` を無視しているため、最初の1MB分のページしか評価されません
2. **走査順が昇順**。`ScanIndexForward` の既定値が `true` のため、1MBの枠を**最も古いチャット**から消費してしまいます。ユーザーが今まさに使っているチャットが、ちょうど枠の外に出ることになります
なお同じファイル内の `listChats` は、base64エンコードした `ExclusiveStartKey` で正しくページングし、`ScanIndexForward: false` を指定しています。そのため履歴一覧は正常に表示され続け、UI からは問題が見えにくくなっています。
## タイトルだけが残る理由
`predictTitle` は `setChatTitle(req.chat.id, req.chat.createdDate, title)` をプライマリキー直指定で呼んでおり、`findChatById` を経由しません。そのためタイトルの更新だけが成功し、メッセージ行は拒否されます。結果として、一覧上は正常に見えるのに中身が空のチャットができあがります。
## 影響を受ける他のエンドポイント
`findChatById` は `listMessages`、`updateTitle`、`createShareId`、`getSharedChat`、`deleteChat`、および `findChatById` ハンドラ自身からも使われています。影響を受けたチャットは、開くことも、名前を変えることも、共有することも、**削除することもできません**。そのため、UI から履歴を整理して復旧するという回避策も取れません。
## 修正案
ページングを行い、かつ新しい順に走査することで、直近のチャットが1回目の呼び出しで解決されるようにします。
```typescript
export const findChatById = async (
_userId: string,
_chatId: string
): Promise => {
const userId = `user#${_userId}`;
const chatId = `chat#${_chatId}`;
let exclusiveStartKey = undefined;
do {
const res = await dynamoDbDocument.send(
new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: '#id = :id',
FilterExpression: '#chatId = :chatId',
ExpressionAttributeNames: { '#id': 'id', '#chatId': 'chatId' },
ExpressionAttributeValues: { ':id': userId, ':chatId': chatId },
ScanIndexForward: false,
ExclusiveStartKey: exclusiveStartKey,
})
);
if (res.Items && res.Items.length > 0) return res.Items[0] as Chat;
exclusiveStartKey = res.LastEvaluatedKey;
} while (exclusiveStartKey);
return null;
};
```
実運用上の不具合は `ScanIndexForward: false` だけでも解消します。新しく作られたチャットは最初のページに含まれるためです。ページングのループを加えることで、古いチャットの参照も正しくなります。
よりスケールする代替案としては、`chatId` をキーにした GSI を追加し、フィルタ付きのパーティション走査ではなく直接クエリにする方法があります。履歴が大きいユーザーでパーティション全体を繰り返し読む必要がなくなりますが、既存テーブルではバックフィルのコストがかかります。
## 環境
- GenU v5.4.0 および現在の `main`(いずれも該当。`main` は 2026-08-06 時点で確認)
- DynamoDB テーブルはオンデマンドモード、約400万アイテム
Contributor guide
Research direction
Start in packages/cdk/lambda/repository.ts with findChatById, then compare its Query handling with the paginated, descending listChats implementation. Check the createMessages entry point and the other callers named in the issue. Done means recent and older chats can be found across DynamoDB pages without the affected endpoints returning a false 403.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- aws, typescript
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100