From 3a342a2dc8bdd09542286310e55b6c6d68421e6a Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 7 Aug 2026 12:49:06 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(query):=20main=20=E7=94=B1=E6=9D=A5?= =?UTF-8?q?=E3=82=A4=E3=83=99=E3=83=B3=E3=83=88=E3=82=92=20(account,=20?= =?UTF-8?q?=E7=A8=AE=E5=88=A5)=20=E3=81=A7=E8=A7=A3=E6=B1=BA=E3=81=97?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E3=81=AE=E3=83=A9=E3=82=A4=E3=83=96=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一アカウントでメンション/ダイレクトカラムと通知カラムが併存すると、 両者が別々に main チャンネルを張ろうとし、Misskey の shouldShare 仕様で 2 本目が黙殺されて後から張った側 (通常は通知カラム) にイベントが届かな かった (#984)。 - ingest_stream_event: stream-notification / stream-mention は query_ids_by_subscription の 1:1 マップではなく (account_id, 種別) → QueryKey で対象 query を直接解決する (NoteCaptureUpdated と同じ アカウント単位イベントの型) - query_subscribe_mentions / notifications: attach_shared_stream_subscription で snapshot にだけ購読 ID を載せ、配送マップには登録しない。これにより 片方の close / suspend がもう片方の配送を巻き添えにしない - notecli を fe0ceed に bump (main 購読の per-account dedup + unsubscribe/suspend の no-op 化)。メンションカラムのサスペンドで OS 通知・未読バッジまで止まる問題 (#1002) も同時に解消 - Stream Inspector: 通知/メンションカラムは同じ共有 subscription id を 持つようになる (従来は負けた側の id がどのイベントとも一致せず フィルタが実質壊れていた) refs #984, #1002 Co-Authored-By: Claude Fable 5 --- ARCHITECTURE.md | 11 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/query_runtime.rs | 208 ++++++++++++++++-- .../deck/DeckStreamInspectorColumn.vue | 4 + 5 files changed, 208 insertions(+), 19 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 569c59018..10d9a99ad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -206,6 +206,8 @@ WebSocket 接続は維持したまま、subscription 単位で channel から ** **重要**: 「可視・予算外」だけでは suspend しない。これをやると見えているのに reaction が永続的に取り逃される(Misskey は再送しない)。 +**main チャンネルは suspend / unsubscribe の対象外** (#984): Misskey の `main` は `shouldShare` チャンネルで **1 WS 接続に 1 本しか張れない**(2 本目の connect はサーバーが黙って無視する)。通知・メンション・OS 通知・未読バッジがすべてこの 1 本にぶら下がるため、main の寿命はカラム(query)ではなく**アカウントセッション**に属する。`StreamingManager` は main をアカウント単位で dedup し、main への `unsubscribe` / `suspend_subscription` を no-op にする。解放経路は `disconnect` のみ。 + #### A-4c. Reaction freshness guarantees **場所**: `useNoteCapture` + `noteStore.applyUpdate` @@ -390,7 +392,7 @@ canonical key(serde JSON)で同一 query を dedup し、`subscriber_count` **コマンド:** -- `query_subscribe_{timeline,antenna,channel,role,mentions,notifications,chat_user,chat_room}` — `connect → open → attach_stream_subscription` を 1 IPC で行い `QuerySnapshot` を返す +- `query_subscribe_{timeline,antenna,channel,role,mentions,notifications,chat_user,chat_room}` — `connect → open → attach_stream_subscription` を 1 IPC で行い `QuerySnapshot` を返す。ただし mentions / notifications は main 共有のため `attach_shared_stream_subscription`(snapshot にだけ subscription id を載せ、配送マップには登録しない)を使う - `query_open(key)` — stream は張らず query レコードだけ作る(read-only 用途) - `query_set_runtime_state(queryId, state)` — `live | warm | suspended`。live ↔ suspended 遷移時は対応する subscription も resume / suspend - `query_close(queryId)` — refcount-- し 0 になったら stream も unsubscribe @@ -419,12 +421,13 @@ note 本体は保持せず、id 列だけを順序付きで持つ。理由: `StreamChange::from_event` が以下の stream-* を `Insert(item)` / `Delete(id)` に正規化し `apply()` で entry に反映: -- `stream-note` / `stream-mention` → `payload.note` -- `stream-notification` → `payload.notification` +- `stream-note` → `payload.note` - `stream-chat-message` → `payload.message` - `stream-note-updated` (updateType = `deleted`) → `payload.noteId` を削除 - `stream-chat-message-deleted` → `payload.messageId` を削除 +**main 由来イベントは subscription_id で引かない** (#984): `stream-notification` / `stream-mention` は `ingest_stream_event` の冒頭で **(account_id, 種別) → QueryKey** に解決する(`NoteCaptureUpdated` と同じ「アカウント単位イベント」の型)。main はアカウント単位 1 本の共有購読で、mentions / notifications の複数 query がぶら下がるため、`query_ids_by_subscription` の 1:1 マップでは配れない。この経路は attach 不要 — query が開いてさえいれば届く。 + **Delta emit:** `QueryDelta { queryId, revision, inserts, deletes }` を `tauri-specta` の typed event(`#[derive(Event)]`)として emit。bindings.ts に `events.queryDelta` として export される。`mount_events()` を `setup` 内で呼んで registry を登録している。 @@ -714,7 +717,7 @@ graph TB - Rust / Tauri 側の subscription を `QueryKey` 単位で 1 本だけ持つ - 複数 column observer に配信する -- observer 数が 0 になったとき即 unsubscribe(refcount-- が 0 で `query_close` → `stream_unsubscribe`) +- observer 数が 0 になったとき即 unsubscribe(refcount-- が 0 で `query_close` → `stream_unsubscribe`)。ただし main(mentions / notifications の購読元)は共有チャンネルのため unsubscribe は no-op で、アカウントの `disconnect` まで生きる (#984) - 再表示時に `sinceId` 差分 fetch + 既存 query の resume を行う ### ViewModel Layer diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 717354ef6..d6f41ef30 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3167,7 +3167,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "notecli" version = "0.8.1" -source = "git+https://github.com/notedeck-dev/notecli?rev=203d9d80dc16bd53646d24a2abcd7357dd598532#203d9d80dc16bd53646d24a2abcd7357dd598532" +source = "git+https://github.com/notedeck-dev/notecli?rev=fe0ceed05aeb0dc4ebfe0cb5d44f41e34fc1312b#fe0ceed05aeb0dc4ebfe0cb5d44f41e34fc1312b" dependencies = [ "android-native-keyring-store", "apple-native-keyring-store", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 781315edc..12ead4fa3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,7 +5,7 @@ description = "Misskey Pro — integrated deck environment (IDE) for Misskey pow edition = "2021" license = "AGPL-3.0-only" [dependencies] -notecli = { git = "https://github.com/notedeck-dev/notecli", rev = "203d9d80dc16bd53646d24a2abcd7357dd598532", features = ["specta"] } +notecli = { git = "https://github.com/notedeck-dev/notecli", rev = "fe0ceed05aeb0dc4ebfe0cb5d44f41e34fc1312b", features = ["specta"] } tauri = { version = "2", features = ["devtools"] } tauri-plugin-opener = "2" tauri-plugin-notification = "2" diff --git a/src-tauri/src/query_runtime.rs b/src-tauri/src/query_runtime.rs index 32dfd8d1c..9d1c9d725 100644 --- a/src-tauri/src/query_runtime.rs +++ b/src-tauri/src/query_runtime.rs @@ -300,6 +300,27 @@ impl QueryRuntime { Ok(snapshot(entry)) } + /// main のような共有 subscription を query に紐付ける。snapshot の + /// source_subscription_id (JS 側の Stream Inspector フィルタ等) は設定するが、 + /// イベント配送用の query_ids_by_subscription には登録しない — main 由来 + /// イベントは ingest_stream_event が (account, 種別) で解決する (#984)。 + pub fn attach_shared_stream_subscription( + &self, + query_id: &str, + subscription_id: String, + ) -> Result { + let mut inner = self.lock()?; + let entry = inner + .entries + .get_mut(query_id) + .ok_or_else(|| runtime_error(format!("unknown query id: {query_id}")))?; + if entry.source_subscription_id.as_ref() != Some(&subscription_id) { + entry.source_subscription_id = Some(subscription_id); + entry.revision = entry.revision.saturating_add(1); + } + Ok(snapshot(entry)) + } + pub fn stream_subscription_for( &self, query_id: &str, @@ -440,6 +461,49 @@ impl QueryRuntime { return true; } + // main チャンネル由来 (notification / mention) は subscription_id で引かない。 + // main はアカウント単位 1 本の共有チャンネル (notecli 側で dedup, #984) で、 + // mentions と notifications の両 query がぶら下がるため、1:1 の + // query_ids_by_subscription では配れない。NoteCaptureUpdated と同様に + // (account_id, 種別) から対象 query を直接解決する。 + let main_route = match event { + StreamEvent::Notification(e) => Some(( + QueryKey::Notifications { + account_id: e.account_id.clone(), + }, + StreamChangeKind::Insert(QueryItem::Notification(Box::new(e.notification.clone()))), + )), + StreamEvent::Mention(e) => Some(( + QueryKey::Timeline { + account_id: e.account_id.clone(), + key: TimelineKey::Mentions.as_canonical(), + }, + StreamChangeKind::Insert(QueryItem::Note(e.note.clone())), + )), + _ => None, + }; + if let Some((key, kind)) = main_route { + let Ok(canonical_key) = canonicalize_key(&key) else { + return false; + }; + let Ok(mut inner) = self.inner.lock() else { + return false; + }; + let Some(query_id) = inner.ids_by_key.get(&canonical_key).cloned() else { + // 対象 query が開いていなければ捨てる (OS 通知・未読バッジは + // subscription 非依存の別経路なので影響しない)。 + return false; + }; + let Some(entry) = inner.entries.get_mut(&query_id) else { + return false; + }; + if kind.apply(entry) { + inner.pending_query_ids.insert(query_id); + return true; + } + return false; + } + let Some(change) = StreamChange::from_event(event) else { return false; }; @@ -456,7 +520,7 @@ impl QueryRuntime { let Some(entry) = inner.entries.get_mut(&query_id) else { return false; }; - if change.apply(entry) { + if change.kind.apply(entry) { inner.pending_query_ids.insert(query_id); true } else { @@ -521,20 +585,14 @@ enum StreamChangeKind { impl<'a> StreamChange<'a> { /// typed StreamEvent から read model への変更を取り出す (#781 Phase 3)。 /// 生 JSON の再 parse はもう存在しない — 型は WS 境界で確定済み。 + /// Notification / Mention は main 由来のため ingest_stream_event の + /// (account, 種別) ルーティングが先に処理し、ここには来ない。 fn from_event(event: &'a StreamEvent) -> Option { let (subscription_id, kind) = match event { StreamEvent::Note(e) => ( e.subscription_id.as_str(), StreamChangeKind::Insert(QueryItem::Note(e.note.clone())), ), - StreamEvent::Mention(e) => ( - e.subscription_id.as_str(), - StreamChangeKind::Insert(QueryItem::Note(e.note.clone())), - ), - StreamEvent::Notification(e) => ( - e.subscription_id.as_str(), - StreamChangeKind::Insert(QueryItem::Notification(Box::new(e.notification.clone()))), - ), StreamEvent::ChatMessage(e) => ( e.subscription_id.as_str(), StreamChangeKind::Insert(QueryItem::ChatMessage(Box::new(e.message.clone()))), @@ -560,7 +618,9 @@ impl<'a> StreamChange<'a> { kind, }) } +} +impl StreamChangeKind { /// recent_ids / id_set / revision を即時更新しつつ、emit するための変更を /// `entry.pending` に積む。返り値は「flusher を起こすべきか」のフラグ /// (= 何かが pending に入ったか)。 @@ -571,7 +631,7 @@ impl<'a> StreamChange<'a> { if entry.runtime_state == QueryRuntimeState::Suspended { return false; } - match self.kind { + match self { StreamChangeKind::Insert(item) => { let id = item.id().to_string(); if entry.id_set.contains(&id) { @@ -768,8 +828,10 @@ pub async fn query_subscribe_mentions( return Ok(opened); } + // main はアカウント単位 1 本の共有チャンネル (notecli 側で dedup)。 + // イベントは (account, 種別) で解決するため shared attach を使う (#984)。 let subscription_id = streaming.subscribe_main(&account_id).await?; - runtime.attach_stream_subscription(&opened.query_id, subscription_id) + runtime.attach_shared_stream_subscription(&opened.query_id, subscription_id) } #[tauri::command] @@ -791,8 +853,10 @@ pub async fn query_subscribe_notifications( return Ok(opened); } + // main はアカウント単位 1 本の共有チャンネル (notecli 側で dedup)。 + // イベントは (account, 種別) で解決するため shared attach を使う (#984)。 let subscription_id = streaming.subscribe_main(&account_id).await?; - runtime.attach_stream_subscription(&opened.query_id, subscription_id) + runtime.attach_shared_stream_subscription(&opened.query_id, subscription_id) } #[tauri::command] @@ -988,7 +1052,9 @@ fn account_id(key: &QueryKey) -> &str { mod tests { use super::*; use notecli::models::{NoteDeletedBody, NoteReactedBody}; - use notecli::streaming::{StreamNoteEvent, StreamNoteUpdatedEvent}; + use notecli::streaming::{ + StreamMentionEvent, StreamNoteEvent, StreamNoteUpdatedEvent, StreamNotificationEvent, + }; use serde_json::json; fn home_key(account: &str) -> QueryKey { @@ -1051,6 +1117,122 @@ mod tests { })) } + fn mentions_key(account: &str) -> QueryKey { + QueryKey::Timeline { + account_id: account.into(), + key: "mentions".into(), + } + } + + fn notifications_key(account: &str) -> QueryKey { + QueryKey::Notifications { + account_id: account.into(), + } + } + + fn notification_event(sub_id: &str, account: &str, notification_id: &str) -> StreamEvent { + StreamEvent::Notification(Box::new(StreamNotificationEvent { + account_id: account.into(), + subscription_id: sub_id.into(), + notification: serde_json::from_value(json!({ + "id": notification_id, + "_accountId": account, + "_serverHost": "misskey.example", + "createdAt": "2026-01-01T00:00:00.000Z", + "type": "reaction" + })) + .expect("test notification fixture should deserialize"), + })) + } + + fn mention_event(sub_id: &str, account: &str, note_id: &str) -> StreamEvent { + StreamEvent::Mention(Box::new(StreamMentionEvent { + account_id: account.into(), + subscription_id: sub_id.into(), + note: test_note(note_id), + })) + } + + /// main 由来イベントは subscription_id ではなく (account, 種別) で解決する。 + /// main はアカウント単位 1 本の共有チャンネルで、mentions / notifications の + /// 両 query がぶら下がるため、1:1 の subscription マップでは配れない (#984)。 + #[test] + fn main_events_route_by_account_and_kind() { + let rt = QueryRuntime::default(); + let mentions = rt.open(mentions_key("acct-1")).unwrap(); + let notifications = rt.open(notifications_key("acct-1")).unwrap(); + + // どちらの query にも attach していない状態で main イベントが届く + // (subscription_id はどの query とも紐付いていない共有 main の id)。 + assert!(rt.ingest_stream_event(¬ification_event("sub-main", "acct-1", "notif-1"))); + assert!(rt.ingest_stream_event(&mention_event("sub-main", "acct-1", "note-1"))); + + let notif_snap = rt + .read_model_snapshot(¬ifications.query_id, None) + .unwrap() + .unwrap(); + assert_eq!( + notif_snap.item_ids, + vec!["notif-1"], + "通知は通知 query だけに入る" + ); + + let mention_snap = rt + .read_model_snapshot(&mentions.query_id, None) + .unwrap() + .unwrap(); + assert_eq!( + mention_snap.item_ids, + vec!["note-1"], + "メンションはメンション query だけに入る" + ); + } + + /// 別アカウントの main イベントは別アカウントの query に入らない。 + #[test] + fn main_events_do_not_cross_accounts() { + let rt = QueryRuntime::default(); + let n1 = rt.open(notifications_key("acct-1")).unwrap(); + let n2 = rt.open(notifications_key("acct-2")).unwrap(); + + assert!(rt.ingest_stream_event(¬ification_event("sub-main", "acct-2", "notif-x"))); + + let snap1 = rt.read_model_snapshot(&n1.query_id, None).unwrap().unwrap(); + assert!(snap1.item_ids.is_empty()); + let snap2 = rt.read_model_snapshot(&n2.query_id, None).unwrap().unwrap(); + assert_eq!(snap2.item_ids, vec!["notif-x"]); + } + + /// メンション query を suspend / close しても通知の配送は止まらない (#984 の + /// 巻き添えパターンの回帰テスト)。 + #[test] + fn notification_delivery_survives_mentions_suspend_and_close() { + let rt = QueryRuntime::default(); + let mentions = rt.open(mentions_key("acct-1")).unwrap(); + let notifications = rt.open(notifications_key("acct-1")).unwrap(); + + rt.set_runtime_state(&mentions.query_id, QueryRuntimeState::Suspended) + .unwrap(); + assert!(rt.ingest_stream_event(¬ification_event("sub-main", "acct-1", "notif-1"))); + + rt.close(&mentions.query_id).unwrap(); + assert!(rt.ingest_stream_event(¬ification_event("sub-main", "acct-1", "notif-2"))); + + let snap = rt + .read_model_snapshot(¬ifications.query_id, None) + .unwrap() + .unwrap(); + assert_eq!(snap.item_ids, vec!["notif-2", "notif-1"]); + } + + /// 開いていない種別の main イベントは黙って捨てる (OS 通知・未読バッジは + /// 別経路なので影響しない)。 + #[test] + fn main_events_without_open_query_are_dropped() { + let rt = QueryRuntime::default(); + assert!(!rt.ingest_stream_event(¬ification_event("sub-main", "acct-1", "notif-1"))); + } + /// T1: 同一 key で 2 回 open すると subscriber=2、query_id は同一、 /// revision は 1 回目で 1、2 回目で 2 になる。 #[test] diff --git a/src/components/deck/DeckStreamInspectorColumn.vue b/src/components/deck/DeckStreamInspectorColumn.vue index 524037b4a..a052b64ef 100644 --- a/src/components/deck/DeckStreamInspectorColumn.vue +++ b/src/components/deck/DeckStreamInspectorColumn.vue @@ -64,6 +64,10 @@ const clearedBefore = ref(0) * 無効化(OFF)したカラムの subscriptionId 集合。デフォルトは全カラム有効=空集合。 * kind ピルと同じファセット型: 有効(色付き)=そのカラムのイベントを流す。 * クリックで OFF にしたカラムだけ除外する。 + * 注: main チャンネル (通知/メンション) はアカウント単位で 1 本の共有購読 + * (#984) のため、通知カラムとメンションカラムは同じ subscriptionId を持つ。 + * どちらかを OFF にすると main 由来イベント全体が非表示になる — 種別で + * 絞りたいときは kind ピルを使う。 */ const disabledSubIds = ref(new Set()) From 7e1fadaddfca89a8170f2fb65e10e642d18b4b2b Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 7 Aug 2026 12:59:55 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20notecli=20rev=20=E3=82=92=20develo?= =?UTF-8?q?p=20=E3=81=AE=20merge=20commit=20=E3=81=AB=E5=86=8D=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notecli#58 (main チャンネル dedup) が develop にマージされたため、 feature ブランチ commit への pin を merge commit 2d7305ea に更新。 Co-Authored-By: Claude Fable 5 --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d6f41ef30..4c9effc07 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3167,7 +3167,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "notecli" version = "0.8.1" -source = "git+https://github.com/notedeck-dev/notecli?rev=fe0ceed05aeb0dc4ebfe0cb5d44f41e34fc1312b#fe0ceed05aeb0dc4ebfe0cb5d44f41e34fc1312b" +source = "git+https://github.com/notedeck-dev/notecli?rev=2d7305ea82f184047191c899966d03b88ebef5d7#2d7305ea82f184047191c899966d03b88ebef5d7" dependencies = [ "android-native-keyring-store", "apple-native-keyring-store", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 12ead4fa3..069080db5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,7 +5,7 @@ description = "Misskey Pro — integrated deck environment (IDE) for Misskey pow edition = "2021" license = "AGPL-3.0-only" [dependencies] -notecli = { git = "https://github.com/notedeck-dev/notecli", rev = "fe0ceed05aeb0dc4ebfe0cb5d44f41e34fc1312b", features = ["specta"] } +notecli = { git = "https://github.com/notedeck-dev/notecli", rev = "2d7305ea82f184047191c899966d03b88ebef5d7", features = ["specta"] } tauri = { version = "2", features = ["devtools"] } tauri-plugin-opener = "2" tauri-plugin-notification = "2" From 0047e39fc36924197f444b8ee8bb76fc3ff00020 Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 7 Aug 2026 12:16:12 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(perf):=20=E4=B8=8A=E9=99=90=E3=81=A4?= =?UTF-8?q?=E3=81=8D=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=E3=81=AE?= =?UTF-8?q?=E5=85=B1=E9=80=9A=E5=9F=BA=E7=9B=A4=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 長時間起動でメモリが単調増加する経路は、どれも「モジュールスコープに空の Map を置いて delete を書き忘れる」形で入っていた。個別に cap を足して回る のではなく、受け皿を用意して構造的に上限を保証する。 上限は関数でも渡せるようにした。生成時の値で固定すると設定変更が効かない 死にノブになるため (#921 と同型の事故)。 Refs #987 Co-Authored-By: Claude Opus 4.8 --- src/services/boundedCache.test.ts | 95 +++++++++++++++++++++++++++++++ src/services/boundedCache.ts | 65 +++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 src/services/boundedCache.test.ts create mode 100644 src/services/boundedCache.ts diff --git a/src/services/boundedCache.test.ts b/src/services/boundedCache.test.ts new file mode 100644 index 000000000..c5f943d26 --- /dev/null +++ b/src/services/boundedCache.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { createBoundedCache } from '@/services/boundedCache' + +describe('createBoundedCache', () => { + it('上限を超えたら最も古いエントリから捨てる', () => { + const cache = createBoundedCache(2) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + + expect(cache.size).toBe(2) + expect(cache.has('a')).toBe(false) + expect(cache.get('b')).toBe(2) + expect(cache.get('c')).toBe(3) + }) + + it('get したエントリは新しい扱いになる (LRU)', () => { + const cache = createBoundedCache(2) + cache.set('a', 1) + cache.set('b', 2) + cache.get('a') // a を触ったので、次に捨てられるのは b + cache.set('c', 3) + + expect(cache.has('a')).toBe(true) + expect(cache.has('b')).toBe(false) + }) + + it('同じキーの上書きはサイズを増やさない', () => { + const cache = createBoundedCache(2) + cache.set('a', 1) + cache.set('a', 2) + + expect(cache.size).toBe(1) + expect(cache.get('a')).toBe(2) + }) + + it('undefined を値として保持できる (has と get を混同しない)', () => { + const cache = createBoundedCache(2) + cache.set('a', undefined) + + expect(cache.has('a')).toBe(true) + expect(cache.get('a')).toBeUndefined() + }) + + it('上限を関数で渡すと、設定変更が次の set から効く', () => { + let max = 3 + const cache = createBoundedCache(() => max) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + expect(cache.size).toBe(3) + + max = 1 + cache.set('d', 4) + + expect(cache.size).toBe(1) + expect(cache.get('d')).toBe(4) + }) + + it('上限 0 以下は 1 に丸める (設定ミスでキャッシュが機能不全にならない)', () => { + const cache = createBoundedCache(0) + cache.set('a', 1) + + expect(cache.size).toBe(1) + expect(cache.get('a')).toBe(1) + }) + + it('delete と clear でエントリを落とせる', () => { + const cache = createBoundedCache(4) + cache.set('a', 1) + cache.set('b', 2) + + expect(cache.delete('a')).toBe(true) + expect(cache.delete('a')).toBe(false) + expect(cache.size).toBe(1) + + cache.clear() + expect(cache.size).toBe(0) + }) + + it('古い順に列挙できる (永続化で「新しい N 件」を選ぶため)', () => { + const cache = createBoundedCache(4) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.get('a') + + expect([...cache.keys()]).toEqual(['b', 'c', 'a']) + expect([...cache.entries()]).toEqual([ + ['b', 2], + ['c', 3], + ['a', 1], + ]) + }) +}) diff --git a/src/services/boundedCache.ts b/src/services/boundedCache.ts new file mode 100644 index 000000000..efc29a586 --- /dev/null +++ b/src/services/boundedCache.ts @@ -0,0 +1,65 @@ +/** + * 上限つきキャッシュの共通基盤 (#987)。 + * + * 「キャッシュには必ず上限」を守る側の受け皿。長時間起動でメモリが単調増加 + * する経路は、たいてい「モジュールスコープに素の Map を置いて delete を + * 書き忘れた」形で入る (blurhash / 絵文字辞書がそうだった)。個別に cap を + * 足して回るのではなく、ここを通すことで上限を構造的に保証する。 + * + * 上限は関数でも渡せる。performance.json5 のノブは実行中に変わるので、 + * 生成時の値で固定すると設定が効かない死にノブになる (#921 と同型の事故)。 + * + * eviction は挿入順ベースの LRU。`evictByLiveness` (notes / chat) と違い、 + * 「どのカラムから参照されているか」を持たない純粋な派生データ用。 + */ + +export interface BoundedCache { + get(key: K): V | undefined + has(key: K): boolean + set(key: K, value: V): void + delete(key: K): boolean + clear(): void + /** 古い順 (次に捨てられる順) */ + keys(): IterableIterator + /** 古い順 (次に捨てられる順) */ + entries(): IterableIterator<[K, V]> + readonly size: number +} + +export function createBoundedCache( + max: number | (() => number), +): BoundedCache { + const map = new Map() + // 0 以下は「キャッシュ無効」ではなく 1 に丸める。設定ミスで毎回の + // 再計算に落ちるより、最小限でも効いているほうが害が小さい + const maxOf = () => Math.max(1, typeof max === 'function' ? max() : max) + + return { + get(key) { + if (!map.has(key)) return undefined + const value = map.get(key) as V + // 触ったものを末尾へ (挿入順 = LRU 順を保つ) + map.delete(key) + map.set(key, value) + return value + }, + has: (key) => map.has(key), + set(key, value) { + map.delete(key) + map.set(key, value) + const limit = maxOf() + while (map.size > limit) { + const oldest = map.keys().next() + if (oldest.done) break + map.delete(oldest.value) + } + }, + delete: (key) => map.delete(key), + clear: () => map.clear(), + keys: () => map.keys(), + entries: () => map.entries(), + get size() { + return map.size + }, + } +} From 4fa8aada3cf768f9022a2a2b8ea1a06b11ae7e3c Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 7 Aug 2026 12:17:26 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix(perf):=20=E7=84=A1=E5=88=B6=E9=99=90?= =?UTF-8?q?=E3=81=AB=E8=82=B2=E3=81=A3=E3=81=A6=E3=81=84=E3=81=9F=E3=82=AD?= =?UTF-8?q?=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=204=20=E7=B5=8C=E8=B7=AF?= =?UTF-8?q?=E3=81=AB=E4=B8=8A=E9=99=90=E3=82=92=E5=85=A5=E3=82=8C=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 長時間起動しても劣化しない、という前提に対して破れていた経路を塞ぐ。 - 画像プレースホルダ (blurhash → data URL): 上限も削除も無く、スクロール した数だけ残っていた。共通基盤に載せる (新ノブ blurhashCacheMax) - 絵文字辞書: 定義・既定値・設定 UI まで揃っていた emojiCachePerHost / emojiPersistPerHost が実装のどこからも参照されておらず、効いているように 見えて何も起きない死にノブだった。実際に配線する - 絵文字辞書の host 数: 連合先の数だけ増え続けていた。新ノブ emojiCacheHosts で頭打ちにし、落とした host の付随状態も一緒に捨てる (辞書だけ刈っても付随状態が残れば同じ速度で増える) - 通知カラムのリアクション URL: キーに通知 ID を含むのに上限が無く、 カラムを閉じるまで消えなかった。通知カラムは開きっぱなしにされる面 Refs #987 Co-Authored-By: Claude Opus 4.8 --- .../deck/DeckNotificationColumn.vue | 9 +- src/defaults/performance.json5 | 2 + src/stores/emojis.dom.test.ts | 90 ++++++++++++++++++- src/stores/emojis.ts | 63 +++++++++++-- src/stores/performance.ts | 2 + src/stores/performanceData.ts | 24 +++++ src/utils/blurhashDataUrl.ts | 15 +++- 7 files changed, 194 insertions(+), 11 deletions(-) diff --git a/src/components/deck/DeckNotificationColumn.vue b/src/components/deck/DeckNotificationColumn.vue index c8f4abab7..244997649 100644 --- a/src/components/deck/DeckNotificationColumn.vue +++ b/src/components/deck/DeckNotificationColumn.vue @@ -43,6 +43,7 @@ import { usePortal } from '@/composables/usePortal' import { useReadMarker } from '@/composables/useReadMarker' import { useTabSlide } from '@/composables/useTabSlide' import { getStreamHealth } from '@/core/streamHealth' +import { createBoundedCache } from '@/services/boundedCache' import { syncNotificationNotes } from '@/services/notificationNoteSync' import { getAccountAvatarUrl, useAccountsStore } from '@/stores/accounts' import { type DeckColumn as DeckColumnType, useDeckStore } from '@/stores/deck' @@ -445,7 +446,13 @@ function flushRafBuffer() { } // Cache reaction URLs per notification to avoid double-call in template (v-if + :src) -const reactionUrlLookup = new Map() +// キーは通知 ID を含むので、流れてきた通知の数だけ増える。通知カラムは +// 開きっぱなしにされる面なので上限を持たせる (#987)。保持している通知の +// 数を超えて覚えていても引かれることはない +const reactionUrlLookup = createBoundedCache(() => + perfStore.get('maxNotifications'), +) +// 絵文字そのものがキー (Unicode 絵文字の種類ぶん) なので通知数では増えない const twemojiUrlLookup = new Map() function getCachedReactionUrl( diff --git a/src/defaults/performance.json5 b/src/defaults/performance.json5 index beab44253..2454f57ad 100644 --- a/src/defaults/performance.json5 +++ b/src/defaults/performance.json5 @@ -1,5 +1,6 @@ { "emojiCachePerHost": 4000, + "emojiCacheHosts": 32, "emojiListHosts": 4, "emojiPersistPerHost": 500, "noteStoreMax": 1500, @@ -9,6 +10,7 @@ "chatMessageStoreMax": 5000, "maxNotifications": 300, "mfmCacheMax": 256, + "blurhashCacheMax": 256, "imageProxyCacheMax": 128, "ogpCacheMax": 256, "noteCaptureMax": 80, diff --git a/src/stores/emojis.dom.test.ts b/src/stores/emojis.dom.test.ts index 66266ac47..d8ab0bf8f 100644 --- a/src/stores/emojis.dom.test.ts +++ b/src/stores/emojis.dom.test.ts @@ -3,8 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ServerEmoji } from '@/adapters/types' import { useEmojisStore } from '@/stores/emojis' +/** テストごとに上書きするノブ。指定しないキーは 10 */ +const perf: Record = {} + vi.mock('@/stores/performance', () => ({ - usePerformanceStore: () => ({ get: () => 10 }), + usePerformanceStore: () => ({ + get: (key: string) => perf[key] ?? 10, + }), })) const HOST = 'misskey.example' @@ -26,6 +31,7 @@ async function flush() { describe('useEmojisStore', () => { beforeEach(() => { localStorage.clear() + for (const key of Object.keys(perf)) delete perf[key] vi.useFakeTimers() setActivePinia(createPinia()) }) @@ -260,6 +266,88 @@ describe('useEmojisStore', () => { }) }) + describe('メモリ上限 (#987)', () => { + it('emojiCachePerHost を超えた絵文字は辞書に載せない', async () => { + perf.emojiCachePerHost = 2 + const store = useEmojisStore() + store.ensureLoaded( + HOST, + vi.fn().mockResolvedValue([emoji('a'), emoji('b'), emoji('c')]), + ) + await flush() + + expect(store.resolve(HOST, 'a')).not.toBeNull() + expect(store.resolve(HOST, 'b')).not.toBeNull() + expect(store.resolve(HOST, 'c')).toBeNull() + }) + + it('emojiCacheHosts を超えたら古い host の辞書から捨てる', async () => { + perf.emojiCacheHosts = 2 + const store = useEmojisStore() + for (const host of ['a.example', 'b.example', 'c.example']) { + store.ensureLoaded(host, vi.fn().mockResolvedValue([emoji('meow')])) + await flush() + } + + expect(store.has('a.example')).toBe(false) + expect(store.has('b.example')).toBe(true) + expect(store.has('c.example')).toBe(true) + }) + + it('落とした host は再び ensureLoaded で取り直せる', async () => { + perf.emojiCacheHosts = 1 + const store = useEmojisStore() + store.ensureLoaded('a.example', vi.fn().mockResolvedValue([emoji('a')])) + await flush() + store.ensureLoaded('b.example', vi.fn().mockResolvedValue([emoji('b')])) + await flush() + expect(store.has('a.example')).toBe(false) + + const refetch = vi.fn().mockResolvedValue([emoji('a')]) + store.ensureLoaded('a.example', refetch) + await flush() + expect(refetch).toHaveBeenCalledTimes(1) + expect(store.resolve('a.example', 'a')).not.toBeNull() + }) + + it('localStorage には emojiPersistPerHost 件までしか保存しない', async () => { + perf.emojiPersistPerHost = 1 + const store = useEmojisStore() + store.ensureLoaded( + HOST, + vi.fn().mockResolvedValue([emoji('a'), emoji('b')]), + ) + await flush() + await vi.advanceTimersByTimeAsync(1_000) + + const raw = localStorage.getItem('emojis_cache') ?? '{}' + const saved = JSON.parse(raw) as { + hosts: Record }> + } + expect(Object.keys(saved.hosts[HOST]?.emojis ?? {})).toEqual(['a']) + // メモリ側は絞らない (永続化は解決の一部を運ぶだけ) + expect(store.resolve(HOST, 'b')).not.toBeNull() + }) + + it('保存済み host が上限を超えていても、復元は上限までで止める', () => { + perf.emojiCacheHosts = 1 + localStorage.setItem( + 'emojis_cache', + JSON.stringify({ + version: 2, + hosts: { + 'a.example': { fetchedAt: 1, emojis: { meow: 'https://a/1.webp' } }, + 'b.example': { fetchedAt: 2, emojis: { meow: 'https://b/1.webp' } }, + }, + }), + ) + const store = useEmojisStore() + + expect(store.has('a.example')).toBe(false) + expect(store.has('b.example')).toBe(true) + }) + }) + it('壊れた host エントリは飛ばし、正常な分だけ復元する', () => { localStorage.setItem( 'emojis_cache', diff --git a/src/stores/emojis.ts b/src/stores/emojis.ts index 6311505a8..9c39359e9 100644 --- a/src/stores/emojis.ts +++ b/src/stores/emojis.ts @@ -24,6 +24,22 @@ interface PersistedCacheV2 { hosts: Record }> } +/** + * host キーの Map を max 件に収める。挿入順に古いものから落とし、落とした + * host を返す。付随する非 reactive な状態も呼び出し側で一緒に捨てるため。 + */ +function capHosts(map: Map, max: number): string[] { + const dropped: string[] = [] + const limit = Math.max(1, max) + while (map.size > limit) { + const oldest = map.keys().next() + if (oldest.done) break + map.delete(oldest.value) + dropped.push(oldest.value) + } + return dropped +} + export const useEmojisStore = defineStore('emojis', () => { const perfStore = usePerformanceStore() @@ -70,16 +86,27 @@ export const useEmojisStore = defineStore('emojis', () => { if (typeof entry.fetchedAt === 'number') fetchedAt.set(host, entry.fetchedAt) } + // 上限を下げたあとの起動で、保存済みの host を丸ごと読み戻さない + for (const gone of capHosts(map, perfStore.get('emojiCacheHosts'))) { + fetchedAt.delete(gone) + } cache.value = map } function persistToStorage() { try { + const perHost = Math.max(1, perfStore.get('emojiPersistPerHost')) const hosts: PersistedCacheV2['hosts'] = {} for (const [host, lookup] of cache.value) { + // localStorage は数 MB で頭打ちになり、超えると書き込みごと失敗して + // オフライン解決が丸ごと効かなくなる。全量ではなく先頭 N 件だけ運ぶ + const entries = Object.entries(lookup) hosts[host] = { fetchedAt: fetchedAt.get(host) ?? Date.now(), - emojis: lookup, + emojis: + entries.length > perHost + ? Object.fromEntries(entries.slice(0, perHost)) + : lookup, } } setStorageJson(STORAGE_KEYS.emojisCache, { @@ -91,30 +118,54 @@ export const useEmojisStore = defineStore('emojis', () => { } } + /** + * 辞書から落ちた host の付随状態を捨てる。これらはすべて host をキーに + * 持つので、辞書だけ上限で刈っても付随状態が残れば同じ速度で増える + */ + function forgetHost(host: string): void { + const timer = refreshTimers.get(host) + if (timer !== undefined) clearTimeout(timer) + refreshTimers.delete(host) + fetchedAt.delete(host) + fetchers.delete(host) + failedHosts.delete(host) + missedNames.delete(host) + unknownNames.delete(host) + lastRefreshAt.delete(host) + } + const { schedule: schedulePersist } = createDebouncedPersist(persistToStorage) // Initialize from localStorage loadFromStorage() function set(host: string, emojis: ServerEmoji[]) { - // Build shortcode→url lookup for resolution (no cap — lightweight Record) + // shortcode→url lookup。ホストあたりの件数は emojiCachePerHost で頭打ちに + // する (#987 — 以前は無制限で、大規模サーバーでは数万エントリになった)。 + // 切り捨てられた絵文字は解決できないが、reportMiss → refresh でも現れない + // ため unknownNames に隔離され、空振りの再取得ループにはならない + const perHost = Math.max(1, perfStore.get('emojiCachePerHost')) const lookup: Record = {} + let count = 0 for (const e of emojis) { + if (count >= perHost) break lookup[e.name] = e.url + count++ } const nextCache = new Map(cache.value) nextCache.set(host, lookup) + // 辞書は連合先の数だけ増える。落とした host の付随状態も一緒に捨てる + for (const gone of capHosts(nextCache, perfStore.get('emojiCacheHosts'))) { + forgetHost(gone) + } cache.value = nextCache fetchedAt.set(host, Date.now()) // emojiList: only keep the most recent hosts to bound memory const nextList = new Map(emojiList.value) nextList.set(host, emojis) - if (nextList.size > perfStore.get('emojiListHosts')) { - const oldest = nextList.keys().next().value - if (oldest !== undefined) nextList.delete(oldest) - } + capHosts(nextList, perfStore.get('emojiListHosts')) emojiList.value = nextList // 辞書に現れた unknown は解放する (再登録された絵文字を拾えるように) diff --git a/src/stores/performance.ts b/src/stores/performance.ts index 3d44bd611..efba707da 100644 --- a/src/stores/performance.ts +++ b/src/stores/performance.ts @@ -15,6 +15,7 @@ import { commands, unwrap } from '@/utils/tauriInvoke' export interface PerformanceConfig { // Emoji cache emojiCachePerHost: number + emojiCacheHosts: number emojiListHosts: number emojiPersistPerHost: number // Notes @@ -25,6 +26,7 @@ export interface PerformanceConfig { chatMessageStoreMax: number // Parse cache mfmCacheMax: number + blurhashCacheMax: number imageProxyCacheMax: number ogpCacheMax: number // Realtime diff --git a/src/stores/performanceData.ts b/src/stores/performanceData.ts index 3efa55be6..0c3ce14bf 100644 --- a/src/stores/performanceData.ts +++ b/src/stores/performanceData.ts @@ -28,6 +28,16 @@ export const FIELD_META: Record = { description: 'ホストあたりのカスタム絵文字キャッシュ数。大規模サーバーは5000+の絵文字を持つ', }, + emojiCacheHosts: { + min: 4, + max: 200, + step: 4, + unit: 'ホスト', + category: 'emoji', + label: '辞書保持ホスト数', + description: + '絵文字を解決するための辞書を保持するホスト数。連合先が増えるほど育つので上限で頭を打たせる', + }, emojiListHosts: { min: 1, max: 10, @@ -93,6 +103,16 @@ export const FIELD_META: Record = { label: 'MFMキャッシュ', description: 'MFMパース結果のLRUキャッシュ上限', }, + blurhashCacheMax: { + min: 64, + max: 2048, + step: 64, + unit: '件', + category: 'cache', + label: 'blurhashキャッシュ', + description: + '画像ロード前のプレースホルダ (blurhash → data URL) のLRUキャッシュ上限', + }, imageProxyCacheMax: { min: 32, max: 2048, @@ -509,6 +529,7 @@ export const CATEGORY_LABELS: Record< /** Slider endpoint: t=0 (省メモリ) */ export const SLIDER_LOW: PerformanceConfig = { emojiCachePerHost: 2000, + emojiCacheHosts: 8, emojiListHosts: 2, emojiPersistPerHost: 200, noteStoreMax: 800, @@ -516,6 +537,7 @@ export const SLIDER_LOW: PerformanceConfig = { maxNotifications: 100, chatMessageStoreMax: 2000, mfmCacheMax: 128, + blurhashCacheMax: 128, imageProxyCacheMax: 64, ogpCacheMax: 128, noteCaptureMax: 40, @@ -563,6 +585,7 @@ export const SLIDER_LOW: PerformanceConfig = { /** Slider endpoint: t=1 (高パフォーマンス) */ export const SLIDER_HIGH: PerformanceConfig = { emojiCachePerHost: 7000, + emojiCacheHosts: 64, emojiListHosts: 6, emojiPersistPerHost: 700, noteStoreMax: 3000, @@ -570,6 +593,7 @@ export const SLIDER_HIGH: PerformanceConfig = { maxNotifications: 500, chatMessageStoreMax: 10000, mfmCacheMax: 512, + blurhashCacheMax: 512, imageProxyCacheMax: 512, ogpCacheMax: 512, noteCaptureMax: 150, diff --git a/src/utils/blurhashDataUrl.ts b/src/utils/blurhashDataUrl.ts index 714960922..9d2a72b83 100644 --- a/src/utils/blurhashDataUrl.ts +++ b/src/utils/blurhashDataUrl.ts @@ -1,14 +1,23 @@ import { decode } from 'blurhash' +import { createBoundedCache } from '@/services/boundedCache' +import { usePerformanceStore } from '@/stores/performance' -const cache = new Map() +// 長時間スクロールで見た blurhash がすべて残り続けていた (#987)。 +// data URL は 1 件あたり数 KB あるので、素の Map では単調増加する +const cache = createBoundedCache(() => { + try { + return usePerformanceStore().get('blurhashCacheMax') + } catch { + return 256 + } +}) /** * blurhash を data URL (32x32 PNG) にデコードする。 * 画像ロード完了までのプレースホルダ用。結果はプロセス内でキャッシュする。 */ export function blurhashToDataUrl(hash: string): string | null { - const cached = cache.get(hash) - if (cached !== undefined) return cached + if (cache.has(hash)) return cache.get(hash) ?? null let result: string | null = null try { From ad6c4af1dad177c49f97efc54d41fb60d2103bcb Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 7 Aug 2026 12:18:12 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(perf):=20negative=20cache=20=E3=81=AE?= =?UTF-8?q?=E6=9C=9F=E9=99=90=E5=88=87=E3=82=8C=E3=82=A8=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=83=AA=E3=82=92=E6=8E=83=E9=99=A4=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取得に失敗した URL の記録は、期限判定が読み取り時にしか無く、期限切れの エントリを消す経路が存在しなかった。失敗した URL の数だけ単調増加する。 記録のたびに期限切れを掃き、それでも収まらなければ古い順に落として上限で 頭を打たせる。4xx は 24 時間保持するので、掃除だけでは頭打ちにならない。 Refs #987 Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/image_cache.rs | 76 +++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/image_cache.rs b/src-tauri/src/image_cache.rs index 18b580777..f4b70bc62 100644 --- a/src-tauri/src/image_cache.rs +++ b/src-tauri/src/image_cache.rs @@ -37,6 +37,34 @@ fn classify_http_failure(status: u16) -> (Duration, bool) { } } +/// 失敗した URL の記録。TTL つき。 +type NegativeCache = HashMap; + +/// negative cache の上限。4xx は 24h 保持するので、期限切れの掃除だけでは +/// 頭打ちにならない (壊れた絵文字を大量に持つサーバーを踏み続けたときなど) +const NEGATIVE_CACHE_MAX: usize = 1024; + +/// negative cache への記録。以前は期限判定が読み取り時にしか無く、期限切れ +/// エントリを消す経路が存在しなかったため、失敗した URL の数だけ単調増加 +/// していた (#987)。記録のたびに期限切れを掃き、それでも収まらなければ +/// 古い順に落として上限で頭を打たせる。 +fn record_negative(neg: &mut NegativeCache, hash: String, ttl: Duration) { + neg.insert(hash, (Instant::now(), ttl)); + neg.retain(|_, (failed_at, ttl)| failed_at.elapsed() < *ttl); + if neg.len() <= NEGATIVE_CACHE_MAX { + return; + } + let excess = neg.len() - NEGATIVE_CACHE_MAX; + let mut by_age: Vec<(String, Instant)> = neg + .iter() + .map(|(key, (failed_at, _))| (key.clone(), *failed_at)) + .collect(); + by_age.sort_by_key(|(_, failed_at)| *failed_at); + for (key, _) in by_age.into_iter().take(excess) { + neg.remove(&key); + } +} + // Fallback defaults (used when perf_config is not available, e.g. in tests) const DEFAULT_MEMORY_CACHE_MAX_ITEM: usize = 256 * 1024; const DEFAULT_MEMORY_CACHE_MAX_TOTAL: usize = 32 * 1024 * 1024; @@ -91,7 +119,7 @@ pub struct ImageCache { inflight: Arc>, http_client: reqwest::Client, fetch_limiter: Arc>, - negative_cache: Arc>>, + negative_cache: Arc>, mem_cache: Arc>, host_circuits: Arc>>, perf: SharedPerfConfig, @@ -468,7 +496,7 @@ impl ImageCache { if error { let mut neg = negative_cache.write().await; - neg.insert(hash_clone.clone(), (Instant::now(), NEGATIVE_TTL_NETWORK)); + record_negative(&mut neg, hash_clone.clone(), NEGATIVE_TTL_NETWORK); tx.send(Some(Err("Stream failed".to_string()))).ok(); // Update host circuit breaker on stream failure if !url_host.is_empty() { @@ -578,9 +606,7 @@ impl ImageCache { let tx_msg = msg.clone(); tx.send(Some(Err(tx_msg))).ok(); tokio::spawn(async move { - neg.write() - .await - .insert(hash.clone(), (Instant::now(), ttl)); + record_negative(&mut *neg.write().await, hash.clone(), ttl); inflight.lock().await.remove(&hash); // Update host circuit breaker (network/5xx/429 のみ — 分類は // classify_http_failure 参照) @@ -924,6 +950,46 @@ mod tests { assert!(!cache.is_negative_cached(url).await); } + /// 記録のたびに期限切れを掃く。以前は消す経路が無く、失敗した URL の数 + /// だけ単調増加していた (#987) + #[tokio::test] + async fn record_negative_drops_expired_entries() { + let mut neg = NegativeCache::new(); + neg.insert( + "expired".to_string(), + ( + Instant::now() - Duration::from_secs(10), + Duration::from_secs(5), + ), + ); + neg.insert( + "alive".to_string(), + (Instant::now(), Duration::from_secs(600)), + ); + + record_negative(&mut neg, "new".to_string(), Duration::from_secs(60)); + + assert!(!neg.contains_key("expired")); + assert!(neg.contains_key("alive")); + assert!(neg.contains_key("new")); + } + + /// 期限切れが一つも無くても上限で頭打ちにする (4xx は 24h 保持するので + /// 掃除だけでは止まらない) + #[tokio::test] + async fn record_negative_is_capped() { + let mut neg = NegativeCache::new(); + for i in 0..(NEGATIVE_CACHE_MAX + 50) { + record_negative(&mut neg, format!("url-{i}"), NEGATIVE_TTL_CLIENT); + } + + assert_eq!(neg.len(), NEGATIVE_CACHE_MAX); + // 落とすのは古い順。最後に入れたものは残っている + let newest = format!("url-{}", NEGATIVE_CACHE_MAX + 49); + assert!(neg.contains_key(&newest)); + assert!(!neg.contains_key("url-0")); + } + /// SSRF 防御は commands::http の validate_external_host に一元化。 /// IP literal だけでなく localhost / 予約 TLD などの hostname も /// ネットワークに出る前に弾く (ローカル HTTP API からも叩ける面のため) From 20870ce86f4195a1946b2362948728568f6283fb Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 7 Aug 2026 12:20:30 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test(lint):=20=E6=AD=BB=E3=81=AB=E3=83=8E?= =?UTF-8?q?=E3=83=96=E3=81=A8=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=82=B9=E3=82=B3=E3=83=BC=E3=83=97=E3=81=AE=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A5=E3=82=92=E6=A9=9F=E6=A2=B0=E6=A4=9C?= =?UTF-8?q?=E6=9F=BB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 個別に直して回るのではなく、同じ事故が再発しない形にする (#895 と同じ思想 — 守るかどうかを人間の注意力に委ねない)。 - パフォーマンス設定の全ノブについて、実装のどこかから読まれているかを 検査する。定義・既定値・設定 UI まで揃っているのに参照されていない ノブは、ユーザーから見ると効いているように見えて何も起きない - モジュールスコープの空 Map/Set を全数棚卸しし、なぜ有界なのかを添えた 表と突き合わせる。新しく置かれたらここで落ちて、共通基盤に載せるか 理由を書くかを迫る。表に実在しないエントリが残っていても落とす Refs #987 Co-Authored-By: Claude Opus 4.8 --- tests/lint/moduleCaches.test.ts | 140 ++++++++++++++++++++++++++++ tests/lint/perfConfigWiring.test.ts | 86 +++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 tests/lint/moduleCaches.test.ts create mode 100644 tests/lint/perfConfigWiring.test.ts diff --git a/tests/lint/moduleCaches.test.ts b/tests/lint/moduleCaches.test.ts new file mode 100644 index 000000000..d0554529a --- /dev/null +++ b/tests/lint/moduleCaches.test.ts @@ -0,0 +1,140 @@ +// 「キャッシュには必ず上限」を機械検査に落とす (#987)。 +// +// 長時間起動でメモリが単調増加した経路は、どれも同じ形で入っていた: +// モジュールスコープに空の Map を置き、delete を書き忘れる。レビューで +// 気づける保証は無いので、新しく置かれた時点でここが落ちるようにする。 +// +// 検査対象は .ts の **空で初期化される** モジュールスコープの Map/Set だけ。 +// - リテラルで初期化されるもの (`new Set(['a', 'b'])`) は固定集合で育たない +// - .vue のトップレベルはコンポーネント寿命なのでアンマウントで消える +// (ただし常時開かれるカラムは実質モジュールスコープと同じ寿命になる。 +// DeckNotificationColumn の reaction URL キャッシュはそれで無制限に +// 育っていた — 検査に頼らず、面の寿命を見て判断すること) +// +// 新しく足したものがここで落ちたら、まず `createBoundedCache` に載せられ +// ないかを検討する。載らないなら、なぜ有界なのかを一言添えて ALLOWED に +// 足す。「なんとなく大丈夫」で足さない。 + +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const ROOT = resolve(import.meta.dirname, '../..') +const SRC = join(ROOT, 'src') + +/** + * 棚卸し済みのモジュールスコープ Map/Set。値はなぜ無制限に育たないか。 + * + * - bounded : 件数の上限がある + * - lifecycle: 登録/解除・完了・発火で必ず消える + * - keyed : キー空間が有限 (アカウント・カラム・ホスト・種別など) + */ +const ALLOWED: Record = { + // bounded — 上限つき + 'src/utils/dedup.ts:responseCache': 'bounded: TTL + 閾値超過で期限切れを掃除', + 'src/utils/mediaProxy.ts:proxyUrlCache': 'bounded: imageProxyCacheMax', + 'src/utils/mfm.ts:parseCache': 'bounded: mfmCacheMax', + 'src/utils/mfm.ts:parseCacheMd': 'bounded: mfmCacheMax', + 'src/utils/formatTime.ts:parsedCache': 'bounded: 分が変わるたび clear', + 'src/composables/useNoteSound.ts:bufferCache': 'bounded: soundCacheMax', + 'src/composables/useNoteSound.ts:audioElCache': 'bounded: soundCacheMax', + 'src/composables/useOgpPreview.ts:ogpCache': 'bounded: ogpCacheMax', + 'src/composables/useImagePrefetch.ts:prefetchedUrls': + 'bounded: prefetchTrackedMax', + 'src/composables/useSnapshotStore.ts:store': + 'bounded: TTL 超過で削除 + カラム破棄で削除', + 'src/services/entityResolution.ts:resolutionCache': + 'bounded: MAX_CACHE_ENTRIES', + + // lifecycle — 完了・解除・発火で消える + 'src/utils/dedup.ts:inflight': 'lifecycle: finally で削除', + 'src/utils/highlight.ts:pendingLangs': 'lifecycle: ロード完了で削除', + 'src/composables/useOgpPreview.ts:pendingRequests': + 'lifecycle: 取得完了で削除', + 'src/utils/desktopNotification.ts:pendingContexts': + 'lifecycle: 通知のクリック/クローズで削除', + 'src/utils/startupTrace.ts:marks': 'lifecycle: 起動時の計測点のみ', + 'src/stores/toast.ts:timers': 'lifecycle: 発火・破棄で削除', + 'src/composables/useMemos.ts:writeTimers': 'lifecycle: 書き込み完了で削除', + 'src/composables/usePipWindow.ts:pipWindows': + 'lifecycle: ウィンドウを閉じると削除', + 'src/composables/usePipWindow.ts:creatingSet': 'lifecycle: 生成完了で削除', + 'src/composables/useDeckWindow.ts:openWindows': + 'lifecycle: ウィンドウを閉じると削除', + 'src/adapters/factory.ts:adapterPending': 'lifecycle: 初期化完了で削除', + 'src/aiscript/plugin-api.ts:pluginContexts': + 'lifecycle: プラグイン停止で削除', + 'src/aiscript/plugin-api.ts:pluginAccountContext': + 'lifecycle: プラグイン停止で削除', + 'src/aiscript/plugin-api.ts:pluginNdContexts': + 'lifecycle: プラグイン停止で削除', + 'src/aiscript/plugin-api.ts:pluginRunLoggers': + 'lifecycle: プラグイン停止で削除', + 'src/aiscript/plugin-api.ts:pluginContextQueues': + 'lifecycle: プラグイン停止で削除', + 'src/aiscript/events.ts:emitterHandlers': 'lifecycle: 購読解除で削除', + 'src/aiscript/events.ts:noteHandlers': 'lifecycle: 購読解除で削除', + 'src/aiscript/events.ts:notificationHandlers': 'lifecycle: 購読解除で削除', + 'src/aiscript/lsp/worker.ts:documents': 'lifecycle: エディタを閉じると削除', + 'src/aiscript/lsp/worker.ts:diagnosticTimers': 'lifecycle: 発火で削除', + 'src/core/queryDeltaBus.ts:handlers': 'lifecycle: 購読解除で削除', + + // keyed — キー空間が有限 + 'src/adapters/registry.ts:registry': 'keyed: サーバーソフトウェアの種類', + 'src/adapters/factory.ts:adapterCache': 'keyed: アカウント', + 'src/capabilities/registry.ts:capabilities': 'keyed: 登録済み capability', + 'src/commands/taskCommands.ts:registeredIds': 'keyed: 登録済みタスク', + 'src/core/queryRegistry.ts:entriesByQueryId': 'keyed: 登録済みクエリ', + 'src/composables/useAds.ts:adsCache': 'keyed: アカウント', + 'src/composables/useLoginPrompt.ts:reloginPromptShownAt': 'keyed: アカウント', + 'src/composables/useUnreadCounter.ts:sharedStates': 'keyed: カラム', + 'src/composables/useNoteSound.ts:failedHosts': 'keyed: ホスト', + 'src/utils/customTimelines.ts:customTlMemCache': 'keyed: アカウント', + 'src/utils/customTimelines.ts:availableTlCache': 'keyed: アカウント', + 'src/utils/customTimelines.ts:runtimeDenied': 'keyed: アカウント', + 'src/utils/customTimelines.ts:filterKeyCache': 'keyed: タイムライン種別', + 'src/aiscript/codemirror/completions.ts:nsMemberCompletions': + 'keyed: AiScript の名前空間', + 'src/services/entityResolution.ts:NO_LIVE_KEYS': 'keyed: 常に空の番人', +} + +function collect(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return collect(path) + return entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts') + ? [path] + : [] + }) +} + +/** + * 行頭 (= モジュールスコープ) の、空で初期化される Map/Set。 + * 型引数が複数行に折り返されることがあるので改行も跨いで見る + */ +const EMPTY_COLLECTION = + /^(?:const|let) ([A-Za-z_$][\w$]*)(?:\s*:[^=]+?)? = new (?:Map|Set|WeakMap|WeakSet)(?:<[\s\S]*?>)?\(\)/gm + +const found = collect(SRC).flatMap((file) => { + const path = relative(ROOT, file) + const text = readFileSync(file, 'utf8') + return [...text.matchAll(EMPTY_COLLECTION)].map((m) => `${path}:${m[1]}`) +}) + +describe('モジュールスコープのキャッシュ (#987)', () => { + it('検査対象を取りこぼしていない', () => { + expect(found.length).toBeGreaterThan(30) + }) + + it('すべて棚卸し済み (上限か、消える保証があること)', () => { + const unlisted = found.filter((entry) => !(entry in ALLOWED)).sort() + expect(unlisted).toEqual([]) + }) + + it('棚卸し表に実在しないエントリが残っていない', () => { + const stale = Object.keys(ALLOWED) + .filter((entry) => !found.includes(entry)) + .sort() + expect(stale).toEqual([]) + }) +}) diff --git a/tests/lint/perfConfigWiring.test.ts b/tests/lint/perfConfigWiring.test.ts new file mode 100644 index 000000000..307db1e4e --- /dev/null +++ b/tests/lint/perfConfigWiring.test.ts @@ -0,0 +1,86 @@ +// パフォーマンス設定の「死にノブ」検査 (#987)。 +// +// 定義・既定値・設定 UI まで揃っているのに、実装のどこからも参照されていない +// ノブが存在した (絵文字キャッシュ関連の 2 つ)。ユーザーから見ると効いている +// ように見えて何も起きない。同じ事故は #921 の max_concurrent_fetches でも +// 起きている (生成時の定数で固定され、設定値が一生効かなかった)。 +// +// 設定を足すときに「使う側の配線を忘れる」のは注意力の問題なので、機械検査に +// 落とす (#895 と同じ思想)。 + +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import DEFAULTS from '@/defaults/performance.json5' + +const ROOT = resolve(import.meta.dirname, '../..') +const SRC = join(ROOT, 'src') + +/** 設定の定義そのもの。ここでの出現は「配線」に数えない */ +const DEFINITION_FILES = [ + join(SRC, 'stores', 'performanceData.ts'), + join(SRC, 'defaults', 'performance.json5'), +] + +function collect(dir: string, ext: string[]): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return collect(path, ext) + return ext.some((e) => entry.name.endsWith(e)) ? [path] : [] + }) +} + +const keys = Object.keys(DEFAULTS as Record) + +const STORE = join(SRC, 'stores', 'performance.ts') + +const consumerFiles = collect(SRC, ['.ts', '.vue']).filter( + (f) => + !f.endsWith('.test.ts') && + !f.endsWith('.dom.test.ts') && + !DEFINITION_FILES.includes(f), +) + +/** + * store 本体は「キーの型定義」と「Rust / CSS / telemetry への push」が同居する。 + * 型定義ブロックだけを落とし、残りは配線として数える。 + */ +function stripKeyDeclarations(text: string): string { + const from = text.indexOf('export interface PerformanceConfig {') + if (from < 0) return text + const to = text.indexOf('\n}', from) + return to < 0 ? text : text.slice(0, from) + text.slice(to) +} + +const consumerSources = consumerFiles.map((f) => { + const text = readFileSync(f, 'utf8') + return { + path: relative(ROOT, f), + text: f === STORE ? stripKeyDeclarations(text) : text, + } +}) + +/** + * そのノブを読んでいるファイル (相対パス)。 + * 消費側は `get('key')` のリテラル参照か、store 内の `c.key` / + * `config.value.key` のプロパティ参照のどちらかで読む。 + */ +function consumersOf(key: string): string[] { + const ref = new RegExp(`['"\`]${key}['"\`]|\\.${key}\\b`) + return consumerSources.filter((s) => ref.test(s.text)).map((s) => s.path) +} + +describe('パフォーマンス設定の配線 (#987)', () => { + it('検査対象のノブを取りこぼしていない', () => { + expect(keys.length).toBeGreaterThan(40) + // 型定義ブロックの除去が効いていること (効いていないと全ノブが配線済みに見える) + const store = consumerSources.find((s) => s.path === relative(ROOT, STORE)) + expect(store?.text).not.toContain('export interface PerformanceConfig {') + expect(store?.text).toContain('frameTelemetry.start(') + }) + + it('すべてのノブが実装から参照されている (死にノブが無い)', () => { + const dead = keys.filter((key) => consumersOf(key).length === 0) + expect(dead).toEqual([]) + }) +}) From d0faa0800a72d52ab15b5f1861c85967fdbca9b1 Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 7 Aug 2026 13:12:41 +0900 Subject: [PATCH 7/8] chore: bump version to 1.42.7 --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/openapi.json | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1780f9af0..18fe94219 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "notedeck", "description": "Misskey Pro — integrated deck environment (IDE) for Misskey power users", "private": true, - "version": "1.42.6", + "version": "1.42.7", "type": "module", "packageManager": "pnpm@11.18.0", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4c9effc07..b6858dfce 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3203,7 +3203,7 @@ dependencies = [ [[package]] name = "notedeck" -version = "1.42.6" +version = "1.42.7" dependencies = [ "async-trait", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 069080db5..d6703a668 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "notedeck" -version = "1.42.6" +version = "1.42.7" description = "Misskey Pro — integrated deck environment (IDE) for Misskey power users" edition = "2021" license = "AGPL-3.0-only" diff --git a/src-tauri/openapi.json b/src-tauri/openapi.json index e877739ae..68e0f7176 100644 --- a/src-tauri/openapi.json +++ b/src-tauri/openapi.json @@ -6,7 +6,7 @@ "license": { "name": "MIT" }, - "version": "1.42.6" + "version": "1.42.7" }, "paths": { "/api": { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e7b06660b..be58c7a51 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", "productName": "NoteDeck", - "version": "1.42.6", + "version": "1.42.7", "identifier": "com.notedeck.desktop", "build": { "frontendDist": "../dist", From 7777056ebd3be5c1d14a02eeb54aecbaaad80948 Mon Sep 17 00:00:00 2001 From: hitalin Date: Fri, 7 Aug 2026 13:24:52 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix(perf):=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AE=E3=82=AD=E3=83=A3=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A5=E4=B8=8A=E9=99=90=E3=83=90=E3=82=A4=E3=83=91?= =?UTF-8?q?=E3=82=B9=203=20=E7=B5=8C=E8=B7=AF=E3=82=92=E5=A1=9E=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit レビュー (#1007) の指摘対応: - boundedCache: 非有限の上限 (NaN / Infinity) を 1 に丸める。 performance.json5 は手編集可能で JSON5 は NaN/Infinity リテラルを 許すため、Math.max(1, NaN) 経由で eviction が無効化されていた - emojis: localStorage 復元と emojiAdded push 反映が emojiCachePerHost を 素通りしていた経路に上限を適用 - emojis: refresh() のフェッチ完了が host 追い出しと交差したとき、 追い出し済み host を復活させない Co-Authored-By: Claude Fable 5 --- src/services/boundedCache.test.ts | 17 +++++++++++++++++ src/services/boundedCache.ts | 10 ++++++++-- src/stores/emojis.ts | 24 +++++++++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/services/boundedCache.test.ts b/src/services/boundedCache.test.ts index c5f943d26..48bd66a98 100644 --- a/src/services/boundedCache.test.ts +++ b/src/services/boundedCache.test.ts @@ -25,6 +25,23 @@ describe('createBoundedCache', () => { expect(cache.has('b')).toBe(false) }) + it('非有限の上限 (NaN / Infinity) は 1 に丸めて無制限化を防ぐ', () => { + // performance.json5 は手編集可能で、JSON5 は NaN / Infinity をリテラルと + // して許す。Math.max(1, NaN) は NaN になり eviction 比較が常に false に + // なる (= 無制限キャッシュ復活) ため、非有限は最小値に落とす + const nan = createBoundedCache(() => Number.NaN) + nan.set('a', 1) + nan.set('b', 2) + expect(nan.size).toBe(1) + + const inf = createBoundedCache( + () => Number.POSITIVE_INFINITY, + ) + inf.set('a', 1) + inf.set('b', 2) + expect(inf.size).toBe(1) + }) + it('同じキーの上書きはサイズを増やさない', () => { const cache = createBoundedCache(2) cache.set('a', 1) diff --git a/src/services/boundedCache.ts b/src/services/boundedCache.ts index efc29a586..83fa1696a 100644 --- a/src/services/boundedCache.ts +++ b/src/services/boundedCache.ts @@ -31,8 +31,14 @@ export function createBoundedCache( ): BoundedCache { const map = new Map() // 0 以下は「キャッシュ無効」ではなく 1 に丸める。設定ミスで毎回の - // 再計算に落ちるより、最小限でも効いているほうが害が小さい - const maxOf = () => Math.max(1, typeof max === 'function' ? max() : max) + // 再計算に落ちるより、最小限でも効いているほうが害が小さい。 + // 非有限 (NaN / Infinity — JSON5 の手編集で到達可能) も 1 に落とす: + // Math.max(1, NaN) は NaN で eviction 比較が常に false になり、 + // 「必ず上限」の不変条件 (#987) が静かに破れる + const maxOf = () => { + const value = typeof max === 'function' ? max() : max + return Number.isFinite(value) ? Math.max(1, value) : 1 + } return { get(key) { diff --git a/src/stores/emojis.ts b/src/stores/emojis.ts index 9c39359e9..f523b4b12 100644 --- a/src/stores/emojis.ts +++ b/src/stores/emojis.ts @@ -80,9 +80,18 @@ export const useEmojisStore = defineStore('emojis', () => { // (undefined を混ぜると has() が true を返し、以後取得を skip してしまう) if (typeof obj.hosts !== 'object' || obj.hosts === null) return const map = new Map>() + const perHost = Math.max(1, perfStore.get('emojiCachePerHost')) for (const [host, entry] of Object.entries(obj.hosts)) { if (typeof entry?.emojis !== 'object' || entry.emojis === null) continue - map.set(host, entry.emojis) + // 保存側の上限 (emojiPersistPerHost) と読み側の上限は独立に変えられる。 + // 上限を下げたあとの起動や旧形式の大きな保存物をそのまま抱えない + const entries = Object.entries(entry.emojis) + map.set( + host, + entries.length > perHost + ? Object.fromEntries(entries.slice(0, perHost)) + : entry.emojis, + ) if (typeof entry.fetchedAt === 'number') fetchedAt.set(host, entry.fetchedAt) } @@ -262,6 +271,10 @@ export const useEmojisStore = defineStore('emojis', () => { missedNames.delete(host) try { const emojis = await fetcher() + // フェッチ中に host が上限で追い出されていたら結果を捨てる。 + // ここで set() すると追い出し済み host が復活し、より新しい host を + // 逆に押し出してしまう (forgetHost は fetchers も消すのでそれで判る) + if (!fetchers.has(host)) return set(host, emojis) // 取り直しても存在しなかった名前は隔離する if (missed) { @@ -310,6 +323,15 @@ export const useEmojisStore = defineStore('emojis', () => { // 再登録された絵文字を拾えるよう unknown から解放する unknown?.delete(e.name) } + // emojiAdded の積み重ねで set() の上限 (emojiCachePerHost) を素通り + // させない。追加分 (末尾) を残し、古いキー (先頭) から削る + const perHost = Math.max(1, perfStore.get('emojiCachePerHost')) + const names = Object.keys(nextLookup) + if (names.length > perHost) { + for (const name of names.slice(0, names.length - perHost)) { + delete nextLookup[name] + } + } } const nextCache = new Map(cache.value) nextCache.set(host, nextLookup)