Repository: EEWBot/eew-renderer
Pull request #47: feat/optimize-response-and-caching
Author: login: yanorei32
Created: 2025-05-07T12:04:02Z | Merged: 2025-05-07T14:52:10Z



Diff:
diff --git a/Cargo.lock b/Cargo.lock
index e7ca3b4..f74393a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -204,6 +204,17 @@ dependencies = [
  "shapefile",
 ]
 
+[[package]]
+name = "async-lock"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "atomic-waker"
 version = "1.1.2"
@@ -603,6 +614,15 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
 [[package]]
 name = "crossbeam-deque"
 version = "0.8.6"
@@ -753,6 +773,27 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "event-listener"
+version = "5.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "fastrand"
 version = "2.3.0"
@@ -853,6 +894,17 @@ version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
 
+[[package]]
+name = "futures-macro"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
+]
+
 [[package]]
 name = "futures-task"
 version = "0.3.31"
@@ -866,9 +918,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
 dependencies = [
  "futures-core",
+ "futures-macro",
  "futures-task",
  "pin-project-lite",
  "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "generator"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc6bd114ceda131d3b1d665eba35788690ad37f5916457286b32ab6fd3c438dd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "log",
+ "rustversion",
+ "windows",
 ]
 
 [[package]]
@@ -1157,6 +1224,12 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
 
+[[package]]
+name = "humantime"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f"
+
 [[package]]
 name = "hyper"
 version = "1.6.0"
@@ -1248,7 +1321,7 @@ dependencies = [
  "js-sys",
  "log",
  "wasm-bindgen",
- "windows-core",
+ "windows-core 0.61.0",
 ]
 
 [[package]]
@@ -1422,12 +1495,44 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
 
