Repository: graphprotocol/indexer-rs
Pull request #691: Fix: Temporarily disable Horizon, enhance agent stability and logging
Author: login: neithanmo
Created: 2025-04-14T19:23:50Z | Merged: 2025-04-18T17:10:50Z

This PR temporarily disables Horizon support within the TAP agent to prevent potential issues due to its incomplete implementation. It also includes several improvements to enhance overall agent stability, error handling, and logging clarity.

Key Changes:

1.  **Forcibly Disable Horizon:** Overrides the `horizon_enabled` configuration setting to `false` in `agent.rs` upon startup, ensuring Horizon features remain inactive regardless of the user's config file settings.
2.  **Operator Warning for Override:** Adds a `tracing::warn!` message to notify operators if Horizon was enabled in their configuration, explaining that it has been forcibly disabled temporarily.
3.  **Conditional Horizon Components:** Prevents the creation and initialization of Horizon-specific components (like `pglistener_v2`, the V2 escrow accounts `watch_pipe`, and the V2 new receipts watcher) if `horizon_enabled` is false.
4.  **Prevent Horizon Actor Restarts:** Stops the `SenderAccountsManager` from attempting to restart failed actors of type `SenderType::Horizon` when Horizon support is disabled.
5.  **Safer Database Result Handling:** Introduces checks (`if let Some(...)`, `is_empty()`) when processing aggregated `allocation_ids` from the database (for both V1 and V2 unfinalized RAVs) to gracefully handle cases where the `ARRAY_AGG` might return `NULL` (no matching rows), preventing potential panics.
6.  **Improved Logging Specificity:** Enhances various `tracing::error!` and `.expect()` messages related to receipt storage, database queries, and sender timeouts by adding explicit "V1" or "V2" identifiers, making it easier to diagnose issues related to legacy vs. Horizon paths.


