Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
e172b6e
perf(classroom): merge classrooms scans and paginate list reads
smalruby3-editor-bot[bot] Aug 29, 2026
ecb3223
fix(classroom): paginate remaining enumerations and harden DDB_MAX_PAGES
smalruby3-editor-bot[bot] Aug 29, 2026
0286eca
test(classroom): scope jest test files as modules to fix ts-jest coll…
smalruby3-editor-bot[bot] Aug 29, 2026
ddcc47a
Merge remote-tracking branch 'origin/develop' into topic/autopilot-1146
smalruby3-editor-bot[bot] Aug 29, 2026
f9af3d9
perf(classroom): 共同管理者の逆引き索引と groupId GSI を用意する
smalruby3-editor-bot[bot] Aug 29, 2026
72ebd51
chore(autopilot): #1146 の address-review チェックポイントを記録する
smalruby3-editor-bot[bot] Aug 29, 2026
99db5d5
Merge remote-tracking branch 'origin/develop' into topic/autopilot-1146
smalruby3-editor-bot[bot] Sep 1, 2026
723247a
perf(classroom): 一覧経路の Scan を逆引き索引と GSI の Query に置き換える
smalruby3-editor-bot[bot] Sep 1, 2026
abdbf7e
test(classroom): 逆引き索引の読み書きを回帰テストで固定する
smalruby3-editor-bot[bot] Sep 1, 2026
2d86419
feat(classroom): 逆引き索引のバックフィル CLI を用意する
smalruby3-editor-bot[bot] Sep 1, 2026
6be7d5a
docs(classroom): 逆引き索引ベースの読み取り構成とバックフィル手順を書く
smalruby3-editor-bot[bot] Sep 1, 2026
fb3749d
chore(autopilot): #1146 の継続メモを片付ける
smalruby3-editor-bot[bot] Sep 1, 2026
1161cef
refactor(classroom): 使われなくなった scanAll ヘルパーを落とす
smalruby3-editor-bot[bot] Sep 1, 2026
08ea801
test(admin): クラス検索テストをモジュール化して CI の赤を解く
smalruby3-editor-bot[bot] Sep 1, 2026
826f5de
docs(classroom): バックフィル手順に GSI の ACTIVE 待ちを足す
smalruby3-editor-bot[bot] Sep 1, 2026
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
5 changes: 3 additions & 2 deletions .claude/rules/infra/smalruby-classroom.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ CDK project for the Classroom service (API Gateway + Lambda + DynamoDB + S3).
`X-Google-Access-Token` を含む)。stage スロットリングに加え、`/classrooms/join` と
`/classrooms/lookup` はルート個別に厳しめのスロットリングを設定
- **Lambda (Node.js 22)**: 単一ハンドラー (`lambda/handler.ts`) に全ビジネスロジック
- **DynamoDB (8 テーブル)**:
- `Classrooms{suffix}` — GSI `joinCode-index` / `teacherSub-index`
- **DynamoDB (9 テーブル)**:
- `Classrooms{suffix}` — GSI `joinCode-index` / `teacherSub-index` / `groupId-index`(クラスに属する課題。#1146 で `groupId IN (...)` の Scan を置換)
- `ClassroomMemberships{suffix}` — GSI `sessionToken-index`
- `ClassroomSubmissions{suffix}` — GSI `classroomId-memberId-index`
- `ClassroomKickRequests{suffix}` — GSI `classroomId-seatNumber-index`(退室依頼 #692)
- `ClassroomGroups{suffix}` — GSI `teacherSub-index`(クラス=学級、長期 TTL 400日)
- `SharedAssignments{suffix}` — GSI `status-createdAt-index` / `authorSub-createdAt-index` / `passcode-index`(みんなの課題 #1066。**TTL なし・prod RETAIN + PITR**)
- `SharedAssignmentReports{suffix}` — 通報(TTL 90日)
- `ClassroomCoTeacherIndex{suffix}` — 共同管理者の逆引き索引 #1146(PK `coTeacherEmail` / SK `resourceKey` = `assignment#<id>` \| `group#<id>`)。`coTeacherEmails` はリスト属性で GSI にできないため別テーブル。**認可はこの索引を見ない**(真実は item 上のリスト)。既存データは `bin/backfill-coteacher-index.ts` で流し込む
- `ClassroomNotifications{suffix}` — お知らせ #1111(PK teacherSub / SK notificationId。**書き手は admin スタックのみ**・この Lambda は Query/UpdateItem だけ grant)
- 上記注記のないものは TTL `ttl`・**RemovalPolicy: DESTROY**
- **S3** `smalruby-classroom-submissions{suffix}`: 提出ファイル (project.sb3, thumbnail.png,
Expand Down
7 changes: 6 additions & 1 deletion docs/classroom/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,15 @@ erDiagram
- 先生トークン検証 (`verifyTeacherIdToken`) は `{sub, email}` を返す。Google は `email_verified` のときのみ email を採用、Microsoft は `email` / `preferred_username`。
- 所有権判定は `canManageClassroom(classroom, identity)` = 「`teacherSub === sub` または `coTeacherEmails` に自分の email が含まれる」。全ての先生向け操作で使用。co-teacher は owner と**完全同等**(課題削除・共同管理者の追加/解除も可)。
- 作成者は `teacherSub` で管理され `coTeacherEmails` には含めないため、co-teacher API から作成者を外すことはできない(管理者ゼロを防止)。
- `GET /classrooms` は次の 3 つの和集合: owner 分(`teacherSub-index`)、課題単位の co-taught 分(課題の `coTeacherEmails` への Scan + `contains` フィルタ)、**管理できるクラス(所有 + 共同管理)に属する課題**(クラスを列挙し、その `groupId` で Classrooms を Scan)。3 つ目は作成者を問わず拾うので、共同管理者にはオーナーが作った課題が、オーナーには共同管理者が作った課題が見える。DynamoDB はリスト属性を GSI 化できないため Scan を使用。Classrooms テーブルは小規模(単一組織・90日 TTL)のため許容。各課題は `role`(owner / co-teacher)を返し、フロントの「共同管理」バッジに使う。
- `GET /classrooms` は次の 3 つの和集合: owner 分(`teacherSub-index`)、課題単位の co-taught 分(**逆引き索引** `ClassroomCoTeacherIndex` → `BatchGetItem`)、**管理できるクラス(所有 + 共同管理)に属する課題**(管理クラスを列挙し、クラスごとに `groupId-index` を Query)。3 つ目は作成者を問わず拾うので、共同管理者にはオーナーが作った課題が、オーナーには共同管理者が作った課題が見える。各課題は `role`(owner / co-teacher)を返し、フロントの「共同管理」バッジに使う。
- **一覧経路は Scan を使わない**: Scan は RCU も 1MB 上限も**フィルタ適用「前」**に効くため、コストと取りこぼしのリスクが「その先生に見える量」ではなく**テーブル全体の量**(=他の先生のデータ量)に比例して増える。そのため課題単位の共同管理は逆引き索引、クラス経由の課題は `groupId-index` GSI に置き換えてある。回帰は `lambda/tests/handler-list-classrooms-reads.test.ts` が「Classrooms / ClassroomGroups への Scan が 0 回」で固定する。
- **ページング**: DynamoDB の 1MB 上限は(Query でもフィルタ適用「前」に)効くため、`LastEvaluatedKey` を辿らないとデータ量が増えたときにエラー無しで取りこぼす。一覧系の Query は共通ヘルパー `queryAll` で最後のページまで辿り、キー指定でまとめて読む経路は `batchGetAll`(100 キーずつ + `UnprocessedKeys` の再試行 — スロットリングは**エラーではなく未処理キーの返却**で表れるので、捨てると同じ「黙って取りこぼす」形になる)で読む。対象は `GET /classrooms` / `GET /classroom-groups` の読み取りに加え、**列挙結果がそのまま書き込みを駆動する経路**(組への移行 `POST /classroom-groups/migrate`、クラス人数の課題への伝播、課題名の重複連番、トピックのカスケード)も含む — ここで取りこぼすと「一部だけ更新されない」状態になり、読み取りの取りこぼしより気付きにくい。無限ループ防止に `DDB_MAX_PAGES`(既定 25、不正値は既定に戻す)でページ数を打ち切り、打ち切り時は CloudWatch に warn を残す。単一クラス内に閉じた Query(メンバー・提出物・退室依頼)と `Limit` 付きの「上位 N 件」系はページングしない。
- **クラス(組)単位の共同管理**の判定は `canManageGroup(group, identity)` = 「`teacherSub === sub` または クラスの `coTeacherEmails` に自分の email が含まれる」。クラス単位の共同管理者は、そのクラスの中では owner と同等に振る舞える(課題の一覧・作成・更新・複製・トピック管理・クラス設定の編集)。**例外はクラスの共同管理者リストそのもの**(`PATCH /classroom-groups/{groupId}` の `coTeacherEmails`)で、これは owner のみ変更できる(共同管理者が勝手に招待・自己解除できないようにするため)。
- 共同管理者の email は**書き込み時**に正規化(`validateCoTeacherEmail` = trim + 小文字)して保存する。認可判定(`canManageClassroom` / `canManageGroup`)は両辺を正規化して比較するので大文字小文字に左右されない。ただし一覧の絞り込みに使う DynamoDB の `contains` は完全一致(大文字小文字を区別)なので、**一覧に出るかどうかは保存側が正規化されている前提**に依存する(手で書き換えた項目に注意)。
- 所有者でも共同管理者でもない先生には、クラスの存在を秘匿して **404 `Group not found`** を返す。
- **`ClassroomCoTeacherIndex` テーブル(逆引き索引)**: PK `coTeacherEmail` / SK `resourceKey`(`assignment#<classroomId>` または `group#<groupId>`)。`coTeacherEmails` はリスト属性なので GSI のキーにできず、逆引きは別テーブルになる。行は `syncCoTeacherIndex()` が**差分だけ**書き(課題の共同管理者 追加/削除、組の `coTeacherEmails` 更新、v2 移行での組への引き上げ — この 4 経路すべてが書き手)、資源の `ttl` を写すので資源と一緒に消える。
- **認可は索引を見ない**。`canManageClassroom` / `canManageGroup` は今も item 上の `coTeacherEmails` を読む。索引は「一覧に出す候補を集める」ためだけのもので、読み取り側は引いた資源のリストで再確認する(索引が遅れても権限は落ちず、一覧に出るのが遅れるだけ)。
- **既存データにはバックフィルが必要**(索引は後付けなので、それ以前の共同管理者の行が存在しない)。`bin/backfill-coteacher-index.ts`(既定 dry-run・冪等)を**読み取りを索引に切り替えた Lambda を反映する前に**実行する。デプロイ順と手順は `docs/classroom/operations.md`。

### ClassroomMemberships テーブル

Expand Down
51 changes: 51 additions & 0 deletions docs/classroom/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,57 @@ npx ts-node bin/restore-classroom.ts --classroom-id <uuid> --apply
- 提出ファイル(S3 実体)の存在確認を行い、欠落があれば dry-run 時に警告表示
- 課題本体の書き込みは `attribute_not_exists` 条件付き(復元の競合で生きているデータを上書きしない)

## 共同管理者の逆引き索引バックフィル `bin/backfill-coteacher-index.ts`

`GET /classrooms` は共同管理の課題・クラスを **逆引き索引** `ClassroomCoTeacherIndex{suffix}`
(PK `coTeacherEmail` / SK `resourceKey`)から集める。索引は後付けなので、**それ以前に登録された
共同管理者の行は存在しない** — 流し込まないまま読み取りを索引に切り替えると、既存の共同管理者から
課題・クラスが**一覧に出なくなる**(権限そのものは item 上の `coTeacherEmails` で判定しているので
落ちない。直リンクなら操作できてしまうぶん気付きにくい)。

**デプロイ順(守る):**

1. `npx cdk deploy` — 索引テーブルと `groupId-index` GSI を作る
2. **`groupId-index` が `ACTIVE` になるまで待つ**(下記)
3. **バックフィルを `--apply` で実行**(下記)
4. 読み取りを索引に切り替えた Lambda を反映する `npx cdk deploy`

1 と 4 が同じ deploy に乗る場合(=通常のリリース)は、**deploy 直後に速やかに 2→3 を実行する**。
その間だけ共同管理の資源が一覧に出ない。

**GSI の待ちを飛ばさないこと。** GSI は作成直後 `CREATING`(`Backfilling: true`)で、この間は
その索引への Query が失敗しうる。**項目が 100 件程度の stg でも ACTIVE まで約 5 分かかった**実績が
あるので、本番ではもう少し見ておく。

```bash
# ACTIVE になるまで待つ
until [ "$(aws dynamodb describe-table --table-name Classrooms<suffix> \
--query 'Table.GlobalSecondaryIndexes[?IndexName==`groupId-index`].IndexStatus' --output text)" = ACTIVE ]; do
sleep 20
done
```

```bash
# デフォルトは dry-run(何件・どの行を作るかだけ表示)
npx ts-node bin/backfill-coteacher-index.ts

# 確認後に実行
npx ts-node bin/backfill-coteacher-index.ts --apply
```

**dry-run は索引テーブルが無くても動く**(既存テーブルを読むだけ)。デプロイ前に流して
「何行できるはずか」を先に確認しておくと、`--apply` の結果と突き合わせられるうえ、
一覧に出ない窓を短くできる。ステージは `.env` の symlink で切り替える
(`ln -sfn .env.prod .env`。`.claude/rules/infra/development.md`)。

- **冪等**。行のキーは `(coTeacherEmail, resourceKey)` なので、何度実行しても同じ行を上書きするだけ。
取りこぼしが疑われるときは再実行して差分を埋めてよい
- 対象は Classrooms / ClassroomGroups の**全件**(アーカイブ済みも含む。`?includeArchived=1` の
一覧からも共同管理者に見えるべきで、消えるのは TTL に任せる)
- 索引行は資源の `ttl` を写すので、資源と一緒に自然消滅する
- email は保存時と同じ正規化(trim + 小文字)でキーにする。**手で書き換えた項目**が正規化されて
いないと索引に載らない(一覧に出ない)ので、その場合は項目側を直してから再実行する

## よくあるケース

| 状況 | 対応 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
* 固定する(いずれも壊れると運用事故になる不変条件)。
*/

// このファイルをモジュールにするための宣言。import/export を持たない .ts は TypeScript の
// グローバルスクリプト扱いになり、トップレベルの const がテストファイル間で衝突する(#1144)。
export {};

const mockSend = jest.fn();
jest.mock('@aws-sdk/lib-dynamodb', () => {
const actual = jest.requireActual('@aws-sdk/lib-dynamodb');
Expand Down
122 changes: 122 additions & 0 deletions infra/smalruby-classroom/bin/backfill-coteacher-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env npx ts-node
/**
* Operator CLI: 既存データから共同管理者の逆引き索引
* (`ClassroomCoTeacherIndex{suffix}`) を作り直す一回限りのバックフィル
* (issue #1146)。
*
* `GET /classrooms` が索引を読むようになったので、**索引に行が無い共同管理者
* には資源が見えない**。索引は #1146 で追加したため、それ以前に登録された
* 共同管理者の行は存在しない。したがってデプロイ順は:
*
* 1. テーブル + GSI を作る(このスクリプトの前提。cdk deploy)
* 2. **このスクリプトを --apply で実行**
* 3. 読み取りを索引に切り替えた Lambda を反映(cdk deploy)
*
* 1 と 3 が同じ cdk deploy に乗る場合は、deploy 直後に速やかに 2 を実行する
* (その間は共同管理の資源が一覧に出ない。認可は item 上の coTeacherEmails を
* 見ているので、権限そのものは落ちない)。
*
* 冪等: 索引行のキーは (email, resourceKey) なので、何度実行しても同じ行を
* 上書きするだけ。夜間などに再実行して差分を埋めても安全。
*
* Usage (from infra/smalruby-classroom, with AWS credentials for the stage):
* npx ts-node bin/backfill-coteacher-index.ts # dry-run(既定)
* npx ts-node bin/backfill-coteacher-index.ts --apply # 実際に書く
*
* The stage comes from the .env symlink (STAGE), same as cdk deploy.
* Full runbook: docs/classroom/operations.md
*/
import 'dotenv/config';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, BatchWriteCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
import { resourceNamesForStage } from '../lambda/restore-lib';
import {
chunkIndexWrites,
indexRowsForResource,
parseBackfillArgs,
type CoTeacherIndexRow,
} from '../lambda/coteacher-index-lib';

const stage = process.env.STAGE || 'stg';
const names = resourceNamesForStage(stage);
const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));

/**
* テーブル全件を読む。バックフィルは「全部読む」以外に手が無いので、ここは
* 意図的に Scan(一回限りの運用作業であり、リクエスト経路ではない)。
* @param tableName - 読むテーブル
*/
async function scanEverything(tableName: string): Promise<Record<string, unknown>[]> {
const items: Record<string, unknown>[] = [];
let startKey: Record<string, unknown> | undefined;
do {
const res = await docClient.send(new ScanCommand({
TableName: tableName,
ExclusiveStartKey: startKey,
}));
items.push(...((res.Items || []) as Record<string, unknown>[]));
startKey = res.LastEvaluatedKey as Record<string, unknown> | undefined;
} while (startKey);
return items;
}

async function writeRows(rows: CoTeacherIndexRow[]): Promise<number> {
let written = 0;
for (const chunk of chunkIndexWrites(rows)) {
let pending = chunk.map(Item => ({ PutRequest: { Item } }));
for (let attempt = 0; pending.length > 0 && attempt < 10; attempt++) {
const res = await docClient.send(new BatchWriteCommand({
RequestItems: { [names.coTeacherIndexTable]: pending },
}));
written += pending.length;
// DynamoDB はスロットリングをエラーではなく「未処理分の返却」で表すので、
// ここを捨てると静かに一部だけ書かれた索引になる。
const unprocessed = (res.UnprocessedItems?.[names.coTeacherIndexTable] || []) as typeof pending;
written -= unprocessed.length;
pending = unprocessed;
}
if (pending.length > 0) {
throw new Error(`failed to write ${pending.length} index rows after retries`);
}
}
return written;
}

async function main(): Promise<void> {
const { apply } = parseBackfillArgs(process.argv.slice(2));
console.log(`stage=${stage} index=${names.coTeacherIndexTable} mode=${apply ? 'APPLY' : 'dry-run'}`);

const [classrooms, groups] = await Promise.all([
scanEverything(names.classroomsTable),
scanEverything(names.groupsTable),
]);

const rows = [
...classrooms.flatMap(item => indexRowsForResource('assignment', item)),
...groups.flatMap(item => indexRowsForResource('group', item)),
];
const emails = new Set(rows.map(r => r.coTeacherEmail));
console.log(
`scanned: ${classrooms.length} assignments / ${groups.length} groups`
+ ` → ${rows.length} index rows for ${emails.size} co-teacher emails`,
);

if (!apply) {
for (const row of rows.slice(0, 20)) {
console.log(` would put ${row.coTeacherEmail} ${row.resourceKey}`);
}
if (rows.length > 20) {
console.log(` ... and ${rows.length - 20} more`);
}
console.log('dry-run: nothing written. re-run with --apply');
return;
}

const written = await writeRows(rows);
console.log(`wrote ${written} index rows`);
}

main().catch(err => {
console.error(err);
process.exit(1);
});
Binary file not shown.
Loading
Loading