1use crate::api::history::JobHistory;
28use crate::websocket::{JobUpdate, QueueStats, ServerMessage, WebSocketState};
29use chrono::{DateTime, Utc};
30use hammerwork::events::{EventSubscription, JobLifecycleEvent, JobLifecycleEventType};
31use std::collections::{HashMap, HashSet};
32use std::sync::Arc;
33use std::time::Duration;
34use tokio::sync::RwLock;
35use tokio::task::JoinHandle;
36use tracing::{debug, warn};
37
38pub const POLL_OVERLAP: chrono::Duration = chrono::Duration::seconds(5);
41
42type SentState = (String, i32, DateTime<Utc>);
44
45type QueueCounts = [u64; 6];
48
49pub struct LiveUpdates<Q> {
51 queue: Arc<Q>,
52 websocket: Arc<RwLock<WebSocketState>>,
53 max_jobs: u32,
54 last_poll: Option<DateTime<Utc>>,
57 sent: HashMap<String, SentState>,
59 queues: HashMap<String, QueueCounts>,
61 running: HashSet<String>,
65}
66
67#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
69pub struct PollOutcome {
70 pub jobs: usize,
72 pub queues: usize,
74}
75
76impl<Q: JobHistory + 'static> LiveUpdates<Q> {
77 pub fn new(queue: Arc<Q>, websocket: Arc<RwLock<WebSocketState>>, max_jobs: u32) -> Self {
80 Self {
81 queue,
82 websocket,
83 max_jobs: max_jobs.max(1),
84 last_poll: None,
85 sent: HashMap::new(),
86 queues: HashMap::new(),
87 running: HashSet::new(),
88 }
89 }
90
91 pub fn spawn(mut self, interval: Duration) -> JoinHandle<()> {
94 tokio::spawn(async move {
95 let mut ticker = tokio::time::interval(interval);
96 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
97 loop {
98 ticker.tick().await;
99 if let Err(e) = self.poll_once().await {
100 warn!(error = %e, "Dashboard live update poll failed");
101 }
102 }
103 })
104 }
105
106 pub async fn poll_once(&mut self) -> hammerwork::Result<PollOutcome> {
109 if self.websocket.read().await.connection_count() == 0 {
110 self.last_poll = None;
111 self.sent.clear();
112 self.queues.clear();
113 self.running.clear();
114 return Ok(PollOutcome::default());
115 }
116
117 let now = self.queue.database_now().await?;
118 let since = self.last_poll.unwrap_or(now) - POLL_OVERLAP;
119 let mut changes = self.queue.recent_job_changes(since, self.max_jobs).await?;
120 let running = self.queue.running_jobs(self.max_jobs).await?;
121 let mut left = self.left_running(&changes, &running);
124 let unchecked = left.split_off(left.len().min(self.max_jobs as usize));
125 let states = if left.is_empty() {
126 Vec::new()
127 } else {
128 self.queue.job_states(&left).await?
129 };
130 let (still_running, stopped) = Self::stopped_running(&left, states, now);
131 self.running = running
132 .iter()
133 .chain(&changes)
134 .filter(|job| job.status == "Running")
135 .map(|job| job.id.clone())
136 .chain(still_running)
137 .chain(unchecked)
138 .collect();
139 changes.extend(stopped);
140 let stats = self.queue.get_all_queue_stats().await?;
141 self.last_poll = Some(now);
142
143 let jobs = self.fresh_changes(changes, since);
144 let queues = self.changed_queues(stats);
145 let outcome = PollOutcome {
146 jobs: jobs.len(),
147 queues: queues.len(),
148 };
149
150 let websocket = self.websocket.read().await;
151 for job in jobs {
152 websocket
153 .broadcast_to_subscribed(ServerMessage::JobUpdate { job }, "job_updates")
154 .await
155 .map_err(|e| hammerwork::HammerworkError::Processing(e.to_string()))?;
156 }
157 for (queue_name, stats) in queues {
158 websocket
159 .broadcast_to_subscribed(
160 ServerMessage::QueueUpdate { queue_name, stats },
161 "queue_updates",
162 )
163 .await
164 .map_err(|e| hammerwork::HammerworkError::Processing(e.to_string()))?;
165 }
166 if outcome != PollOutcome::default() {
167 debug!(?outcome, "Pushed dashboard live updates");
168 }
169 Ok(outcome)
170 }
171
172 fn left_running(&self, changes: &[JobUpdate], running: &[JobUpdate]) -> Vec<String> {
177 let seen: HashSet<&str> = changes
178 .iter()
179 .chain(running)
180 .map(|job| job.id.as_str())
181 .collect();
182 let mut left: Vec<String> = self
183 .running
184 .iter()
185 .filter(|id| !seen.contains(id.as_str()))
186 .cloned()
187 .collect();
188 left.sort_unstable();
189 left
190 }
191
192 fn stopped_running(
198 left: &[String],
199 states: Vec<JobUpdate>,
200 now: DateTime<Utc>,
201 ) -> (Vec<String>, Vec<JobUpdate>) {
202 let left: HashSet<&str> = left.iter().map(String::as_str).collect();
203 let (running, stopped): (Vec<JobUpdate>, Vec<JobUpdate>) = states
204 .into_iter()
205 .filter(|job| left.contains(job.id.as_str()))
206 .partition(|job| job.status == "Running");
207 let stopped = stopped
208 .into_iter()
209 .map(|mut job| {
210 job.updated_at = now;
211 job
212 })
213 .collect();
214 (running.into_iter().map(|job| job.id).collect(), stopped)
215 }
216
217 fn fresh_changes(&mut self, changes: Vec<JobUpdate>, since: DateTime<Utc>) -> Vec<JobUpdate> {
220 self.sent
221 .retain(|_, (_, _, changed_at)| *changed_at > since);
222 let mut fresh: Vec<JobUpdate> = changes
223 .into_iter()
224 .filter(|job| {
225 let state = (job.status.clone(), job.attempts, job.updated_at);
226 self.sent.insert(job.id.clone(), state.clone()) != Some(state)
227 })
228 .collect();
229 fresh.sort_by_key(|job| job.updated_at);
230 fresh
231 }
232
233 fn changed_queues(
235 &mut self,
236 stats: Vec<hammerwork::stats::QueueStats>,
237 ) -> Vec<(String, QueueStats)> {
238 let present: HashSet<&str> = stats.iter().map(|s| s.queue_name.as_str()).collect();
239 self.queues
240 .retain(|name, _| present.contains(name.as_str()));
241 let mut changed = Vec::new();
242 for queue in stats {
243 let counts = [
244 queue.pending_count,
245 queue.running_count,
246 queue.completed_count,
247 queue.statistics.failed,
248 queue.dead_count,
249 queue.timed_out_count,
250 ];
251 if self.queues.insert(queue.queue_name.clone(), counts) == Some(counts) {
252 continue;
253 }
254 let failed_count = crate::api::failed_job_count(&queue);
255 changed.push((
256 queue.queue_name,
257 QueueStats {
258 pending_count: queue.pending_count,
259 running_count: queue.running_count,
260 completed_count: queue.completed_count,
261 failed_count,
262 dead_count: queue.dead_count,
263 throughput_per_minute: queue.statistics.throughput_per_minute,
264 avg_processing_time_ms: queue.statistics.avg_processing_time_ms,
265 error_rate: queue.statistics.error_rate,
266 updated_at: Utc::now(),
267 },
268 ));
269 }
270 changed
271 }
272}
273
274pub fn forward_job_events(
283 websocket: Arc<RwLock<WebSocketState>>,
284 mut subscription: EventSubscription,
285) -> JoinHandle<()> {
286 use tokio::sync::broadcast::error::RecvError;
287 tokio::spawn(async move {
288 loop {
289 match subscription.receiver.recv().await {
290 Ok(event) if subscription.filter.matches(&event) => {
291 let message = ServerMessage::JobUpdate {
292 job: job_update_from_event(&event),
293 };
294 let state = websocket.read().await;
295 if let Err(e) = state.broadcast_to_subscribed(message, "job_updates").await {
296 warn!(error = %e, "Failed to forward a job event to dashboard clients");
297 }
298 }
299 Ok(_) => {}
300 Err(RecvError::Lagged(missed)) => {
301 debug!(missed, "Dashboard event forwarding fell behind");
302 }
303 Err(RecvError::Closed) => break,
304 }
305 }
306 })
307}
308
309pub fn job_update_from_event(event: &JobLifecycleEvent) -> JobUpdate {
311 use JobLifecycleEventType as E;
312 let status = match event.event_type {
313 E::Enqueued | E::Retried | E::Restored => "Pending",
314 E::Started => "Running",
315 E::Completed => "Completed",
316 E::Failed => "Failed",
317 E::Dead => "Dead",
318 E::TimedOut => "TimedOut",
319 E::Cancelled => "Cancelled",
320 E::Archived => "Archived",
321 };
322 let attempts = event
323 .error
324 .as_ref()
325 .and_then(|error| error.retry_attempt)
326 .and_then(|attempt| i32::try_from(attempt).ok())
327 .unwrap_or_default();
328 JobUpdate {
329 id: event.job_id.to_string(),
330 queue_name: event.queue_name.clone(),
331 status: status.to_string(),
332 priority: event.priority.to_string(),
333 attempts,
334 updated_at: event.timestamp,
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::config::WebSocketConfig;
342 use hammerwork::events::{EventFilter, EventManager, JobLifecycleEventBuilder};
343 use hammerwork::priority::JobPriority;
344 use uuid::Uuid;
345 use warp::Filter;
346
347 type Shared = Arc<RwLock<WebSocketState>>;
348
349 fn new_state() -> Shared {
350 Arc::new(RwLock::new(WebSocketState::new(WebSocketConfig::default())))
351 }
352
353 async fn connect(state: &Shared) -> warp::test::WsClient {
354 let state = state.clone();
355 let route = warp::path("ws")
356 .and(warp::ws())
357 .map(move |ws: warp::ws::Ws| {
358 let state = state.clone();
359 ws.on_upgrade(move |socket| async move {
360 let _ = WebSocketState::serve_connection(state, socket).await;
361 })
362 });
363 warp::test::ws()
364 .path("/ws")
365 .handshake(route)
366 .await
367 .expect("handshake")
368 }
369
370 async fn wait_for_connections(state: &Shared, expected: usize) {
371 for _ in 0..200 {
372 if state.read().await.connection_count() == expected {
373 return;
374 }
375 tokio::time::sleep(Duration::from_millis(10)).await;
376 }
377 panic!("expected {expected} connections");
378 }
379
380 async fn next_json(client: &mut warp::test::WsClient) -> Option<serde_json::Value> {
382 match tokio::time::timeout(Duration::from_millis(500), client.recv()).await {
383 Ok(Ok(message)) if message.is_text() => {
384 Some(serde_json::from_str(message.to_str().unwrap()).unwrap())
385 }
386 _ => None,
387 }
388 }
389
390 async fn subscribe(client: &mut warp::test::WsClient, event_types: &[&str]) {
392 let message = serde_json::json!({"type": "Subscribe", "event_types": event_types});
393 client.send_text(message.to_string()).await;
394 tokio::time::sleep(Duration::from_millis(100)).await;
395 }
396
397 #[test]
398 fn job_events_map_to_dashboard_statuses() {
399 let id = Uuid::new_v4();
400 let event = JobLifecycleEvent::failed(
401 id,
402 "emails".to_string(),
403 JobPriority::High,
404 hammerwork::events::JobError {
405 message: "boom".to_string(),
406 error_type: None,
407 details: None,
408 retry_attempt: Some(2),
409 },
410 );
411 let update = job_update_from_event(&event);
412 assert_eq!(update.id, id.to_string());
413 assert_eq!(update.queue_name, "emails");
414 assert_eq!(update.status, "Failed");
415 assert_eq!(update.priority, "high");
416 assert_eq!(update.attempts, 2);
417 assert_eq!(update.updated_at, event.timestamp);
418
419 let completed =
420 JobLifecycleEvent::completed(id, "emails".to_string(), JobPriority::Normal, 12);
421 assert_eq!(job_update_from_event(&completed).status, "Completed");
422 let started = JobLifecycleEvent::started(id, "emails".to_string(), JobPriority::Normal);
423 assert_eq!(job_update_from_event(&started).status, "Running");
424 }
425
426 #[tokio::test]
429 async fn in_process_job_events_reach_subscribed_clients() {
430 let state = new_state();
431 let events = EventManager::new_default();
432 let subscription = events.subscribe(EventFilter::new()).await.unwrap();
433 let forwarder = forward_job_events(state.clone(), subscription);
434
435 let mut jobs = connect(&state).await;
436 let mut archive_only = connect(&state).await;
437 wait_for_connections(&state, 2).await;
438 subscribe(&mut jobs, &["job_updates"]).await;
439 subscribe(&mut archive_only, &["archive_events"]).await;
440
441 let id = Uuid::new_v4();
442 events
443 .publish_event(JobLifecycleEvent::completed(
444 id,
445 "emails".to_string(),
446 JobPriority::Normal,
447 5,
448 ))
449 .await
450 .unwrap();
451
452 let message = next_json(&mut jobs).await.expect("job update delivered");
453 assert_eq!(message["type"], "JobUpdate");
454 assert_eq!(message["job"]["id"], id.to_string());
455 assert_eq!(message["job"]["status"], "Completed");
456 assert!(next_json(&mut archive_only).await.is_none());
457
458 drop(events);
460 tokio::time::timeout(Duration::from_secs(2), forwarder)
461 .await
462 .expect("forwarder stopped")
463 .unwrap();
464 }
465
466 fn change(id: &str, status: &str, at: DateTime<Utc>) -> JobUpdate {
467 JobUpdate {
468 id: id.to_string(),
469 queue_name: "q".to_string(),
470 status: status.to_string(),
471 priority: "normal".to_string(),
472 attempts: 0,
473 updated_at: at,
474 }
475 }
476
477 #[tokio::test]
481 async fn overlapping_polls_push_each_change_once() {
482 let mut live = LiveUpdates::new(
483 crate::api::test_support::unreachable_queue(),
484 new_state(),
485 10,
486 );
487 let t0 = Utc::now();
488 let first = live.fresh_changes(
489 vec![
490 change("b", "Running", t0),
491 change("a", "Pending", t0 - POLL_OVERLAP / 2),
492 ],
493 t0 - POLL_OVERLAP,
494 );
495 let ids: Vec<&str> = first.iter().map(|j| j.id.as_str()).collect();
496 assert_eq!(ids, ["a", "b"], "oldest first");
497
498 let t1 = t0 + chrono::Duration::seconds(1);
499 let second = live.fresh_changes(
500 vec![
501 change("a", "Running", t1),
502 change("b", "Running", t0),
503 change("c", "Pending", t1),
504 ],
505 t1 - POLL_OVERLAP,
506 );
507 let pushed: Vec<(&str, &str)> = second
508 .iter()
509 .map(|j| (j.id.as_str(), j.status.as_str()))
510 .collect();
511 assert_eq!(pushed, [("a", "Running"), ("c", "Pending")]);
512
513 live.fresh_changes(Vec::new(), t1 + chrono::Duration::hours(1));
515 assert!(live.sent.is_empty());
516 }
517
518 #[tokio::test]
519 async fn queue_statistics_are_pushed_when_they_change() {
520 let mut live = LiveUpdates::new(
521 crate::api::test_support::unreachable_queue(),
522 new_state(),
523 10,
524 );
525 let stats = |name: &str, pending: u64| hammerwork::stats::QueueStats {
526 queue_name: name.to_string(),
527 pending_count: pending,
528 running_count: 0,
529 dead_count: 0,
530 timed_out_count: 0,
531 completed_count: 0,
532 statistics: Default::default(),
533 };
534 let first = live.changed_queues(vec![stats("a", 1), stats("b", 0)]);
535 assert_eq!(first.len(), 2);
536 let second = live.changed_queues(vec![stats("a", 1), stats("b", 3)]);
537 assert_eq!(second.len(), 1);
538 assert_eq!(second[0].0, "b");
539 assert_eq!(second[0].1.pending_count, 3);
540 live.changed_queues(vec![stats("a", 1)]);
542 assert_eq!(live.queues.len(), 1);
543
544 let mut failing = stats("a", 1);
547 failing.statistics.failed = 2;
548 failing.dead_count = 3;
549 failing.timed_out_count = 4;
550 live.changed_queues(vec![failing.clone()]);
551 let mut more_failed = failing.clone();
552 more_failed.statistics.failed = 5;
553 let changed = live.changed_queues(vec![more_failed]);
554 assert_eq!(changed.len(), 1, "a change in Failed jobs is pushed");
555 assert_eq!(changed[0].1.failed_count, 5 + 3 + 4);
556 assert_eq!(changed[0].1.dead_count, 3);
557 }
558
559 #[tokio::test]
562 async fn without_clients_nothing_is_polled() {
563 let mut live = LiveUpdates::new(
564 crate::api::test_support::unreachable_queue(),
565 new_state(),
566 10,
567 );
568 assert_eq!(live.poll_once().await.unwrap(), PollOutcome::default());
569 }
570
571 async fn clients_receive_job_state_changes<Q: JobHistory + 'static>(queue: Arc<Q>) {
574 use hammerwork::Job;
575 let state = new_state();
576 let mut live = LiveUpdates::new(queue.clone(), state.clone(), 100);
577 let mut jobs = connect(&state).await;
578 let mut alerts_only = connect(&state).await;
579 wait_for_connections(&state, 2).await;
580 subscribe(&mut jobs, &["job_updates"]).await;
581 subscribe(&mut alerts_only, &["system_alerts"]).await;
582 live.poll_once().await.unwrap();
583 while next_json(&mut jobs).await.is_some() {}
585
586 let queue_name = format!("live_{}", Uuid::new_v4().simple());
587 let id = queue
588 .enqueue(Job::new(queue_name.clone(), serde_json::json!({})))
589 .await
590 .unwrap();
591 let pending = wait_for(&mut live, &mut jobs, id, "Pending").await;
592 assert_eq!(pending["job"]["queue_name"], queue_name.as_str());
593
594 let run = queue.dequeue(&queue_name).await.unwrap().expect("job");
595 queue
596 .finish_job_run(&run, hammerwork::queue::JobOutcome::Completed)
597 .await
598 .unwrap();
599 wait_for(&mut live, &mut jobs, id, "Completed").await;
600 assert!(
601 next_json(&mut alerts_only).await.is_none(),
602 "not subscribed to job updates"
603 );
604
605 queue.delete_job(id).await.unwrap();
606 }
607
608 async fn clients_see_running_jobs_go_back_to_pending<Q: JobHistory + 'static>(queue: Arc<Q>) {
612 use hammerwork::Job;
613 let state = new_state();
614 let mut live = LiveUpdates::new(queue.clone(), state.clone(), 100);
615 let mut jobs = connect(&state).await;
616 wait_for_connections(&state, 1).await;
617 subscribe(&mut jobs, &["job_updates"]).await;
618 live.poll_once().await.unwrap();
619 while next_json(&mut jobs).await.is_some() {}
620
621 let queue_name = format!("live_back_{}", Uuid::new_v4().simple());
622 let mut job = Job::new(queue_name.clone(), serde_json::json!({}));
623 job.created_at = Utc::now() - chrono::Duration::hours(1);
624 job.scheduled_at = job.created_at;
625 let id = queue.enqueue(job).await.unwrap();
626 let cleanup = CleanupJob {
627 queue: queue.clone(),
628 id,
629 };
630
631 let run = queue.dequeue(&queue_name).await.unwrap().expect("job");
632 wait_for(&mut live, &mut jobs, id, "Running").await;
633 assert!(queue.release_job_run(&run).await.unwrap());
634 let released = wait_for(&mut live, &mut jobs, id, "Pending").await;
635 assert_eq!(released["job"]["attempts"], 0);
636
637 let run = queue.dequeue(&queue_name).await.unwrap().expect("job");
639 wait_for(&mut live, &mut jobs, id, "Running").await;
640 queue
641 .finish_job_run(
642 &run,
643 hammerwork::queue::JobOutcome::Retry {
644 retry_at: Utc::now() + chrono::Duration::hours(1),
645 error: "again".to_string(),
646 timed_out: false,
647 },
648 )
649 .await
650 .unwrap();
651 let retried = wait_for(&mut live, &mut jobs, id, "Pending").await;
652 assert_eq!(retried["job"]["attempts"], 1);
653 drop(cleanup);
654 }
655
656 struct CleanupJob<Q: JobHistory + 'static> {
658 queue: Arc<Q>,
659 id: Uuid,
660 }
661
662 impl<Q: JobHistory + 'static> Drop for CleanupJob<Q> {
663 fn drop(&mut self) {
664 let (queue, id) = (self.queue.clone(), self.id);
665 let _ = tokio::task::block_in_place(|| {
667 tokio::runtime::Handle::current().block_on(queue.delete_job(id))
668 });
669 }
670 }
671
672 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
673 #[ignore = "requires DATABASE_URL (PostgreSQL)"]
674 async fn postgres_clients_see_running_jobs_go_back_to_pending() {
675 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
676 let pool = sqlx::PgPool::connect(&url).await.unwrap();
677 clients_see_running_jobs_go_back_to_pending(Arc::new(hammerwork::JobQueue::new(pool)))
678 .await;
679 }
680
681 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
682 #[ignore = "requires MYSQL_DATABASE_URL"]
683 async fn mysql_clients_see_running_jobs_go_back_to_pending() {
684 let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
685 let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
686 clients_see_running_jobs_go_back_to_pending(Arc::new(hammerwork::JobQueue::new(pool)))
687 .await;
688 }
689
690 #[tokio::test]
694 async fn jobs_that_stop_running_without_a_timestamp_are_reported() {
695 let mut live = LiveUpdates::new(
696 crate::api::test_support::unreachable_queue(),
697 new_state(),
698 10,
699 );
700 let t0 = Utc::now();
701 live.running = ["a", "b", "c", "d"].map(String::from).into_iter().collect();
702 let left = live.left_running(
703 &[change("d", "Completed", t0)],
704 &[change("c", "Running", t0)],
705 );
706 assert_eq!(left, ["a", "b"]);
707
708 let now = t0 + chrono::Duration::seconds(5);
709 let (still_running, stopped) =
710 LiveUpdates::<hammerwork::JobQueue<sqlx::Postgres>>::stopped_running(
711 &left,
712 vec![
713 change("a", "Pending", t0 - chrono::Duration::hours(1)),
714 change("b", "Running", t0),
715 change("x", "Pending", t0),
716 ],
717 now,
718 );
719 assert_eq!(still_running, ["b"]);
720 assert_eq!(stopped.len(), 1);
721 assert_eq!(stopped[0].id, "a");
722 assert_eq!(stopped[0].status, "Pending");
723 assert_eq!(
724 stopped[0].updated_at, now,
725 "reported when the change was seen"
726 );
727 }
728
729 async fn wait_for<Q: JobHistory + 'static>(
732 live: &mut LiveUpdates<Q>,
733 client: &mut warp::test::WsClient,
734 id: Uuid,
735 status: &str,
736 ) -> serde_json::Value {
737 for _ in 0..20 {
738 live.poll_once().await.unwrap();
739 while let Some(message) = next_json(client).await {
740 assert_eq!(message["type"], "JobUpdate", "{message}");
741 if message["job"]["id"] == id.to_string().as_str()
742 && message["job"]["status"] == status
743 {
744 return message;
745 }
746 }
747 }
748 panic!("no {status} update for job {id}");
749 }
750
751 #[tokio::test]
752 #[ignore = "requires DATABASE_URL (PostgreSQL)"]
753 async fn postgres_clients_receive_job_state_changes() {
754 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
755 let pool = sqlx::PgPool::connect(&url).await.unwrap();
756 clients_receive_job_state_changes(Arc::new(hammerwork::JobQueue::new(pool))).await;
757 }
758
759 #[tokio::test]
760 #[ignore = "requires MYSQL_DATABASE_URL"]
761 async fn mysql_clients_receive_job_state_changes() {
762 let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
763 let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
764 clients_receive_job_state_changes(Arc::new(hammerwork::JobQueue::new(pool))).await;
765 }
766}