+[[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
 [[package]]
 name = "log"
 version = "0.4.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
 
+[[package]]
+name = "loom"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "matchers"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
+dependencies = [
+ "regex-automata 0.1.10",
+]
+
 [[package]]
 name = "matchit"
 version = "0.8.4"
@@ -1485,6 +1590,28 @@ dependencies = [
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "moka"
+version = "0.12.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926"
+dependencies = [
+ "async-lock",
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "event-listener",
+ "futures-util",
+ "loom",
+ "parking_lot",
+ "portable-atomic",
+ "rustc_version",
+ "smallvec",
+ "tagptr",
+ "thiserror",
+ "uuid",
+]
+
 [[package]]
 name = "multimap"
 version = "0.10.0"
@@ -1838,6 +1965,35 @@ dependencies = [
  "ttf-parser 0.25.1",
 ]
 
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.11",
+ "smallvec",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "parse-zoneinfo"
 version = "0.3.1"
@@ -1981,6 +2137,12 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "portable-atomic"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
+
 [[package]]
 name = "powerfmt"
 version = "0.2.0"
@@ -2196,8 +2358,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
 dependencies = [
  "aho-corasick",
  "memchr",
- "regex-automata",
- "regex-syntax",
+ "regex-automata 0.4.9",
+ "regex-syntax 0.8.5",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
+dependencies = [
+ "regex-syntax 0.6.29",
 ]
 
 [[package]]
@@ -2208,9 +2379,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
 dependencies = [
  "aho-corasick",
  "memchr",
- "regex-syntax",
+ "regex-syntax 0.8.5",
 ]
 
+[[package]]
+name = "regex-syntax"
+version = "0.6.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
+
 [[package]]
 name = "regex-syntax"
 version = "0.8.5"
@@ -2234,8 +2411,10 @@ dependencies = [
  "glium",
  "glutin-winit",
  "hmac",
+ "humantime",
  "image",
  "justnames",
+ "moka",
  "prost",
  "prost-build",
  "rand 0.9.1",
@@ -2301,6 +2480,15 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
 
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
 [[package]]
 name = "rustix"
 version = "0.38.44"
@@ -2369,6 +2557,12 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
 
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
 [[package]]
 name = "sctk-adwaita"
 version = "0.10.1"
@@ -2382,6 +2576,12 @@ dependencies = [
  "tiny-skia",
 ]
 
+[[package]]
+name = "semver"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
+
 [[package]]
 name = "serde"
 version = "1.0.219"
@@ -2629,6 +2829,12 @@ version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
 
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
 [[package]]
 name = "tempfile"
 version = "3.19.1"
@@ -2837,10 +3043,14 @@ version = "0.3.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008"
 dependencies = [
+ "matchers",
  "nu-ansi-term",
+ "once_cell",
+ "regex",
  "sharded-slab",
  "smallvec",
  "thread_local",
+ "tracing",
  "tracing-core",
  "tracing-log",
 ]
@@ -2887,6 +3097,15 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
 
+[[package]]
+name = "uuid"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
+dependencies = [
+ "getrandom 0.3.2",
+]
+
 [[package]]
 name = "valuable"
 version = "0.1.1"
@@ -3155,17 +3374,51 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
+[[package]]
+name = "windows"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+dependencies = [
+ "windows-core 0.58.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+dependencies = [
+ "windows-implement 0.58.0",
+ "windows-interface 0.58.0",
+ "windows-result 0.2.0",
+ "windows-strings 0.1.0",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-core"
 version = "0.61.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
 dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.0",
+ "windows-interface 0.59.1",
  "windows-link",
- "windows-result",
- "windows-strings",
+ "windows-result 0.3.2",
+ "windows-strings 0.4.0",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
 ]
 
 [[package]]
@@ -3179,6 +3432,17 @@ dependencies = [
  "syn 2.0.100",
 ]
 
+[[package]]
+name = "windows-interface"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.100",
+]
+
 [[package]]
 name = "windows-interface"
 version = "0.59.1"
@@ -3196,6 +3460,15 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
 
+[[package]]
+name = "windows-result"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-result"
 version = "0.3.2"
@@ -3205,6 +3478,16 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "windows-strings"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result 0.2.0",
+ "windows-targets 0.52.6",
+]
+
 [[package]]
 name = "windows-strings"
 version = "0.4.0"
diff --git a/renderer/Cargo.toml b/renderer/Cargo.toml
index 7042437..a106ddd 100644
--- a/renderer/Cargo.toml
+++ b/renderer/Cargo.toml
@@ -6,10 +6,13 @@ edition = "2021"
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [dependencies]
+anyhow = "1.0.98"
 array-const-fn-init = "0.1.1"
 axum = "0.8.0"
 base65536 = "1.0.1"
 bytes = "1.10.1"
+chrono = "0.4.40"
+chrono-tz = "0.10.3"
 clap = { version = "4.5.36", features = ["derive", "env"] }
 const_soft_float = "0.1.4"
 enum-map = "2.7.3"
@@ -17,23 +20,22 @@ glium = "0.36.0"
 glutin-winit = "0.5.0"
 hmac = "0.12.1"
 image = { version = "0.25.1", default-features = false, features = ["png"] }
+moka = { version = "0.12.10", features = ["future", "sync"] }
 prost = "0.13.0"
+rand = "0.9.1"
 renderer-assets = { path = "../renderer-assets/" }
 renderer-types = { path = "../renderer-types/" }
-sha1 = "0.10.6"
-tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros", "net"] }
-urlencoding = "2.1.3"
-winit = "0.30.9"
-chrono = "0.4.40"
-chrono-tz = "0.10.3"
 rusttype = { version = "0.9.3", features = ["gpu_cache"] }
+sha1 = "0.10.6"
 strum = { version = "0.27.1", features = ["derive"] }
+tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros", "net"] }
 tracing = "0.1.41"
 tracing-subscriber = "0.3.19"
-anyhow = "1.0.98"
-rand = "0.9.1"
+urlencoding = "2.1.3"
+winit = "0.30.9"
 
 justnames = { git = "https://github.com/EEWBot/justnames", tag = "v0.1.0" }
+humantime = "2.2.0"
 
 [build-dependencies]
 prost-build = "0.13.5"
diff --git a/renderer/src/main.rs b/renderer/src/main.rs
index 52aff9d..f315d0c 100644
--- a/renderer/src/main.rs
+++ b/renderer/src/main.rs
@@ -30,6 +30,14 @@ struct Cli {
 
     #[clap(env, long, default_value_t = false)]
     allow_demo: bool,
+
+    #[clap(long, env)]
+    #[clap(default_value = "50ms")]
+    minimum_response_interval: humantime::Duration,
+
+    #[clap(long, env)]
+    #[clap(default_value_t = 512)]
+    image_cache_capacity: u64,
 }
 
 #[tokio::main]
@@ -51,6 +59,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
             &cli.hmac_key,
             &cli.instance_name,
             cli.allow_demo,
+            cli.minimum_response_interval.into(),
+            cli.image_cache_capacity,
         )
         .await;
 
diff --git a/renderer/src/web/mod.rs b/renderer/src/web/mod.rs
index 1719672..37a2080 100644
--- a/renderer/src/web/mod.rs
+++ b/renderer/src/web/mod.rs
@@ -1,5 +1,6 @@
 use std::net::SocketAddr;
 use std::sync::Arc;
+use std::time::Duration;
 
 use anyhow::{Context, Result};
 use axum::{
@@ -16,7 +17,11 @@ use prost::Message;
 
 use crate::model::*;
 
+mod rate_limiter;
+use rate_limiter::ResponseRateLimiter;
+
 type HmacSha1 = Hmac<sha1::Sha1>;
+pub(in crate::web) type Sha1Bytes = [u8; 20];
 
 #[derive(Clone, Debug)]
 pub struct AppState {
@@ -24,6 +29,8 @@ pub struct AppState {
     hmac_key: Arc<String>,
     instance_name: Arc<String>,
     allow_demo: bool,
+    response_limiter: ResponseRateLimiter,
+    cache: moka::future::Cache<Sha1Bytes, bytes::Bytes>,
 }
 
 async fn render_handler(State(app): State<AppState>, req: Request) -> Response {
@@ -70,34 +77,50 @@ async fn render_handler(State(app): State<AppState>, req: Request) -> Response {
     };
 
     let short_hash = &format!("{:x}", calculated_sha1)[0..6];
-
-    let rendering_context = crate::rendering_context_v0::RenderingContextV0 {
-        time: DateTime::from_timestamp(decoded.time as i64, 0).unwrap(),
-        epicenter: decoded.epicenter.map(
-            |crate::quake_prefecture::Epicenter { lat_x10, lon_x10 }| {
-                renderer_types::Vertex::new(lon_x10 as f32 / 10.0, lat_x10 as f32 / 10.0)
-            },
-        ),
-        area_intensities: enum_map! {
-            震度::震度1 => decoded.one.clone().map(|v| v.codes).unwrap_or(vec![]),
-            震度::震度2 => decoded.two.clone().map(|v| v.codes).unwrap_or(vec![]),
-            震度::震度3 => decoded.three.clone().map(|v| v.codes).unwrap_or(vec![]),
-            震度::震度4 => decoded.four.clone().map(|v| v.codes).unwrap_or(vec![]),
-            震度::震度5弱 => decoded.five_minus.clone().map(|v| v.codes).unwrap_or(vec![]),
-            震度::震度5強 => decoded.five_plus.clone().map(|v| v.codes).unwrap_or(vec![]),
-            震度::震度6弱 => decoded.six_minus.clone().map(|v| v.codes).unwrap_or(vec![]),
-            震度::震度6強 => decoded.six_plus.clone().map(|v| v.codes).unwrap_or(vec![]),
-            震度::震度7 => decoded.seven.clone().map(|v| v.codes).unwrap_or(vec![]),
-        },
-        request_identity: format!("{short_hash}#{request_id}"),
-    };
-
-    let (tx, rx) = tokio::sync::oneshot::channel();
-
-    app.request_channel
-        .send(crate::Message::RenderingRequest((rendering_context, tx)))
-        .await
-        .unwrap();
+    let request_identity = &format!("{short_hash}#{request_id}");
+
+    tracing::info!("Request: {request_identity}");
+
+    let bin = app
+        .cache
+        .get_with(calculated_sha1.into(), async move {
+            let rendering_context = crate::rendering_context_v0::RenderingContextV0 {
+                time: DateTime::from_timestamp(decoded.time as i64, 0).unwrap(),
+                epicenter: decoded.epicenter.map(
+                    |crate::quake_prefecture::Epicenter { lat_x10, lon_x10 }| {
+                        renderer_types::Vertex::new(lon_x10 as f32 / 10.0, lat_x10 as f32 / 10.0)
+                    },
+                ),
+                area_intensities: enum_map! {
+                    震度::震度1 => decoded.one.clone().map(|v| v.codes).unwrap_or(vec![]),
+                    震度::震度2 => decoded.two.clone().map(|v| v.codes).unwrap_or(vec![]),
+                    震度::震度3 => decoded.three.clone().map(|v| v.codes).unwrap_or(vec![]),
+                    震度::震度4 => decoded.four.clone().map(|v| v.codes).unwrap_or(vec![]),
+                    震度::震度5弱 => decoded.five_minus.clone().map(|v| v.codes).unwrap_or(vec![]),
+                    震度::震度5強 => decoded.five_plus.clone().map(|v| v.codes).unwrap_or(vec![]),
+                    震度::震度6弱 => decoded.six_minus.clone().map(|v| v.codes).unwrap_or(vec![]),
+                    震度::震度6強 => decoded.six_plus.clone().map(|v| v.codes).unwrap_or(vec![]),
+                    震度::震度7 => decoded.seven.clone().map(|v| v.codes).unwrap_or(vec![]),
+                },
+                request_identity: request_identity.to_string(),
+            };
+
+            let (tx, rx) = tokio::sync::oneshot::channel();
+
+            app.request_channel
+                .send(crate::Message::RenderingRequest((rendering_context, tx)))
+                .await
+                .unwrap();
+
+            bytes::Bytes::from_owner(rx.await.unwrap())
+        })
+        .await;
+
+    let response_at = app
+        .response_limiter
+        .schedule(calculated_sha1.into(), request_identity);
+
+    tokio::time::sleep_until(response_at.into()).await;
 
     (
         [
@@ -107,7 +130,7 @@ async fn render_handler(State(app): State<AppState>, req: Request) -> Response {
                 HeaderValue::from_str(&app.instance_name).unwrap(),
             ),
         ],
-        rx.await.unwrap(),
+        bin,
     )
         .into_response()
 }
@@ -132,6 +155,8 @@ async fn demo_handler(State(app): State<AppState>) -> Response {
         return (StatusCode::UNAUTHORIZED, "Demo endpoint is not allowed").into_response();
     }
 
+    let request_identity = &format!("demo#{request_id}");
+
     let rendering_context = crate::rendering_context_v0::RenderingContextV0 {
         time: chrono_tz::Japan
             .with_ymd_and_hms(2024, 1, 1, 16, 10, 0)
@@ -149,7 +174,7 @@ async fn demo_handler(State(app): State<AppState>) -> Response {
             震度::震度6強 => vec![],
             震度::震度7 => vec![390],
         },
-        request_identity: format!("demo#{request_id}"),
+        request_identity: request_identity.to_string(),
     };
 
     let (tx, rx) = tokio::sync::oneshot::channel();
@@ -159,6 +184,12 @@ async fn demo_handler(State(app): State<AppState>) -> Response {
         .await
         .unwrap();
 
+    let bin = rx.await.unwrap();
+
+    let response_at = app.response_limiter.schedule([0; 20], request_identity);
+
+    tokio::time::sleep_until(response_at.into()).await;
+
     (
         [
             (CONTENT_TYPE, HeaderValue::from_str("image/png").unwrap()),
@@ -167,7 +198,7 @@ async fn demo_handler(State(app): State<AppState>) -> Response {
                 HeaderValue::from_str(&app.instance_name).unwrap(),
             ),
         ],
-        rx.await.unwrap(),
+        bin,
     )
         .into_response()
 }
@@ -178,10 +209,18 @@ pub async fn run(
     hmac_key: &str,
     instance_name: &str,
     allow_demo: bool,
+    minimum_response_interval: Duration,
+    image_cache_capacity: u64,
 ) -> Result<()> {
     let hmac_key = Arc::new(hmac_key.to_string());
     let instance_name = Arc::new(instance_name.to_string());
 
+    let response_limiter = ResponseRateLimiter::new(minimum_response_interval);
+
+    let cache = moka::future::Cache::builder()
+        .max_capacity(image_cache_capacity)
+        .build();
+
     let app = Router::new()
         .route("/", get(root_handler))
         .route("/demo", get(demo_handler))
@@ -191,6 +230,8 @@ pub async fn run(
             hmac_key,
             instance_name,
             allow_demo,
+            response_limiter,
+            cache,
         });
 
     let listener = tokio::net::TcpListener::bind(listen)
diff --git a/renderer/src/web/rate_limiter.rs b/renderer/src/web/rate_limiter.rs
new file mode 100644
index 0000000..1bf3134
--- /dev/null
+++ b/renderer/src/web/rate_limiter.rs
@@ -0,0 +1,55 @@
+use std::time::{Duration, Instant};
+
+use moka::{
+    ops::compute::{CompResult, Op},
+    sync::Cache,
+};
+
+#[derive(Debug, Clone)]
+pub struct ResponseRateLimiter {
+    minimum_response_interval: Duration,
+    last_respond_ats: Cache<super::Sha1Bytes, Instant>,
+}
+
+impl ResponseRateLimiter {
+    pub fn new(minimum_response_interval: Duration) -> Self {
+        Self {
+            minimum_response_interval,
+            last_respond_ats: Cache::builder()
+                .time_to_live(Duration::from_secs(5))
+                .build(),
+        }
+    }
+
+    pub fn schedule(&self, sha1: super::Sha1Bytes, identity: &str) -> Instant {
+        let now = Instant::now();
+
+        let schedule_responce_at =
+            self.last_respond_ats
+                .entry(sha1)
+                .and_compute_with(|maybe_entry| match maybe_entry {
+                    Some(v) => {
+                        if *v.value() + self.minimum_response_interval < now {
+                            Op::Put(now)
+                        } else {
+                            let schedule_at = *v.value() + self.minimum_response_interval;
+
+                            tracing::info!(
+                                "Scheduled after {:?} ({identity})",
+                                schedule_at.duration_since(now)
+                            );
+
+                            Op::Put(schedule_at)
+                        }
+                    }
+                    None => Op::Put(now),
+                });
+
+        match schedule_responce_at {
+            CompResult::Inserted(v) | CompResult::ReplacedWith(v) => *v.value(),
+            CompResult::StillNone(_) | CompResult::Removed(_) | CompResult::Unchanged(_) => {
+                unreachable!()
+            }
+        }
+    }
+}


Reviews:
user: login: Siro256
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:43:21Z
user: login: yanorei32
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:58:02Z
user: login: yanorei32
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T12:59:47Z
user: login: yanorei32
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T13:00:22Z
user: login: Siro256
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T13:07:14Z
user: login: Siro256
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T13:08:13Z
user: login: Siro256
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T13:08:37Z
user: login: Siro256
body: 💯
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T13:11:11Z
user: login: sjcl
body: - [ ] 

- [ ] 

- [ ] 

- [ ] 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T13:45:44Z

Review comments:
diff_hunk: @@ -0,0 +1,55 @@
+use std::time::{Duration, Instant};
+
+use moka::{
+    ops::compute::{CompResult, Op},
+    sync::Cache,
+};
+
+#[derive(Debug, Clone)]
+pub struct ResponseRateLimiter {
+    minimum_response_interval: Duration,
+    last_respond_ats: Cache<[u8; 20], Instant>,
+}
+
+impl ResponseRateLimiter {
+    pub fn new(minimum_response_interval: Duration) -> Self {
+        Self {
+            minimum_response_interval,
+            last_respond_ats: Cache::builder()
+                .time_to_live(Duration::from_secs(5))
+                .build(),
+        }
+    }
+
+    pub fn schedule(&self, sha1: [u8; 20], identity: &str) -> Instant {
+        let now = Instant::now();
+
+        let schedule_responce_at =
+            self.last_respond_ats
+                .entry(sha1)
+                .and_compute_with(|maybe_entry| match maybe_entry {
+                    Some(v) => {
+                        if *v.value() + self.minimum_response_interval < now {
+                            Op::Put(now)
+                        } else {
+                            let schedule_at = *v.value() + self.minimum_response_interval;
+
+                            tracing::info!(
+                                "Scheduled after {:?} ({identity})",
+                                schedule_at.duration_since(now)
+                            );
+
+                            Op::Put(schedule_at)
+                        }
+                    }
+                    None => Op::Put(now),
+                });
+
+        match schedule_responce_at {
+            CompResult::Inserted(v) | CompResult::ReplacedWith(v) => *v.value(),
+            CompResult::StillNone(_) | CompResult::Removed(_) | CompResult::Unchanged(_) => {
+                unreachable!()
+            }
path: renderer/src/web/rate_limiter.rs
user: login: Siro256
body: ```rust
            _ => unreachable!()
```
でないのは何か意図がありますか？
created_at: 2025-05-07T12:10:37Z
author_association: MEMBER
diff_hunk: @@ -30,6 +30,14 @@ struct Cli {
 
     #[clap(env, long, default_value_t = false)]
     allow_demo: bool,
+
+    #[clap(long, env)]
+    #[clap(default_value = "50ms")]
+    minimum_response_interval: humantime::Duration,
+
+    #[clap(long, env)]
+    #[clap(default_value_t = 512)]
+    image_cache_entries: u64,
path: renderer/src/main.rs
user: login: Siro256
body: `max_image_cache_capacity`か、長いなら`image_cache_capacity`あたりのほうが分かりやすい気がします
(entriesだと要素そのものに感じてしまう)
created_at: 2025-05-07T12:27:27Z
author_association: MEMBER
diff_hunk: @@ -24,6 +28,8 @@ pub struct AppState {
     hmac_key: Arc<String>,
     instance_name: Arc<String>,
     allow_demo: bool,
+    response_limiter: ResponseRateLimiter,
+    cache: moka::future::Cache<[u8; 20], bytes::Bytes>,
path: renderer/src/web/mod.rs
user: login: Siro256
body: `[u8; 20]`に
```rust
type Sha1 = [u8; 20];
```
とかの型エイリアスがあると読みやすい気がします
created_at: 2025-05-07T12:35:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,55 @@
+use std::time::{Duration, Instant};
+
+use moka::{
+    ops::compute::{CompResult, Op},
+    sync::Cache,
+};
+
+#[derive(Debug, Clone)]
+pub struct ResponseRateLimiter {
+    minimum_response_interval: Duration,
+    last_respond_ats: Cache<[u8; 20], Instant>,
+}
+
+impl ResponseRateLimiter {
+    pub fn new(minimum_response_interval: Duration) -> Self {
+        Self {
+            minimum_response_interval,
+            last_respond_ats: Cache::builder()
+                .time_to_live(Duration::from_secs(5))
+                .build(),
+        }
+    }
+
+    pub fn schedule(&self, sha1: [u8; 20], identity: &str) -> Instant {
+        let now = Instant::now();
+
+        let schedule_responce_at =
+            self.last_respond_ats
+                .entry(sha1)
+                .and_compute_with(|maybe_entry| match maybe_entry {
+                    Some(v) => {
+                        if *v.value() + self.minimum_response_interval < now {
+                            Op::Put(now)
+                        } else {
+                            let schedule_at = *v.value() + self.minimum_response_interval;
+
+                            tracing::info!(
+                                "Scheduled after {:?} ({identity})",
+                                schedule_at.duration_since(now)
+                            );
+
+                            Op::Put(schedule_at)
+                        }
+                    }
+                    None => Op::Put(now),
+                });
+
+        match schedule_responce_at {
+            CompResult::Inserted(v) | CompResult::ReplacedWith(v) => *v.value(),
+            CompResult::StillNone(_) | CompResult::Removed(_) | CompResult::Unchanged(_) => {
+                unreachable!()
+            }
path: renderer/src/web/rate_limiter.rs
user: login: yanorei32
body: 将来的になにかAPIが追加されたときに、網羅性検査にひっかかる、直前のコードから明示的にunrechableといえる、を意図しています。（意図として、「それ以外」がunreachable!()ではなく、 `CompResult::StillNone(_) | CompResult::Removed(_) | CompResult::Unchanged(_)` が unrechable!() です）
created_at: 2025-05-07T12:58:02Z
author_association: MEMBER
diff_hunk: @@ -30,6 +30,14 @@ struct Cli {
 
     #[clap(env, long, default_value_t = false)]
     allow_demo: bool,
+
+    #[clap(long, env)]
+    #[clap(default_value = "50ms")]
+    minimum_response_interval: humantime::Duration,
+
+    #[clap(long, env)]
+    #[clap(default_value_t = 512)]
+    image_cache_entries: u64,
path: renderer/src/main.rs
user: login: yanorei32
body: じゃあimage_cache_capacityでいきましょう。
created_at: 2025-05-07T12:59:47Z
author_association: MEMBER
diff_hunk: @@ -24,6 +28,8 @@ pub struct AppState {
     hmac_key: Arc<String>,
     instance_name: Arc<String>,
     allow_demo: bool,
+    response_limiter: ResponseRateLimiter,
+    cache: moka::future::Cache<[u8; 20], bytes::Bytes>,
path: renderer/src/web/mod.rs
user: login: yanorei32
body: Sha1は語弊がある気がしているので (TypedのSha1ではない為)、Sha1Bytesとかにしますか。
created_at: 2025-05-07T13:00:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,55 @@
+use std::time::{Duration, Instant};
+
+use moka::{
+    ops::compute::{CompResult, Op},
+    sync::Cache,
+};
+
+#[derive(Debug, Clone)]
+pub struct ResponseRateLimiter {
+    minimum_response_interval: Duration,
+    last_respond_ats: Cache<[u8; 20], Instant>,
+}
+
+impl ResponseRateLimiter {
+    pub fn new(minimum_response_interval: Duration) -> Self {
+        Self {
+            minimum_response_interval,
+            last_respond_ats: Cache::builder()
+                .time_to_live(Duration::from_secs(5))
+                .build(),
+        }
+    }
+
+    pub fn schedule(&self, sha1: [u8; 20], identity: &str) -> Instant {
+        let now = Instant::now();
+
+        let schedule_responce_at =
+            self.last_respond_ats
+                .entry(sha1)
+                .and_compute_with(|maybe_entry| match maybe_entry {
+                    Some(v) => {
+                        if *v.value() + self.minimum_response_interval < now {
+                            Op::Put(now)
+                        } else {
+                            let schedule_at = *v.value() + self.minimum_response_interval;
+
+                            tracing::info!(
+                                "Scheduled after {:?} ({identity})",
+                                schedule_at.duration_since(now)
+                            );
+
+                            Op::Put(schedule_at)
+                        }
+                    }
+                    None => Op::Put(now),
+                });
+
+        match schedule_responce_at {
+            CompResult::Inserted(v) | CompResult::ReplacedWith(v) => *v.value(),
+            CompResult::StillNone(_) | CompResult::Removed(_) | CompResult::Unchanged(_) => {
+                unreachable!()
+            }
path: renderer/src/web/rate_limiter.rs
user: login: Siro256
body: 完全に理解
created_at: 2025-05-07T13:07:14Z
author_association: MEMBER
diff_hunk: @@ -30,6 +30,14 @@ struct Cli {
 
     #[clap(env, long, default_value_t = false)]
     allow_demo: bool,
+
+    #[clap(long, env)]
+    #[clap(default_value = "50ms")]
+    minimum_response_interval: humantime::Duration,
+
+    #[clap(long, env)]
+    #[clap(default_value_t = 512)]
+    image_cache_entries: u64,
path: renderer/src/main.rs
user: login: Siro256
body: 👍
created_at: 2025-05-07T13:08:13Z
author_association: MEMBER
diff_hunk: @@ -24,6 +28,8 @@ pub struct AppState {
     hmac_key: Arc<String>,
     instance_name: Arc<String>,
     allow_demo: bool,
+    response_limiter: ResponseRateLimiter,
+    cache: moka::future::Cache<[u8; 20], bytes::Bytes>,
path: renderer/src/web/mod.rs
user: login: Siro256
body: よさそう
created_at: 2025-05-07T13:08:36Z
author_association: MEMBER

PR comments:
user: login: yanorei32
created_at: 2025-05-07T12:06:41Z
body: ReviewがコケてるのはClippy Warningsが多すぎるからです。いつかだれかどうにかするので一旦無視で。
author_association: MEMBER
user: login: yanorei32
created_at: 2025-05-07T14:52:15Z
body: まぁマージ。
author_association: MEMBER