| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | | use oxy_core::hash::ChangeHash; |
| 7 | | use oxy_core::repo::Repo; |
| 8 | | use oxy_core::state::{Snapshot, State}; |
| 9 | | use std::collections::HashMap; |
| 10 | | use std::path::{Path, PathBuf}; |
| 11 | | use std::sync::{Arc, Mutex}; |
| 12 | |
|
| 13 | |
|
| 14 | | pub struct Loaded { |
| 15 | | pub heads: Vec<ChangeHash>, |
| 16 | | pub state: State, |
| 17 | | pub snapshot: Snapshot, |
| 18 | | } |
| 19 | |
|
| 20 | | #[derive(Clone, Debug, PartialEq, Eq, Hash)] |
| 21 | | struct Key { |
| 22 | | repo: [u8; 16], |
| 23 | | heads: Vec<ChangeHash>, |
| 24 | | } |
| 25 | |
|
| 26 | | struct Entry { |
| 27 | | loaded: Arc<Loaded>, |
| 28 | | last_used: u64, |
| 29 | | |
| 30 | | weight: usize, |
| 31 | | } |
| 32 | |
|
| 33 | |
|
| 34 | |
|
| 35 | |
|
| 36 | |
|
| 37 | |
|
| 38 | |
|
| 39 | 318× | fn weight(l: &Loaded) -> usize { |
| 40 | 318× | let (mut text, mut lines) = (0usize, 0usize); |
| 41 | 816× | for f in l.snapshot.files.values() { |
| 42 | 816× | match &f.content { |
| 43 | 601× | oxy_core::state::FileContent::Text { bytes, .. } => { |
| 44 | 601× | text += bytes.len(); |
| 45 | 66743× | lines += bytes.iter().filter(|b| **b == b'\n').count() + 1; |
| 46 | | } |
| 47 | 215× | _ => text += 64, |
| 48 | | } |
| 49 | | } |
| 50 | 318× | text.saturating_mul(5).saturating_add(lines.saturating_mul(384)).saturating_add(l.state.len().saturating_mul(1024)) |
| 51 | 318× | } |
| 52 | |
|
| 53 | |
|
| 54 | |
|
| 55 | |
|
| 56 | 400× | pub fn budget() -> usize { |
| 57 | 400× | std::env::var("OXYHUB_STATE_CACHE_MB").ok().and_then(|v| v.parse::<usize>().ok()).unwrap_or(3 * 1024).saturating_mul(1 << 20) |
| 58 | 400× | } |
| 59 | |
|
| 60 | |
|
| 61 | |
|
| 62 | |
|
| 63 | |
|
| 64 | |
|
| 65 | |
|
| 66 | | pub struct Summary { |
| 67 | | pub changes: std::collections::HashSet<ChangeHash>, |
| 68 | | pub latest_ms: u64, |
| 69 | | pub conflicted: bool, |
| 70 | | } |
| 71 | |
|
| 72 | | impl Summary { |
| 73 | 646× | pub fn brief(&self) -> Brief { |
| 74 | 646× | Brief { changes: self.changes.len(), latest_ms: self.latest_ms, conflicted: self.conflicted } |
| 75 | 646× | } |
| 76 | | } |
| 77 | |
|
| 78 | |
|
| 79 | |
|
| 80 | |
|
| 81 | |
|
| 82 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 83 | | pub struct Brief { |
| 84 | | pub changes: usize, |
| 85 | | pub latest_ms: u64, |
| 86 | | pub conflicted: bool, |
| 87 | | } |
| 88 | |
|
| 89 | |
|
| 90 | |
|
| 91 | | const SUMMARIES: usize = 2048; |
| 92 | |
|
| 93 | | const PARALLEL: usize = 4; |
| 94 | |
|
| 95 | |
|
| 96 | | type Summaries = (HashMap<Key, (Arc<Summary>, u64)>, u64); |
| 97 | |
|
| 98 | |
|
| 99 | | pub struct StateCache { |
| 100 | | capacity: usize, |
| 101 | | |
| 102 | | budget: usize, |
| 103 | | inner: Mutex<(HashMap<Key, Entry>, u64)>, |
| 104 | | summaries: Mutex<Summaries>, |
| 105 | | |
| 106 | | briefs: Option<PathBuf>, |
| 107 | | } |
| 108 | |
|
| 109 | | impl StateCache { |
| 110 | 391× | pub fn new(capacity: usize) -> StateCache { |
| 111 | 391× | StateCache::with_budget(capacity, budget()) |
| 112 | 391× | } |
| 113 | |
|
| 114 | 393× | pub fn with_budget(capacity: usize, budget: usize) -> StateCache { |
| 115 | 393× | StateCache { |
| 116 | 393× | capacity: capacity.max(1), |
| 117 | 393× | budget, |
| 118 | 393× | inner: Mutex::new((HashMap::new(), 0)), |
| 119 | 393× | summaries: Mutex::new((HashMap::new(), 0)), |
| 120 | 393× | briefs: None, |
| 121 | 393× | } |
| 122 | 393× | } |
| 123 | |
|
| 124 | | |
| 125 | 388× | pub fn with_briefs(mut self, dir: PathBuf) -> StateCache { |
| 126 | 388× | self.briefs = Some(dir); |
| 127 | 388× | self |
| 128 | 388× | } |
| 129 | |
|
| 130 | 322× | fn brief_path(&self, key: &Key) -> Option<PathBuf> { |
| 131 | 322× | let dir = self.briefs.as_ref()?; |
| 132 | 320× | let mut h = blake3::Hasher::new(); |
| 133 | 509× | for c in &key.heads { |
| 134 | 509× | h.update(&c.0); |
| 135 | 509× | } |
| 136 | 320× | Some(dir.join(hex::encode(key.repo)).join(h.finalize().to_hex().as_str())) |
| 137 | 322× | } |
| 138 | |
|
| 139 | 381× | fn known_brief(&self, key: &Key) -> Option<Brief> { |
| 140 | 381× | if let Some((s, _)) = self.summaries.lock().unwrap().0.get(key) { |
| 141 | 328× | return Some(s.brief()); |
| 142 | 53× | } |
| 143 | 53× | read_brief(&self.brief_path(key)?) |
| 144 | 381× | } |
| 145 | |
|
| 146 | | |
| 147 | | |
| 148 | 2× | pub fn brief(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Brief, oxy_core::repo::RepoError> { |
| 149 | 2× | Ok(self.briefs(repo, &[heads.to_vec()])?[0]) |
| 150 | 2× | } |
| 151 | |
|
| 152 | | |
| 153 | | |
| 154 | 317× | pub fn briefs(&self, repo: &Repo, all: &[Vec<ChangeHash>]) -> Result<Vec<Brief>, oxy_core::repo::RepoError> { |
| 155 | 317× | let id = repo.id()?; |
| 156 | 381× | let mut out: Vec<Option<Brief>> = all.iter().map(|h| self.known_brief(&Key { repo: id, heads: sorted(h) })).collect(); |
| 157 | 381× | let missing: Vec<usize> = (0..all.len()).filter(|&i| out[i].is_none()).collect(); |
| 158 | 317× | if missing.is_empty() { |
| 159 | 292× | return Ok(out.into_iter().map(|b| b.expect("known")).collect()); |
| 160 | 48× | } |
| 161 | 48× | let next = Mutex::new(missing.iter()); |
| 162 | 48× | let found: Mutex<Vec<(usize, Result<Brief, oxy_core::repo::RepoError>)>> = Mutex::new(Vec::new()); |
| 163 | 48× | let threads = PARALLEL.min(missing.len()).min(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)); |
| 164 | | |
| 165 | 48× | let root = repo.root().to_path_buf(); |
| 166 | 48× | std::thread::scope(|s| { |
| 167 | 48× | for _ in 0..threads { |
| 168 | 52× | s.spawn(|| { |
| 169 | 52× | let mine = Repo::open(&root); |
| 170 | | loop { |
| 171 | 104× | let Some(&i) = next.lock().unwrap().next() else { break }; |
| 172 | 52× | let b = match &mine { |
| 173 | 52× | Ok(r) => self.summary(r, &all[i]).map(|s| s.brief()), |
| 174 | 0× | Err(e) => Err(oxy_core::repo::RepoError::Msg(format!("open {}: {e}", root.display()))), |
| 175 | | }; |
| 176 | 52× | found.lock().unwrap().push((i, b)); |
| 177 | | } |
| 178 | 52× | }); |
| 179 | | } |
| 180 | 48× | }); |
| 181 | 52× | for (i, b) in found.into_inner().unwrap() { |
| 182 | 52× | out[i] = Some(b?); |
| 183 | | } |
| 184 | 86× | Ok(out.into_iter().map(|b| b.expect("worked out")).collect()) |
| 185 | 317× | } |
| 186 | |
|
| 187 | | |
| 188 | | |
| 189 | | |
| 190 | 484× | pub fn summary(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Arc<Summary>, oxy_core::repo::RepoError> { |
| 191 | 484× | let sorted = sorted(heads); |
| 192 | 484× | let key = Key { repo: repo.id()?, heads: sorted.clone() }; |
| 193 | | { |
| 194 | 484× | let mut guard = self.summaries.lock().unwrap(); |
| 195 | 484× | let (map, tick) = &mut *guard; |
| 196 | 484× | *tick += 1; |
| 197 | 484× | if let Some(e) = map.get_mut(&key) { |
| 198 | 212× | e.1 = *tick; |
| 199 | 212× | return Ok(e.0.clone()); |
| 200 | 272× | } |
| 201 | | } |
| 202 | 272× | let held = self.inner.lock().unwrap().0.get(&key).map(|e| e.loaded.clone()); |
| 203 | 272× | let summary = Arc::new(match held { |
| 204 | 26× | Some(l) => summarise(&l.state, &l.snapshot), |
| 205 | | None => { |
| 206 | 246× | let state = repo.load_state(&sorted)?; |
| 207 | 243× | let snapshot = state.materialize(); |
| 208 | 243× | summarise(&state, &snapshot) |
| 209 | | } |
| 210 | | }); |
| 211 | 269× | if let Some(path) = self.brief_path(&key) { |
| 212 | 268× | write_brief(&path, summary.brief()); |
| 213 | 268× | } |
| 214 | 269× | let mut guard = self.summaries.lock().unwrap(); |
| 215 | 269× | let (map, tick) = &mut *guard; |
| 216 | 269× | if map.len() >= SUMMARIES { |
| 217 | 0× | if let Some(oldest) = map.iter().min_by_key(|(_, e)| e.1).map(|(k, _)| k.clone()) { |
| 218 | 0× | map.remove(&oldest); |
| 219 | 0× | } |
| 220 | 269× | } |
| 221 | 269× | map.insert(key, (summary.clone(), *tick)); |
| 222 | 269× | Ok(summary) |
| 223 | 484× | } |
| 224 | |
|
| 225 | | |
| 226 | 1592× | pub fn load(&self, repo: &Repo, heads: &[ChangeHash]) -> Result<Arc<Loaded>, oxy_core::repo::RepoError> { |
| 227 | 1592× | let mut sorted = heads.to_vec(); |
| 228 | 1592× | sorted.sort(); |
| 229 | 1592× | sorted.dedup(); |
| 230 | 1592× | let key = Key { repo: repo.id()?, heads: sorted.clone() }; |
| 231 | | { |
| 232 | 1592× | let mut guard = self.inner.lock().unwrap(); |
| 233 | 1592× | let (map, tick) = &mut *guard; |
| 234 | 1592× | *tick += 1; |
| 235 | 1592× | if let Some(e) = map.get_mut(&key) { |
| 236 | 1273× | e.last_used = *tick; |
| 237 | 1273× | return Ok(e.loaded.clone()); |
| 238 | 319× | } |
| 239 | | } |
| 240 | 319× | let state = repo.load_state(&sorted)?; |
| 241 | 318× | let snapshot = state.materialize(); |
| 242 | 318× | let loaded = Arc::new(Loaded { heads: sorted, state, snapshot }); |
| 243 | 318× | let w = weight(&loaded); |
| 244 | 318× | let mut guard = self.inner.lock().unwrap(); |
| 245 | 318× | let (map, tick) = &mut *guard; |
| 246 | | |
| 247 | | |
| 248 | 318× | let budget = self.budget; |
| 249 | 318× | let mut total: usize = map.values().map(|e| e.weight).sum::<usize>() + w; |
| 250 | 319× | while !map.is_empty() && (map.len() >= self.capacity || total > budget) { |
| 251 | 1× | let Some(oldest) = map.iter().min_by_key(|(_, e)| e.last_used).map(|(k, _)| k.clone()) else { |
| 252 | 0× | break; |
| 253 | | }; |
| 254 | 1× | if let Some(e) = map.remove(&oldest) { |
| 255 | 1× | total -= e.weight; |
| 256 | 1× | } |
| 257 | | } |
| 258 | 318× | map.insert(key, Entry { loaded: loaded.clone(), last_used: *tick, weight: w }); |
| 259 | 318× | Ok(loaded) |
| 260 | 1592× | } |
| 261 | |
|
| 262 | | |
| 263 | 9× | pub fn stats(&self) -> (usize, usize, usize) { |
| 264 | 9× | let (entries, weight) = { |
| 265 | 9× | let guard = self.inner.lock().unwrap(); |
| 266 | 9× | (guard.0.len(), guard.0.values().map(|e| e.weight).sum()) |
| 267 | | }; |
| 268 | 9× | (entries, weight, self.summaries.lock().unwrap().0.len()) |
| 269 | 9× | } |
| 270 | |
|
| 271 | 6× | pub fn len(&self) -> usize { |
| 272 | 6× | self.inner.lock().unwrap().0.len() |
| 273 | 6× | } |
| 274 | |
|
| 275 | 0× | pub fn is_empty(&self) -> bool { |
| 276 | 0× | self.len() == 0 |
| 277 | 0× | } |
| 278 | | } |
| 279 | |
|
| 280 | 865× | fn sorted(heads: &[ChangeHash]) -> Vec<ChangeHash> { |
| 281 | 865× | let mut v = heads.to_vec(); |
| 282 | 865× | v.sort(); |
| 283 | 865× | v.dedup(); |
| 284 | 865× | v |
| 285 | 865× | } |
| 286 | |
|
| 287 | |
|
| 288 | 52× | fn read_brief(path: &Path) -> Option<Brief> { |
| 289 | 52× | let text = std::fs::read_to_string(path).ok()?; |
| 290 | 1× | let mut f = text.split_whitespace(); |
| 291 | 1× | Some(Brief { changes: f.next()?.parse().ok()?, latest_ms: f.next()?.parse().ok()?, conflicted: f.next()? == "1" }) |
| 292 | 52× | } |
| 293 | |
|
| 294 | |
|
| 295 | 268× | fn write_brief(path: &Path, b: Brief) { |
| 296 | 268× | let Some(dir) = path.parent() else { return }; |
| 297 | 268× | if std::fs::create_dir_all(dir).is_err() { |
| 298 | 0× | return; |
| 299 | 268× | } |
| 300 | 268× | let tmp = path.with_extension(format!("tmp{}", std::process::id())); |
| 301 | 268× | if std::fs::write(&tmp, format!("{} {} {}", b.changes, b.latest_ms, u8::from(b.conflicted))).is_ok() { |
| 302 | 268× | std::fs::rename(&tmp, path).ok(); |
| 303 | 268× | } |
| 304 | 268× | } |
| 305 | |
|
| 306 | 269× | fn summarise(state: &State, snapshot: &Snapshot) -> Summary { |
| 307 | | Summary { |
| 308 | 269× | changes: state.changes().map(|(h, _)| *h).collect(), |
| 309 | 269× | latest_ms: state.changes().map(|(_, m)| m.hlc.wall_ms).max().unwrap_or(0), |
| 310 | 269× | conflicted: snapshot.is_conflicted(), |
| 311 | | } |
| 312 | 269× | } |
| 313 | |
|
| 314 | | #[cfg(test)] |
| 315 | | mod tests { |
| 316 | | use super::*; |
| 317 | |
|
| 318 | | |
| 319 | | |
| 320 | | |
| 321 | | #[test] |
| 322 | | #[ignore] |
| 323 | 0× | fn measures_a_real_state() { |
| 324 | 0× | fn rss_kib() -> usize { |
| 325 | 0× | let s = std::fs::read_to_string("/proc/self/status").unwrap(); |
| 326 | 0× | s.lines().find(|l| l.starts_with("VmRSS:")).and_then(|l| l.split_whitespace().nth(1)).unwrap().parse().unwrap() |
| 327 | 0× | } |
| 328 | 0× | let path = std::env::var("OXYHUB_MEASURE_REPO").expect("OXYHUB_MEASURE_REPO"); |
| 329 | 0× | let repo = Repo::open(std::path::Path::new(&path)).unwrap(); |
| 330 | 0× | let view = repo.view().unwrap(); |
| 331 | | |
| 332 | 0× | let cache = StateCache::with_budget(64, usize::MAX); |
| 333 | 0× | let mut held = Vec::new(); |
| 334 | 0× | for bookmark in view.bookmarks.keys().take(6).cloned().collect::<Vec<_>>() { |
| 335 | 0× | let heads = repo.heads_of(&view, &bookmark).unwrap(); |
| 336 | 0× | let before = rss_kib(); |
| 337 | 0× | let loaded = cache.load(&repo, &heads).unwrap(); |
| 338 | 0× | let after = rss_kib(); |
| 339 | 0× | let files = loaded.snapshot.files.len(); |
| 340 | 0× | let (mut text, mut lines) = (0usize, 0usize); |
| 341 | 0× | for f in loaded.snapshot.files.values() { |
| 342 | 0× | if let oxy_core::state::FileContent::Text { bytes, .. } = &f.content { |
| 343 | 0× | text += bytes.len(); |
| 344 | 0× | lines += bytes.iter().filter(|b| **b == b'\n').count(); |
| 345 | 0× | } |
| 346 | | } |
| 347 | 0× | println!( |
| 348 | | "{bookmark}: {} changes, {files} files, {} KiB text, {lines} lines: weight {} MiB, resident +{} MiB", |
| 349 | 0× | loaded.state.len(), |
| 350 | 0× | text >> 10, |
| 351 | 0× | weight(&loaded) >> 20, |
| 352 | 0× | (after.saturating_sub(before)) >> 10 |
| 353 | | ); |
| 354 | 0× | held.push(loaded); |
| 355 | | } |
| 356 | 0× | } |
| 357 | |
|
| 358 | | #[test] |
| 359 | 1× | fn caches_by_heads_and_evicts_the_oldest() { |
| 360 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 361 | 1× | let repo = Repo::init_bare(dir.path()).unwrap(); |
| 362 | 1× | let cache = StateCache::new(2); |
| 363 | 1× | let a = cache.load(&repo, &[]).unwrap(); |
| 364 | 1× | let b = cache.load(&repo, &[]).unwrap(); |
| 365 | 1× | assert!(Arc::ptr_eq(&a, &b), "same heads, same entry"); |
| 366 | 1× | assert_eq!(cache.len(), 1); |
| 367 | 1× | let h1 = oxy_core::hash::ObjectRef([1; 32]); |
| 368 | 1× | let h2 = oxy_core::hash::ObjectRef([2; 32]); |
| 369 | | |
| 370 | 1× | assert!(cache.load(&repo, &[h1]).is_err()); |
| 371 | 1× | assert_eq!(cache.len(), 1); |
| 372 | 1× | let _ = (h2, dir); |
| 373 | 1× | } |
| 374 | |
|
| 375 | | #[test] |
| 376 | 1× | fn summaries_leave_the_state_cache_alone() { |
| 377 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 378 | 1× | let repo = Repo::init_bare(dir.path()).unwrap(); |
| 379 | 1× | let cache = StateCache::new(2); |
| 380 | 1× | let a = cache.summary(&repo, &[]).unwrap(); |
| 381 | 1× | assert_eq!(cache.len(), 0, "a summary does not fill the state cache"); |
| 382 | 1× | assert!(Arc::ptr_eq(&a, &cache.summary(&repo, &[]).unwrap()), "kept"); |
| 383 | 1× | assert!(a.changes.is_empty() && !a.conflicted); |
| 384 | 1× | assert!(cache.summary(&repo, &[oxy_core::hash::ObjectRef([3; 32])]).is_err()); |
| 385 | 1× | } |
| 386 | |
|
| 387 | | #[test] |
| 388 | 1× | fn a_size_budget_evicts_the_oldest_and_keeps_the_newest() { |
| 389 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 390 | 1× | let repo = Repo::init(dir.path()).unwrap(); |
| 391 | 1× | std::fs::write(dir.path().join("a.txt"), "a\n".repeat(1000)).unwrap(); |
| 392 | 1× | let one = repo.commit("one", &[]).unwrap(); |
| 393 | 1× | std::fs::write(dir.path().join("b.txt"), "b\n".repeat(1000)).unwrap(); |
| 394 | 1× | let two = repo.commit("two", &[]).unwrap(); |
| 395 | | |
| 396 | 1× | let cache = StateCache::with_budget(64, 0); |
| 397 | 1× | cache.load(&repo, &[one]).unwrap(); |
| 398 | 1× | assert_eq!(cache.len(), 1, "kept though over the budget"); |
| 399 | 1× | cache.load(&repo, &[two]).unwrap(); |
| 400 | 1× | assert_eq!(cache.len(), 1, "the older one went"); |
| 401 | | |
| 402 | 1× | let roomy = StateCache::with_budget(64, 1 << 30); |
| 403 | 1× | roomy.load(&repo, &[one]).unwrap(); |
| 404 | 1× | roomy.load(&repo, &[two]).unwrap(); |
| 405 | 1× | assert_eq!(roomy.len(), 2); |
| 406 | 1× | } |
| 407 | |
|
| 408 | | #[test] |
| 409 | 1× | fn briefs_are_worked_out_in_parallel_and_survive_a_restart() { |
| 410 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 411 | 1× | let repo = Repo::init(dir.path()).unwrap(); |
| 412 | 1× | std::fs::write(dir.path().join("a.txt"), "a\n").unwrap(); |
| 413 | 1× | let one = repo.commit("one", &[]).unwrap(); |
| 414 | 1× | std::fs::write(dir.path().join("b.txt"), "b\n").unwrap(); |
| 415 | 1× | let two = repo.commit("two", &[]).unwrap(); |
| 416 | 1× | let kept = tempfile::tempdir().unwrap(); |
| 417 | 1× | let cache = StateCache::new(8).with_briefs(kept.path().to_path_buf()); |
| 418 | 1× | let got = cache.briefs(&repo, &[vec![one], vec![one, two], vec![]]).unwrap(); |
| 419 | 1× | assert_eq!(got.iter().map(|b| b.changes).collect::<Vec<_>>(), vec![1, 2, 0]); |
| 420 | 1× | assert!(got[1].latest_ms >= got[0].latest_ms && !got[1].conflicted); |
| 421 | | |
| 422 | | |
| 423 | 1× | let gone = tempfile::tempdir().unwrap(); |
| 424 | 1× | let empty = Repo::init(gone.path()).unwrap(); |
| 425 | 1× | let empty_id = empty.id().unwrap(); |
| 426 | 1× | for f in std::fs::read_dir(kept.path()).unwrap() { |
| 427 | 1× | let f = f.unwrap().path(); |
| 428 | 1× | std::fs::rename(&f, kept.path().join(hex::encode(empty_id))).unwrap(); |
| 429 | 1× | } |
| 430 | 1× | let restarted = StateCache::new(8).with_briefs(kept.path().to_path_buf()); |
| 431 | 1× | assert_eq!(restarted.brief(&empty, &[two, one]).unwrap(), got[1], "from disk, whatever the heads' order"); |
| 432 | 1× | assert!(StateCache::new(8).brief(&empty, &[one, two]).is_err(), "without the disk it would have to load"); |
| 433 | | |
| 434 | 1× | assert!(cache.briefs(&repo, &[vec![one], vec![oxy_core::hash::ObjectRef([9; 32])]]).is_err()); |
| 435 | | |
| 436 | 1× | let bare_dir = tempfile::tempdir().unwrap(); |
| 437 | 1× | let bare = Repo::init_bare(bare_dir.path()).unwrap(); |
| 438 | 1× | assert_eq!(cache.briefs(&bare, &[vec![], vec![]]).unwrap().len(), 2); |
| 439 | 1× | } |
| 440 | | } |