| 1 | module async |
| 2 | |
| 3 | import context |
| 4 | import sync |
| 5 | import time |
| 6 | |
| 7 | // PoolConfig configures a fixed-size concurrency pool. |
| 8 | // |
| 9 | // Both values must be positive. `workers` is the maximum number of jobs that |
| 10 | // can execute concurrently; `queue_size` is the bounded backlog accepted by |
| 11 | // try_submit() while all worker slots are busy. |
| 12 | @[params] |
| 13 | pub struct PoolConfig { |
| 14 | pub: |
| 15 | workers int |
| 16 | queue_size int |
| 17 | } |
| 18 | |
| 19 | // Pool limits concurrent JobFn execution with fixed worker slots and bounded backlog. |
| 20 | // |
| 21 | // The pool owns one derived context shared by all jobs. Closing the pool stops |
| 22 | // new submissions, waits for every accepted job, and returns the first job error |
| 23 | // if any. |
| 24 | @[heap] |
| 25 | pub struct Pool { |
| 26 | mut: |
| 27 | ctx context.Context |
| 28 | cancel context.CancelFn = unsafe { nil } |
| 29 | tokens chan bool |
| 30 | submit_ready chan int |
| 31 | wg &sync.WaitGroup = sync.new_waitgroup() |
| 32 | max_jobs int |
| 33 | // Protects lifecycle flags, accepted job count, WaitGroup.add(), and |
| 34 | // first_err. This lock is never held while a user JobFn runs. |
| 35 | mutex &sync.Mutex = sync.new_mutex() |
| 36 | first_err IError = none |
| 37 | closed bool |
| 38 | waited bool |
| 39 | accepted int |
| 40 | } |
| 41 | |
| 42 | // new_pool creates a Pool with a background parent context. |
| 43 | pub fn new_pool(config PoolConfig) !&Pool { |
| 44 | return new_pool_with_context(context.background(), config) |
| 45 | } |
| 46 | |
| 47 | // new_pool_with_context creates a Pool with a context derived from parent. |
| 48 | // |
| 49 | // The worker limit and queue size are fixed for the pool lifetime. Parent |
| 50 | // cancellation is cooperative: jobs must observe `ctx.done()` and return. |
| 51 | pub fn new_pool_with_context(parent context.Context, config PoolConfig) !&Pool { |
| 52 | if config.workers <= 0 { |
| 53 | return error(err_pool_workers_invalid) |
| 54 | } |
| 55 | if config.queue_size <= 0 { |
| 56 | return error(err_pool_queue_size_invalid) |
| 57 | } |
| 58 | ctx, cancel := new_cancel_context(parent) |
| 59 | mut pool := &Pool{ |
| 60 | ctx: context.Context(ctx) |
| 61 | cancel: cancel |
| 62 | tokens: chan bool{cap: config.workers} |
| 63 | submit_ready: chan int{cap: 1} |
| 64 | wg: sync.new_waitgroup() |
| 65 | max_jobs: config.workers + config.queue_size |
| 66 | mutex: sync.new_mutex() |
| 67 | } |
| 68 | for _ in 0 .. config.workers { |
| 69 | pool.tokens <- true |
| 70 | } |
| 71 | return pool |
| 72 | } |
| 73 | |
| 74 | // try_submit accepts f if the pool is open and its bounded backlog has capacity. |
| 75 | // |
| 76 | // It never blocks for queue space. A full backlog returns `async: pool queue is |
| 77 | // full`, making backpressure explicit for callers. |
| 78 | pub fn (mut p Pool) try_submit(f JobFn) ! { |
| 79 | if f == unsafe { nil } { |
| 80 | return error(err_nil_job) |
| 81 | } |
| 82 | p.mutex.lock() |
| 83 | if p.closed { |
| 84 | p.mutex.unlock() |
| 85 | return error(err_pool_closed) |
| 86 | } |
| 87 | if p.accepted >= p.max_jobs { |
| 88 | p.mutex.unlock() |
| 89 | return error(err_pool_queue_full) |
| 90 | } |
| 91 | p.accept_job_locked() |
| 92 | p.mutex.unlock() |
| 93 | // The JobFn is passed directly to the spawned wrapper instead of being |
| 94 | // stored in a channel. That keeps closure ownership with V's normal spawn |
| 95 | // path while the token channel below still enforces the fixed worker limit. |
| 96 | spawn run_pool_job(mut p, f) |
| 97 | } |
| 98 | |
| 99 | // submit_with_context waits until f can be accepted, parent is canceled, or the pool closes. |
| 100 | // |
| 101 | // The parent context bounds admission only. Once f is accepted, the job runs with |
| 102 | // the pool's own context, matching try_submit() and preserving pool lifecycle ownership. |
| 103 | pub fn (mut p Pool) submit_with_context(parent context.Context, f JobFn) ! { |
| 104 | if f == unsafe { nil } { |
| 105 | return error(err_nil_job) |
| 106 | } |
| 107 | mut submit_ctx := parent |
| 108 | initial_err := submit_ctx.err() |
| 109 | if initial_err !is none { |
| 110 | return initial_err |
| 111 | } |
| 112 | done := submit_ctx.done() |
| 113 | mut watch_done := true |
| 114 | select { |
| 115 | _ := <-done { |
| 116 | err := submit_ctx.err() |
| 117 | if err !is none { |
| 118 | return err |
| 119 | } |
| 120 | watch_done = false |
| 121 | } |
| 122 | else {} |
| 123 | } |
| 124 | for { |
| 125 | p.mutex.lock() |
| 126 | if p.closed { |
| 127 | p.mutex.unlock() |
| 128 | return error(err_pool_closed) |
| 129 | } |
| 130 | if watch_done { |
| 131 | err := submit_ctx.err() |
| 132 | if err !is none { |
| 133 | p.mutex.unlock() |
| 134 | return err |
| 135 | } |
| 136 | } |
| 137 | if p.accepted < p.max_jobs { |
| 138 | p.accept_job_locked() |
| 139 | p.mutex.unlock() |
| 140 | spawn run_pool_job(mut p, f) |
| 141 | return |
| 142 | } |
| 143 | submit_ready := p.submit_ready |
| 144 | p.mutex.unlock() |
| 145 | if !watch_done { |
| 146 | _ := <-submit_ready |
| 147 | continue |
| 148 | } |
| 149 | select { |
| 150 | _ := <-submit_ready { |
| 151 | continue |
| 152 | } |
| 153 | _ := <-done { |
| 154 | err := submit_ctx.err() |
| 155 | if err !is none { |
| 156 | return err |
| 157 | } |
| 158 | watch_done = false |
| 159 | continue |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // submit_with_timeout waits up to timeout for f to be accepted by the pool. |
| 166 | // |
| 167 | // The timeout bounds admission only. Accepted jobs still receive the pool context. |
| 168 | pub fn (mut p Pool) submit_with_timeout(timeout time.Duration, f JobFn) ! { |
| 169 | timeout_ctx, cancel := new_timeout_context(context.background(), timeout) |
| 170 | mut submit_ctx := context.Context(timeout_ctx) |
| 171 | defer { |
| 172 | cancel() |
| 173 | } |
| 174 | p.submit_with_context(submit_ctx, f) or { |
| 175 | if err.msg() == context_deadline_exceeded && timeout_ctx.was_canceled_by_timeout() { |
| 176 | return error(err_timeout) |
| 177 | } |
| 178 | return err |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // wait closes the pool to new submissions, drains accepted jobs, and waits for completion. |
| 183 | // |
| 184 | // wait is one-shot. It returns the first job error observed by any accepted job. |
| 185 | pub fn (mut p Pool) wait() ! { |
| 186 | p.mutex.lock() |
| 187 | if p.waited { |
| 188 | p.mutex.unlock() |
| 189 | return error(err_pool_wait_called) |
| 190 | } |
| 191 | p.waited = true |
| 192 | p.closed = true |
| 193 | p.wake_submitters_locked() |
| 194 | p.mutex.unlock() |
| 195 | |
| 196 | p.wg.wait() |
| 197 | p.cancel() |
| 198 | err := p.get_first_error() |
| 199 | if err !is none { |
| 200 | return err |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | // close is an explicit lifecycle alias for wait(). |
| 205 | // |
| 206 | // It rejects later submissions and waits for all accepted jobs before returning. |
| 207 | pub fn (mut p Pool) close() ! { |
| 208 | p.wait()! |
| 209 | } |
| 210 | |
| 211 | fn run_pool_job(mut p Pool, f JobFn) { |
| 212 | defer { |
| 213 | p.finish_accepted_job() |
| 214 | p.wg.done() |
| 215 | } |
| 216 | // The token channel is a bounded semaphore. At most `workers` accepted jobs |
| 217 | // can pass this point and run user code concurrently. |
| 218 | _ := <-p.tokens |
| 219 | defer { |
| 220 | p.tokens <- true |
| 221 | } |
| 222 | mut job_ctx := p.ctx |
| 223 | f(mut job_ctx) or { p.set_first_error(err) } |
| 224 | } |
| 225 | |
| 226 | fn (mut p Pool) finish_accepted_job() { |
| 227 | p.mutex.lock() |
| 228 | p.accepted-- |
| 229 | p.wake_submitters_locked() |
| 230 | p.mutex.unlock() |
| 231 | } |
| 232 | |
| 233 | fn (mut p Pool) accept_job_locked() { |
| 234 | p.accepted++ |
| 235 | // add() is protected by the same mutex as wait(), so callers cannot race an |
| 236 | // accepted job against pool shutdown. |
| 237 | p.wg.add(1) |
| 238 | } |
| 239 | |
| 240 | fn (mut p Pool) wake_submitters_locked() { |
| 241 | if !p.submit_ready.closed { |
| 242 | p.submit_ready.close() |
| 243 | } |
| 244 | if !p.closed { |
| 245 | p.submit_ready = chan int{cap: 1} |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | fn (mut p Pool) set_first_error(err IError) { |
| 250 | p.mutex.lock() |
| 251 | if p.first_err is none { |
| 252 | p.first_err = err |
| 253 | } |
| 254 | p.mutex.unlock() |
| 255 | } |
| 256 | |
| 257 | fn (mut p Pool) get_first_error() IError { |
| 258 | p.mutex.lock() |
| 259 | err := p.first_err |
| 260 | p.mutex.unlock() |
| 261 | return err |
| 262 | } |
| 263 | |