Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 7 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` のみ。

Comment on lines +209 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace concrete lifecycle counts with canonical references or add documented exceptions.

  • ARCHITECTURE.md#L209-L210: Refer to the streaming implementation for the main-channel sharing constraint, or add the required preceding lint-disable reason.
  • ARCHITECTURE.md#L395-L395: Remove the concrete IPC count. Refer to the command implementation if the detail is required.
  • ARCHITECTURE.md#L429-L430: Remove the concrete mapping cardinality. Refer to QueryRuntime as the canonical routing behavior.
  • ARCHITECTURE.md#L720-L720: Describe the lifecycle condition without a concrete observer count, or add the required exception.

As per coding guidelines, root Markdown must not contain future-stale concrete numbers and must refer to the canonical file.

📍 Affects 1 file
  • ARCHITECTURE.md#L209-L210 (this comment)
  • ARCHITECTURE.md#L395-L395
  • ARCHITECTURE.md#L429-L430
  • ARCHITECTURE.md#L720-L720
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ARCHITECTURE.md` around lines 209 - 210, Remove future-stale concrete
lifecycle counts from ARCHITECTURE.md: at lines 209-210, refer to the streaming
implementation for main-channel sharing or document the required lint exception;
at line 395, remove the concrete IPC count and reference the command
implementation if needed; at lines 429-430, remove the mapping cardinality and
identify QueryRuntime as the canonical routing behavior; at line 720, describe
the lifecycle condition without a concrete observer count or add the required
exception.

Source: Coding guidelines

#### A-4c. Reaction freshness guarantees

**場所**: `useNoteCapture` + `noteStore.applyUpdate`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 を登録している。
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[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"
[dependencies]
notecli = { git = "https://github.com/notedeck-dev/notecli", rev = "203d9d80dc16bd53646d24a2abcd7357dd598532", 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"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"license": {
"name": "MIT"
},
"version": "1.42.6"
"version": "1.42.7"
},
"paths": {
"/api": {
Expand Down
76 changes: 71 additions & 5 deletions src-tauri/src/image_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ fn classify_http_failure(status: u16) -> (Duration, bool) {
}
}

/// 失敗した URL の記録。TTL つき。
type NegativeCache = HashMap<String, (Instant, Duration)>;

/// 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;
Expand Down Expand Up @@ -91,7 +119,7 @@ pub struct ImageCache {
inflight: Arc<Mutex<InflightMap>>,
http_client: reqwest::Client,
fetch_limiter: Arc<Mutex<FetchLimiter>>,
negative_cache: Arc<RwLock<HashMap<String, (Instant, Duration)>>>,
negative_cache: Arc<RwLock<NegativeCache>>,
mem_cache: Arc<RwLock<MemCacheState>>,
host_circuits: Arc<RwLock<HashMap<String, HostCircuitState>>>,
perf: SharedPerfConfig,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 参照)
Expand Down Expand Up @@ -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 からも叩ける面のため)
Expand Down
Loading
Loading