@@ -29,14 +29,26 @@ use tokio::sync::mpsc;
2929/// Number of sharded notification workers.
3030/// Deterministic routing by table_id hash preserves per-table ordering
3131/// while achieving parallelism across different tables.
32- const NUM_NOTIFY_WORKERS : usize = 4 ;
32+ ///
33+ /// Scales with available CPUs (up to a hard cap) so multi-core deployments
34+ /// can fan out across more tables in parallel. Falls back to 4 on the
35+ /// (rare) platforms where `available_parallelism` is unavailable.
36+ fn num_notify_workers ( ) -> usize {
37+ // Cap at 16 to bound DashMap contention and worker overhead.
38+ // Minimum of 4 preserves previous baseline behavior on small machines.
39+ let cpus =
40+ std:: thread:: available_parallelism ( ) . map ( std:: num:: NonZeroUsize :: get) . unwrap_or ( 4 ) ;
41+ cpus. clamp ( 4 , 16 )
42+ }
3343
34- /// Per-worker queue capacity. Total capacity = NUM_NOTIFY_WORKERS × this value.
44+ /// Per-worker queue capacity. Total capacity = workers × this value.
3545const NOTIFY_QUEUE_PER_WORKER : usize = 4_096 ;
3646
37- /// Number of subscribers per parallel chunk for shared table streaming notification.
38- /// Tuned to amortize tokio::spawn overhead while achieving parallelism at scale.
39- const SHARED_NOTIFY_CHUNK_SIZE : usize = 256 ;
47+ /// Subscriber count above which we break fan-out into spawned chunks.
48+ /// For single-table fan-out at high subscriber counts (e.g. 100K on one
49+ /// table all hashing to one worker), spawning per-chunk lets the tokio
50+ /// runtime parallelise delivery across its thread pool.
51+ const SHARED_NOTIFY_CHUNK_SIZE : usize = 512 ;
4052
4153struct NotificationTask {
4254 user_id : Option < UserId > ,
@@ -274,10 +286,11 @@ impl NotificationService {
274286 }
275287
276288 pub fn new ( registry : Arc < ConnectionsManager > ) -> Arc < Self > {
277- let mut worker_txs = Vec :: with_capacity ( NUM_NOTIFY_WORKERS ) ;
278- let mut worker_rxs = Vec :: with_capacity ( NUM_NOTIFY_WORKERS ) ;
289+ let worker_count = num_notify_workers ( ) ;
290+ let mut worker_txs = Vec :: with_capacity ( worker_count) ;
291+ let mut worker_rxs = Vec :: with_capacity ( worker_count) ;
279292
280- for _ in 0 ..NUM_NOTIFY_WORKERS {
293+ for _ in 0 ..worker_count {
281294 let ( tx, rx) = mpsc:: channel ( NOTIFY_QUEUE_PER_WORKER ) ;
282295 worker_txs. push ( tx) ;
283296 worker_rxs. push ( rx) ;
@@ -424,35 +437,46 @@ impl NotificationService {
424437 ) ;
425438 }
426439
427- // Large fan-out: parallel chunked dispatch
428- // Large fan-out: collect handles once, then parallel chunked dispatch
429- let handles: Vec < SubscriptionHandle > =
440+ // Large fan-out: spawn a task per chunk so the tokio runtime can
441+ // parallelise delivery across its thread pool. When all subscribers
442+ // are on the same table they hash to one notification worker —
443+ // spawning is the only way to utilise multiple cores for the fan-out.
444+ let handles_vec: Vec < SubscriptionHandle > =
430445 all_handles. iter ( ) . map ( |entry| entry. value ( ) . clone ( ) ) . collect ( ) ;
431446
432- let mut join_handles = Vec :: with_capacity (
433- ( handles. len ( ) + SHARED_NOTIFY_CHUNK_SIZE - 1 ) / SHARED_NOTIFY_CHUNK_SIZE ,
434- ) ;
435-
436- for chunk in handles. chunks ( SHARED_NOTIFY_CHUNK_SIZE ) {
437- let chunk = chunk. to_vec ( ) ;
438- let nr = Arc :: clone ( & new_row) ;
439- let or = old_row. as_ref ( ) . map ( Arc :: clone) ;
440- let ct = change_type. clone ( ) ;
441- let pk = Arc :: clone ( & pk_columns) ;
442-
443- join_handles. push ( tokio:: spawn ( async move {
444- dispatch_chunk ( chunk. into_iter ( ) , & nr, or. as_deref ( ) , & ct, & pk, seq_value)
447+ let table_id = table_id. clone ( ) ;
448+ let mut tasks = Vec :: new ( ) ;
449+
450+ for chunk in handles_vec. chunks ( SHARED_NOTIFY_CHUNK_SIZE ) {
451+ let chunk_handles: Vec < SubscriptionHandle > = chunk. to_vec ( ) ;
452+ let new_row = Arc :: clone ( & new_row) ;
453+ let old_row = old_row. as_ref ( ) . map ( Arc :: clone) ;
454+ let change_type = change_type. clone ( ) ;
455+ let pk_columns = Arc :: clone ( & pk_columns) ;
456+ let table_id = table_id. clone ( ) ;
457+
458+ tasks. push ( tokio:: spawn ( async move {
459+ match dispatch_chunk (
460+ chunk_handles. into_iter ( ) ,
461+ & new_row,
462+ old_row. as_deref ( ) ,
463+ & change_type,
464+ & pk_columns,
465+ seq_value,
466+ ) {
467+ Ok ( count) => count,
468+ Err ( e) => {
469+ log:: error!( "Notification dispatch error for table {}: {}" , table_id, e) ;
470+ 0
471+ } ,
472+ }
445473 } ) ) ;
446474 }
447475
448476 let mut total = 0usize ;
449- for jh in join_handles {
450- match jh. await {
451- Ok ( Ok ( count) ) => total += count,
452- Ok ( Err ( e) ) => {
453- log:: error!( "Notification dispatch error for table {}: {}" , table_id, e) ;
454- } ,
455- Err ( e) => log:: error!( "Notification chunk task panicked: {}" , e) ,
477+ for task in tasks {
478+ if let Ok ( count) = task. await {
479+ total += count;
456480 }
457481 }
458482
0 commit comments