Diff:
diff --git a/crates/service/src/tap/receipt_store.rs b/crates/service/src/tap/receipt_store.rs
index 3efa63ccf..e244bb325 100644
--- a/crates/service/src/tap/receipt_store.rs
+++ b/crates/service/src/tap/receipt_store.rs
@@ -112,7 +112,7 @@ impl InnerContext {
         .execute(&self.pgpool)
         .await
         .map_err(|e| {
-            tracing::error!("Failed to store receipt: {}", e);
+            tracing::error!("Failed to store V1 receipt: {}", e);
             anyhow!(e)
         })?;
 
@@ -180,7 +180,7 @@ impl InnerContext {
         .execute(&self.pgpool)
         .await
         .map_err(|e| {
-            tracing::error!("Failed to store receipt: {}", e);
+            tracing::error!("Failed to store V2 receipt: {}", e);
             anyhow!(e)
         })?;
 
diff --git a/crates/tap-agent/src/agent.rs b/crates/tap-agent/src/agent.rs
index 9fa6143f5..5c9fa3724 100644
--- a/crates/tap-agent/src/agent.rs
+++ b/crates/tap-agent/src/agent.rs
@@ -174,7 +174,21 @@ pub async fn start_agent() -> (ActorRef<SenderAccountsManagerMessage>, JoinHandl
     .await
     .expect("Error creating escrow_accounts channel");
 
-    let config = Box::leak(Box::new(SenderAccountConfig::from_config(&CONFIG)));
+    let config = Box::leak(Box::new({
+        let mut config = SenderAccountConfig::from_config(&CONFIG);
+        // FIXME: This is a temporary measure to disable
+        // Horizon, even if enabled through our configuration file.
+        // Force disable Horizon support
+        config.horizon_enabled = false;
+        // Add a warning log so operators know their setting was ignore
+        if CONFIG.horizon.enabled {
+            tracing::warn!(
+            "Horizon support is configured as enabled but has been forcibly disabled as it's not fully supported yet. \
+            This is a temporary measure until Horizon support is stable."
+        );
+        }
+        config
+    }));
 
     let args = SenderAccountsManagerArgs {
         config,
diff --git a/crates/tap-agent/src/agent/sender_accounts_manager.rs b/crates/tap-agent/src/agent/sender_accounts_manager.rs
index 1ad28617e..25d761ad5 100644
--- a/crates/tap-agent/src/agent/sender_accounts_manager.rs
+++ b/crates/tap-agent/src/agent/sender_accounts_manager.rs
@@ -191,13 +191,20 @@ impl Actor for SenderAccountsManager {
             allocation_id
                 .keys()
                 .cloned()
-                // TODO map based on the allocation type returned by the subgraph
+                // TODO: map based on the allocation type returned by the subgraph
                 .map(AllocationId::Legacy)
                 .collect::<HashSet<_>>()
         });
         // we need two connections because each one will listen to different notify events
         let pglistener_v1 = PgListener::connect_with(&pgpool.clone()).await.unwrap();
-        let pglistener_v2 = PgListener::connect_with(&pgpool.clone()).await.unwrap();
+
+        // Extra safety, we don't want to have a listener if horizon is not enabled
+        let pglistener_v2 = if config.horizon_enabled {
+            Some(PgListener::connect_with(&pgpool.clone()).await.unwrap())
+        } else {
+            None
+        };
+
         let myself_clone = myself.clone();
         let accounts_clone = escrow_accounts_v1.clone();
         watch_pipe(accounts_clone, move |escrow_accounts| {
@@ -212,19 +219,23 @@ impl Actor for SenderAccountsManager {
             async {}
         });
 
-        let myself_clone = myself.clone();
-        let _escrow_accounts_v2 = escrow_accounts_v2.clone();
-        watch_pipe(_escrow_accounts_v2, move |escrow_accounts| {
-            let senders = escrow_accounts.get_senders();
-            myself_clone
-                .cast(SenderAccountsManagerMessage::UpdateSenderAccountsV2(
-                    senders,
-                ))
-                .unwrap_or_else(|e| {
-                    tracing::error!("Error while updating sender_accounts v2: {:?}", e);
-                });
-            async {}
-        });
+        // Extra safety, we don't want to have a
+        // escrow account listener if horizon is not enabled
+        if config.horizon_enabled {
+            let myself_clone = myself.clone();
+            let _escrow_accounts_v2 = escrow_accounts_v2.clone();
+            watch_pipe(_escrow_accounts_v2, move |escrow_accounts| {
+                let senders = escrow_accounts.get_senders();
+                myself_clone
+                    .cast(SenderAccountsManagerMessage::UpdateSenderAccountsV2(
+                        senders,
+                    ))
+                    .unwrap_or_else(|e| {
+                        tracing::error!("Error while updating sender_accounts v2: {:?}", e);
+                    });
+                async {}
+            });
+        }
 
         let mut state = State {
             config,
@@ -305,17 +316,18 @@ impl Actor for SenderAccountsManager {
         // after starting all senders
         state.new_receipts_watcher_handle_v2 = None;
 
-        if state.config.horizon_enabled {
+        // Extra safety, we don't want to have a listener if horizon is not enabled
+        if let Some(listener_v2) = pglistener_v2 {
             state.new_receipts_watcher_handle_v2 = Some(tokio::spawn(
                 new_receipts_watcher()
                     .actor_cell(myself.get_cell())
-                    .pglistener(pglistener_v2)
+                    .pglistener(listener_v2)
                     .escrow_accounts_rx(escrow_accounts_v2)
                     .sender_type(SenderType::Horizon)
                     .maybe_prefix(prefix)
                     .call(),
             ));
-        }
+        };
 
         tracing::info!("SenderAccountManager created!");
         Ok(state)
@@ -446,18 +458,41 @@ impl Actor for SenderAccountsManager {
                     }
                 };
 
-                let mut sender_allocation = select! {
-                    sender_allocation = state.get_pending_sender_allocation_id_v1() => sender_allocation,
-                    _ = tokio::time::sleep(state.config.tap_sender_timeout) => {
-                        tracing::error!("Timeout while getting pending sender allocation ids");
-                        return Ok(());
+                // Get the sender's allocations taking into account
+                // the sender type
+                let allocations = match sender_type {
+                    SenderType::Legacy => {
+                        let mut sender_allocation = select! {
+                            sender_allocation = state.get_pending_sender_allocation_id_v1() => sender_allocation,
+                            _ = tokio::time::sleep(state.config.tap_sender_timeout) => {
+                                tracing::error!(version = "V1", "Timeout while getting pending sender allocation ids");
+                                return Ok(());
+                            }
+                        };
+                        sender_allocation
+                            .remove(&sender_id)
+                            .unwrap_or(HashSet::new())
+                    }
+                    SenderType::Horizon => {
+                        if !state.config.horizon_enabled {
+                            tracing::info!(%sender_id, "Horizon sender failed but horizon is disabled, not restarting");
+
+                            return Ok(());
+                        }
+
+                        let mut sender_allocation = select! {
+                            sender_allocation = state.get_pending_sender_allocation_id_v2() => sender_allocation,
+                            _ = tokio::time::sleep(state.config.tap_sender_timeout) => {
+                                tracing::error!(version = "V2", "Timeout while getting pending sender allocation ids");
+                                return Ok(());
+                            }
+                        };
+                        sender_allocation
+                            .remove(&sender_id)
+                            .unwrap_or(HashSet::new())
                     }
                 };
 
-                let allocations = sender_allocation
-                    .remove(&sender_id)
-                    .unwrap_or(HashSet::new());
-
                 state
                     .create_or_deny_sender(myself.get_cell(), sender_id, allocations, sender_type)
                     .await;
@@ -575,12 +610,12 @@ impl State {
         )
         .fetch_all(&self.pgpool)
         .await
-        .expect("should be able to fetch pending receipts from the database");
+        .expect("should be able to fetch pending receipts V1 from the database");
 
         for row in receipts_signer_allocations_in_db {
             let allocation_ids = row
                 .allocation_ids
-                .expect("all receipts should have an allocation_id")
+                .expect("all receipts V1 should have an allocation_id")
                 .iter()
                 .map(|allocation_id| {
                     AllocationId::Legacy(
@@ -615,28 +650,38 @@ impl State {
         )
         .fetch_all(&self.pgpool)
         .await
-        .expect("should be able to fetch unfinalized RAVs from the database");
+        .expect("should be able to fetch unfinalized RAVs V1 from the database");
 
         for row in nonfinal_ravs_sender_allocations_in_db {
-            let allocation_ids = row
-                .allocation_ids
-                .expect("all RAVs should have an allocation_id")
-                .iter()
-                .map(|allocation_id| {
-                    AllocationId::Legacy(
-                        Address::from_str(allocation_id)
-                            .expect("allocation_id should be a valid address"),
-                    )
-                })
-                .collect::<HashSet<_>>();
-            let sender_id = Address::from_str(&row.sender_address)
-                .expect("sender_address should be a valid address");
+            // Check if allocation_ids is Some before processing,
+            // as ARRAY_AGG with FILTER returns NULL
+            // instead of an empty array
+            if let Some(allocation_id_strings) = row.allocation_ids {
+                let allocation_ids = allocation_id_strings
+                    .iter()
+                    .map(|allocation_id| {
+                        AllocationId::Legacy(
+                            Address::from_str(allocation_id)
+                                .expect("allocation_id should be a valid address"),
+                        )
+                    })
+                    .collect::<HashSet<_>>();
 
-            // Accumulate allocations for the sender
-            unfinalized_sender_allocations_map
-                .entry(sender_id)
-                .or_default()
-                .extend(allocation_ids);
+                if !allocation_ids.is_empty() {
+                    let sender_id = Address::from_str(&row.sender_address)
+                        .expect("sender_address should be a valid address");
+
+                    unfinalized_sender_allocations_map
+                        .entry(sender_id)
+                        .or_default()
+                        .extend(allocation_ids);
+                }
+            } else {
+                // Log the case when allocation_ids is NULL
+                tracing::warn!(
+                    "Found NULL allocation_ids. This may indicate all RAVs are finalized."
+                );
+            }
         }
         unfinalized_sender_allocations_map
     }
@@ -668,12 +713,12 @@ impl State {
         )
         .fetch_all(&self.pgpool)
         .await
-        .expect("should be able to fetch pending receipts from the database");
+        .expect("should be able to fetch pending V2 receipts from the database");
 
         for row in receipts_signer_allocations_in_db {
             let allocation_ids = row
                 .allocation_ids
-                .expect("all receipts should have an allocation_id")
+                .expect("all receipts V2 should have an allocation_id")
                 .iter()
                 .map(|allocation_id| {
                     AllocationId::Legacy(
@@ -708,28 +753,38 @@ impl State {
         )
         .fetch_all(&self.pgpool)
         .await
-        .expect("should be able to fetch unfinalized RAVs from the database");
+        .expect("should be able to fetch unfinalized V2 RAVs from the database");
 
         for row in nonfinal_ravs_sender_allocations_in_db {
-            let allocation_ids = row
-                .allocation_ids
-                .expect("all RAVs should have an allocation_id")
-                .iter()
-                .map(|allocation_id| {
-                    AllocationId::Legacy(
-                        Address::from_str(allocation_id)
-                            .expect("allocation_id should be a valid address"),
-                    )
-                })
-                .collect::<HashSet<_>>();
-            let sender_id =
-                Address::from_str(&row.payer).expect("sender_address should be a valid address");
+            // Check if allocation_ids is Some before processing,
+            // as ARRAY_AGG with FILTER returns NULL instead of an
+            // empty array
+            if let Some(allocation_id_strings) = row.allocation_ids {
+                let allocation_ids = allocation_id_strings
+                    .iter()
+                    .map(|allocation_id| {
+                        AllocationId::Legacy(
+                            Address::from_str(allocation_id)
+                                .expect("allocation_id should be a valid address"),
+                        )
+                    })
+                    .collect::<HashSet<_>>();
 
-            // Accumulate allocations for the sender
-            unfinalized_sender_allocations_map
-                .entry(sender_id)
-                .or_default()
-                .extend(allocation_ids);
+                if !allocation_ids.is_empty() {
+                    let sender_id = Address::from_str(&row.payer)
+                        .expect("sender_address should be a valid address");
+
+                    unfinalized_sender_allocations_map
+                        .entry(sender_id)
+                        .or_default()
+                        .extend(allocation_ids);
+                }
+            } else {
+                // Log the case when allocation_ids is NULL
+                tracing::warn!(
+                    "Found NULL allocation_ids. This may indicate all RAVs are finalized."
+                );
+            }
         }
         unfinalized_sender_allocations_map
     }
@@ -1081,7 +1136,7 @@ mod tests {
     }
 
     #[sqlx::test(migrations = "../../migrations")]
-    async fn test_update_sender_allocation(pgpool: PgPool) {
+    async fn test_update_sender_account(pgpool: PgPool) {
         let (prefix, mut notify, (actor, join_handle)) =
             create_sender_accounts_manager().pgpool(pgpool).call().await;
 


Reviews:
user: login: suchapalaver
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T22:15:46Z
user: login: suchapalaver
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T22:16:19Z
user: login: suchapalaver
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T22:17:54Z
user: login: suchapalaver
body: Makes everything a lot clearer for now, on top of the useful added guardrails!
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T22:19:07Z
user: login: TypeLevelConsoli
body: I left a minor thing, but feel free to ignore it

Thanks for this!
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T22:52:24Z
user: login: suchapalaver
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:49:08Z
user: login: suchapalaver
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T15:49:28Z
user: login: TypeLevelConsoli
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T12:21:53Z
user: login: suchapalaver
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T17:10:49Z

Review comments:
diff_hunk: @@ -174,7 +174,21 @@ pub async fn start_agent() -> (ActorRef<SenderAccountsManagerMessage>, JoinHandl
     .await
     .expect("Error creating escrow_accounts channel");
 
-    let config = Box::leak(Box::new(SenderAccountConfig::from_config(&CONFIG)));
+    let config = Box::leak(Box::new({
+        let mut config = SenderAccountConfig::from_config(&CONFIG);
+        // FIXME: This is a temporary measure to disable
+        // Horizon, even if enable through our configuration file.
path: crates/tap-agent/src/agent.rs
user: login: suchapalaver
body: ```suggestion
        // Horizon, even if enabled through our configuration file.
```

Not a big deal though
created_at: 2025-04-15T22:15:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -191,13 +191,20 @@ impl Actor for SenderAccountsManager {
             allocation_id
                 .keys()
                 .cloned()
-                // TODO map based on the allocation type returned by the subgraph
+                // TODO: map based on the allocation type returned by the subgraph
                 .map(AllocationId::Legacy)
                 .collect::<HashSet<_>>()
         });
         // we need two connections because each one will listen to different notify events
         let pglistener_v1 = PgListener::connect_with(&pgpool.clone()).await.unwrap();
-        let pglistener_v2 = PgListener::connect_with(&pgpool.clone()).await.unwrap();
+
+        // Extra safety, we don't want to have a listener if horizon is not enabled
path: crates/tap-agent/src/agent/sender_accounts_manager.rs
user: login: suchapalaver
body: Really appreciate these doc comments 👍 
created_at: 2025-04-15T22:16:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -191,13 +191,20 @@ impl Actor for SenderAccountsManager {
             allocation_id
                 .keys()
                 .cloned()
-                // TODO map based on the allocation type returned by the subgraph
+                // TODO: map based on the allocation type returned by the subgraph
                 .map(AllocationId::Legacy)
                 .collect::<HashSet<_>>()
         });
         // we need two connections because each one will listen to different notify events
         let pglistener_v1 = PgListener::connect_with(&pgpool.clone()).await.unwrap();
-        let pglistener_v2 = PgListener::connect_with(&pgpool.clone()).await.unwrap();
+
+        // Extra safety, we don't want to have a listener if horizon is not enabled
+        let pglistener_v2 = if config.horizon_enabled {
+            Some(PgListener::connect_with(&pgpool.clone()).await.unwrap())
+        } else {
+            None
+        };
path: crates/tap-agent/src/agent/sender_accounts_manager.rs
user: login: suchapalaver
body: This makes the code a lot clearer too 👍 
created_at: 2025-04-15T22:17:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -446,18 +458,41 @@ impl Actor for SenderAccountsManager {
                     }
                 };
 
-                let mut sender_allocation = select! {
-                    sender_allocation = state.get_pending_sender_allocation_id_v1() => sender_allocation,
-                    _ = tokio::time::sleep(state.config.tap_sender_timeout) => {
-                        tracing::error!("Timeout while getting pending sender allocation ids");
-                        return Ok(());
+                // Get the sender's allocations taking into account
+                // the sender type
+                let allocations = match sender_type {
+                    SenderType::Legacy => {
+                        let mut sender_allocation = select! {
+                            sender_allocation = state.get_pending_sender_allocation_id_v1() => sender_allocation,
+                            _ = tokio::time::sleep(state.config.tap_sender_timeout) => {
+                                tracing::error!("Timeout while getting pending sender allocation ids");
+                                return Ok(());
+                            }
+                        };
+                        sender_allocation
+                            .remove(&sender_id)
+                            .unwrap_or(HashSet::new())
+                    }
+                    SenderType::Horizon => {
+                        if !state.config.horizon_enabled {
+                            tracing::info!(%sender_id, "Horizon sender failed but horizon is disabled, not restarting");
+
+                            return Ok(());
+                        }
+
+                        let mut sender_allocation = select! {
+                            sender_allocation = state.get_pending_sender_allocation_id_v2() => sender_allocation,
+                            _ = tokio::time::sleep(state.config.tap_sender_timeout) => {
+                                tracing::error!("Timeout while getting pending V2 sender allocation ids");
path: crates/tap-agent/src/agent/sender_accounts_manager.rs
user: login: TypeLevelConsoli
body: Maybe instead of logging `V1` and `V2` in the message body, we could add a field `version = "V1"` and `version = "V2"` to the error? not a big deal :)
created_at: 2025-04-15T22:50:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -174,7 +174,21 @@ pub async fn start_agent() -> (ActorRef<SenderAccountsManagerMessage>, JoinHandl
     .await
     .expect("Error creating escrow_accounts channel");
 
-    let config = Box::leak(Box::new(SenderAccountConfig::from_config(&CONFIG)));
+    let config = Box::leak(Box::new({
+        let mut config = SenderAccountConfig::from_config(&CONFIG);
+        // FIXME: This is a temporary measure to disable
+        // Horizon, even if enable through our configuration file.
+        // Force disable Horizon support
+        config.horizon_enabled = false;
+        // Add a warning log so operators know their setting was ignore
+        if CONFIG.horizon.enabled {
+            tracing::warn!(
+            "Horizon support is configured as enabled but has been forcibly disabled as it's not fully supported yet. \
+            This is a temporary measure until Horizon support is stable."
+        );
path: crates/tap-agent/src/agent.rs
user: login: TypeLevelConsoli
body: glad to have the warning!
created_at: 2025-04-15T22:51:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -615,28 +650,32 @@ impl State {
         )
         .fetch_all(&self.pgpool)
         .await
-        .expect("should be able to fetch unfinalized RAVs from the database");
+        .expect("should be able to fetch unfinalized RAVs V1 from the database");
 
         for row in nonfinal_ravs_sender_allocations_in_db {
-            let allocation_ids = row
-                .allocation_ids
-                .expect("all RAVs should have an allocation_id")
-                .iter()
-                .map(|allocation_id| {
-                    AllocationId::Legacy(
-                        Address::from_str(allocation_id)
-                            .expect("allocation_id should be a valid address"),
-                    )
-                })
-                .collect::<HashSet<_>>();
-            let sender_id = Address::from_str(&row.sender_address)
-                .expect("sender_address should be a valid address");
+            // Check if allocation_ids is Some before processing,
+            // as ARRAY_AGG with FILTER can return NULL
+            if let Some(allocation_id_strings) = row.allocation_ids {
+                let allocation_ids = allocation_id_strings
+                    .iter()
+                    .map(|allocation_id| {
+                        AllocationId::Legacy(
+                            Address::from_str(allocation_id)
+                                .expect("allocation_id should be a valid address"),
+                        )
+                    })
+                    .collect::<HashSet<_>>();
 
-            // Accumulate allocations for the sender
-            unfinalized_sender_allocations_map
-                .entry(sender_id)
-                .or_default()
-                .extend(allocation_ids);
+                if !allocation_ids.is_empty() {
+                    let sender_id = Address::from_str(&row.sender_address)
+                        .expect("sender_address should be a valid address");
+
+                    unfinalized_sender_allocations_map
+                        .entry(sender_id)
+                        .or_default()
+                        .extend(allocation_ids);
+                }
path: crates/tap-agent/src/agent/sender_accounts_manager.rs
user: login: suchapalaver
body: Do you think it’d be helpful to add some logging here—maybe even a warning? Or was the behavior that caused issues actually expected?
created_at: 2025-04-16T15:49:08Z
author_association: CONTRIBUTOR

PR comments:
user: login: coveralls
created_at: 2025-04-14T19:31:22Z
body: ## Pull Request Test Coverage Report for [Build 14536847548](https://coveralls.io/builds/73290549)


### Details

* **48** of **109**   **(44.04%)**  changed or added relevant lines in **3** files are covered.
* **4** unchanged lines in **1** file lost coverage.
* Overall coverage decreased (**-0.2%**) to **74.084%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [crates/service/src/tap/receipt_store.rs](https://coveralls.io/builds/73290549/source?filename=crates%2Fservice%2Fsrc%2Ftap%2Freceipt_store.rs#L115) | 1 | 2 | 50.0%
| [crates/tap-agent/src/agent.rs](https://coveralls.io/builds/73290549/source?filename=crates%2Ftap-agent%2Fsrc%2Fagent.rs#L177) | 0 | 14 | 0.0%
| [crates/tap-agent/src/agent/sender_accounts_manager.rs](https://coveralls.io/builds/73290549/source?filename=crates%2Ftap-agent%2Fsrc%2Fagent%2Fsender_accounts_manager.rs#L234) | 47 | 93 | 50.54%
<!-- | **Total:** | **48** | **109** | **44.04%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [crates/watcher/src/lib.rs](https://coveralls.io/builds/73290549/source?filename=crates%2Fwatcher%2Fsrc%2Flib.rs#L41) | 4 | 92.71% |
<!-- | **Total:** | **4** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/73290549/badge)](https://coveralls.io/builds/73290549) |
| :-- | --: |
| Change from base [Build 14456407381](https://coveralls.io/builds/73223651): |  -0.2% |
| Covered Lines: | 8876 |
| Relevant Lines: | 11981 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE
user: login: neithanmo
created_at: 2025-04-18T14:51:54Z
body: This fixes a crash reported by #686 
author_association: CONTRIBUTOR