1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
use crate::cacheable::CacheService;
use crate::common::model::Request;
use crate::common::model::config::Config;
use crate::common::model::download_config::DownloadConfig;
use crate::downloader::request_downloader::RequestDownloader;
use crate::downloader::{Downloader, WebSocketDownloader};
use crate::utils::distributed_rate_limit::DistributedSlidingWindowRateLimiter;
use crate::utils::redis_lock::DistributedLockManager;
use dashmap::DashMap;
use deadpool_redis::redis::Script;
use std::sync::Arc;
use std::time::SystemTime;
use tokio::sync::RwLock;
pub struct DownloaderManager {
/// 应用配置(命名空间名等);此前吃整个 `Arc<State>`,现窄化为具体依赖(重构 Phase 2)。
pub app_config: Arc<RwLock<Config>>,
/// 分布式锁管理器(用于取 Redis 连接池)。
pub locker: Arc<DistributedLockManager>,
/// 默认下载器(缺省 reqwest);可经 [`set_default_downloader`](Self::set_default_downloader)
/// 在启动前替换(如换成浏览器渲染 / 代理轮换 / 自定义重试的下载器)。
pub default_downloader: RwLock<Box<dyn Downloader>>,
// Registered downloader factories.
pub downloader: Arc<DashMap<String, Box<dyn Downloader>>>,
// Task downloader configuration.
pub config: Arc<DashMap<String, DownloadConfig>>,
// Task downloader instances.
pub task_downloader: Arc<DashMap<String, Box<dyn Downloader>>>,
pub wss_downloader: Arc<WebSocketDownloader>,
// Records the last expiration update timestamp to reduce Redis write frequency.
pub expire_update_cache: Arc<DashMap<String, u64>>,
}
impl DownloaderManager {
/// Create a new DownloaderManager instance.
///
/// # Arguments
/// * `state` - Shared application state containing configuration, rate limiter, etc.
pub async fn new(
app_config: Arc<RwLock<Config>>,
limiter: Arc<DistributedSlidingWindowRateLimiter>,
locker: Arc<DistributedLockManager>,
cache_service: Arc<CacheService>,
) -> Self {
let (pool_size, max_response_size) = {
let cfg = app_config.read().await;
(
cfg.download_config.pool_size.unwrap_or(200),
cfg.download_config
.max_response_size
.unwrap_or(10 * 1024 * 1024),
)
};
DownloaderManager {
app_config,
locker: locker.clone(),
default_downloader: RwLock::new(Box::new(RequestDownloader::new(
Arc::clone(&limiter),
Arc::clone(&locker),
Arc::clone(&cache_service),
pool_size,
max_response_size,
))),
// Downloader factory list.
downloader: Arc::new(DashMap::new()),
// Task downloader configuration.
config: Arc::new(DashMap::new()),
// Task downloader instances.
task_downloader: Arc::new(DashMap::new()),
wss_downloader: Arc::new(WebSocketDownloader::new()),
expire_update_cache: Arc::new(DashMap::new()),
}
}
/// Register a custom downloader implementation.
///
/// The downloader is selected for a module/request when its `DownloadConfig.downloader`
/// name matches this downloader's [`name()`](Downloader::name).
pub async fn register(&self, downloader: Box<dyn Downloader>) {
self.downloader.insert(downloader.name(), downloader);
}
/// Replaces the default downloader (used when a request's `config.downloader` does not
/// match any registered downloader). Set this before the engine starts.
pub async fn set_default_downloader(&self, downloader: Box<dyn Downloader>) {
*self.default_downloader.write().await = downloader;
}
/// Set rate limit for a specific limit_id dynamically.
///
/// This updates the rate limit configuration for an active downloader instance.
pub async fn set_limit(&self, limit_id: &str, limit: f32) {
let downloader = self
.task_downloader
.get(limit_id)
.map(|d| dyn_clone::clone_box(d.value().as_ref()));
// guard dropped here — never hold DashMap Ref across .await
if let Some(d) = downloader {
d.set_limit(limit_id, limit).await;
}
}
// Helper function to get the current timestamp.
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
}
// Cleans up expired downloaders.
async fn cleanup_expired_downloader(&self) {
let downloader_expire_time = self
.app_config
.read()
.await
.download_config
.downloader_expire;
let current_time = Self::current_timestamp();
let max_score = current_time.saturating_sub(downloader_expire_time);
let config_name = self.app_config.read().await.name.clone();
let key = format!("{}:downloader_expire", config_name);
// Lua script to get and remove expired keys
let script = Script::new(
r#"
local key = KEYS[1]
local max_score = ARGV[1]
local expired = redis.call('ZRANGEBYSCORE', key, '-inf', max_score)
if #expired > 0 then
redis.call('ZREM', key, unpack(expired))
end
return expired
"#,
);
let mut expired_keys: Vec<String> = Vec::new();
// Execute Lua script
let pool = self.locker.get_pool();
if let Some(pool) = pool
&& let Ok(mut conn) = pool.get().await
{
let result: Result<Vec<String>, _> = script
.key(&key)
.arg(max_score)
.invoke_async(&mut conn)
.await;
if let Ok(keys) = result {
expired_keys = keys;
}
}
// Remove expired keys from local map
for key in &expired_keys {
self.task_downloader.remove(key);
}
// Check health status.
let check_list: Vec<(String, Box<dyn Downloader>)> = self
.task_downloader
.iter()
.map(|r| (r.key().clone(), dyn_clone::clone_box(r.value().as_ref())))
.collect();
for (key, downloader) in check_list {
if downloader.health_check().await.is_err() {
self.task_downloader.remove(&key);
if let Some(pool) = pool
&& let Ok(mut conn) = pool.get().await
{
let _: () = deadpool_redis::redis::cmd("ZREM")
.arg(&key)
.arg(&key)
.query_async(&mut conn)
.await
.unwrap_or(());
}
}
}
}
/// Start the background cleanup task.
/// Should be called once at startup.
pub fn start_background_cleaner(self: Arc<Self>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
self.cleanup_expired_downloader().await;
}
});
}
/// Get or create a downloader for the given request.
///
/// This method manages the lifecycle of downloader instances for specific tasks (modules).
/// It handles creation, configuration updates, and expiration of idle downloaders.
///
/// # Arguments
/// * `request` - The request containing module ID and limit ID.
/// * `download_config` - Configuration for the downloader.
pub async fn get_downloader(
&self,
request: &Request,
download_config: DownloadConfig,
) -> Box<dyn Downloader> {
let current_time = Self::current_timestamp();
let module_id = request.module_id();
// Check whether expiration time needs to be updated (once every 60 seconds).
let should_update = if let Some(last_update) = self.expire_update_cache.get(&module_id) {
current_time > *last_update + 60
} else {
true
};
if should_update {
// Optimistically update cache to prevent spamming the spawn
self.expire_update_cache
.insert(module_id.clone(), current_time);
// Update expiration time (Redis ZSET).
let config_name = self.app_config.read().await.name.clone();
let key = format!("{}:downloader_expire", config_name);
let pool = self.locker.get_pool().cloned();
let module_id_clone = module_id.clone();
let current_time_clone = current_time;
tokio::spawn(async move {
if let Some(pool) = pool
&& let Ok(mut conn) = pool.get().await
{
let _: () = deadpool_redis::redis::cmd("ZADD")
.arg(&key)
.arg(current_time_clone)
.arg(&module_id_clone)
.query_async(&mut conn)
.await
.unwrap_or(());
}
});
}
// Get or insert configuration.
// Extract the cached value and drop the DashMap guard BEFORE any
// potential .insert() on the same map — holding a Ref (read guard)
// while calling .insert() (write lock) on the same shard is a
// parking_lot self-deadlock.
let cached_config = self.config.get(&module_id).map(|existing| existing.clone());
// guard dropped here
let config = if let Some(cached) = cached_config {
if cached != download_config {
self.config
.insert(module_id.clone(), download_config.clone());
download_config.clone()
} else {
cached
}
} else {
self.config
.insert(module_id.clone(), download_config.clone());
download_config.clone()
};
// Determine effective limit_id here to ensure rate limiter gets correct key
let limit_id = if request.limit_id.is_empty() {
request.module_id()
} else {
request.limit_id.clone()
};
// Get or create downloader.
// Clone the cached downloader and drop the DashMap guard before any .await.
// Holding a DashMap Ref across an await can deadlock the Tokio runtime
// (parking_lot RwLock blocks the OS thread).
let cached_downloader = self
.task_downloader
.get(&module_id)
.map(|d| dyn_clone::clone_box(d.value().as_ref()));
// guard dropped here
if let Some(d) = cached_downloader {
d.set_config(&limit_id, config).await;
return d;
}
let new_downloader = if let Some(registered) = self.downloader.get(&config.downloader) {
dyn_clone::clone_box(registered.value().as_ref())
} else {
dyn_clone::clone_box(self.default_downloader.read().await.as_ref())
};
// Ensure the configuration is up to date.
new_downloader.set_config(&limit_id, config).await;
self.task_downloader.insert(
module_id.clone(),
dyn_clone::clone_box(new_downloader.as_ref()),
);
new_downloader
}
/// Clear all configurations and downloaders.
///
/// This removes all registered task downloaders and their configurations.
pub async fn clear(&self) {
self.config.clear();
self.task_downloader.clear();
}
}