├── LICENSE ├── Makefile ├── README.md ├── lib └── resty │ └── async.lua └── lua-resty-async-dev-1.rockspec /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, Aapo Talvensaari 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint 2 | 3 | lint: 4 | @luacheck -q ./lib 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lua-resty-async 2 | 3 | A thread pool based asynchronous job scheduler for OpenResty. 4 | 5 | 6 | ## API 7 | 8 | 9 | ### Functions and Methods 10 | 11 | 12 | #### local async = async.new(options) 13 | 14 | Creates a new instance of `resty.async`. With the `options` argument you can control the behavior 15 | and the limits: 16 | 17 | - `timer_interval`: how ofter the queue timer runs in seconds (the default is `0.1`) 18 | - `wait_interval`: how long the light threads wait for a job on each step, in seconds (the default is `0.5`) 19 | - `log_step`: how long the log timer sleeps between the steps, in seconds (the default is `0.5`) 20 | - `log_interval`: how often the log timer writes to the log, in seconds (the default is `60`, `0` disables the log timer) 21 | - `threads`: how many light threads to start that execute the jobs (the default is `100`) 22 | - `respawn_limit`: how many jobs a light thread can execute, before it is respawned (the default is `1000`) 23 | - `bucket_size`: how many jobs can be put in each bucket (each interval has its own bucket), buckets are drained by queue timer (the default is `1000`) 24 | - `lawn_size`: how many jobs can be added to buckets (combined) before we don't accept new jobs, again these are drained as the jobs are queued (the default is `10000`) 25 | - `queue_size`: how many jobs can be queued for the light threads concurrently, this is also drained as jobs are executed by the light threads (the default is `100000`) 26 | 27 | 28 | #### local ok, err = async:start() 29 | 30 | Starts `resty.async` timers. Returns `true` on success, otherwise return `nil` and error message. 31 | 32 | Possible errors include: 33 | - It was already started 34 | - Nginx worker is exiting 35 | - Starting timer jobs fail 36 | 37 | 38 | #### local ok, err = async:stop(wait) 39 | 40 | Stops `resty.async` timers. Returns `true` on success, otherwise return `nil` and error message. 41 | You can pass the optional `wait` argument (`number`) to wait for stop to stop the timers. 42 | 43 | When stopping the `async`, it will still execute all the queued jobs before it stops (it still 44 | passes `premature=true` to them). 45 | 46 | Possible errors include: 47 | - It was not started before calling stop 48 | - Nginx worker is exiting 49 | - Waiting fails (e.g. a timeout) when using the optional `wait` argument 50 | 51 | 52 | #### local ok, err = async:run(func, ...) 53 | 54 | Queues a job right away and skips the scheduling through queue timer. This is same as calling 55 | `async:at(0, func, ...)`, that is with `0` delay. It will immediately release a semaphore that 56 | any of the threads in thread pool can pick up and start executing as soon as Nginx scheduler 57 | gives CPU to it. 58 | 59 | The possible errors 60 | - Async is not yet started (this might change, if we want to enable queuing in `init` phase, for example) 61 | - Nginx worker is exiting 62 | - Async queue is full 63 | 64 | 65 | #### local ok, err = async:every(delay, func, ...) 66 | 67 | Queues a recurring job that runs approximately every `delay` seconds. This is similar to 68 | [`ngx.timer.every`](https://github.com/openresty/lua-nginx-module#ngxtimerevery), but instead 69 | of executing jobs with a timer construct we use a thread pool of light threads to execute it. 70 | The job is never queued overlapping, if it takes more than `delay` to execute a job. 71 | 72 | Possible errors: 73 | - Async is not yet started (this might change, if we want to enable queuing in `init` phase, for example) 74 | - Nginx worker is exiting 75 | - `delay` is not a positive number, greater than zero 76 | - Async lawn is full (too many jobs waiting to be queued) 77 | - Async bucket is full (too many jobs in a bucket (`delay`) waiting to be queued) 78 | 79 | 80 | #### local ok, err = async:at(delay, func, ...) 81 | 82 | Queues a job to be executed after a specified `delay`. This is similar to 83 | [`ngx.timer.at`](https://github.com/openresty/lua-nginx-module#ngxtimerat), but instead 84 | of executing jobs with a timer construct we use a thread pool of light threads to execute it. 85 | 86 | Possible errors: 87 | - Async is not yet started (this might change, if we want to enable queuing in `init` phase, for example) 88 | - Nginx worker is exiting 89 | - `delay` is not a positive number, or zero 90 | - Async lawn is full (too many jobs waiting to be queued) 91 | - Async bucket is full (too many jobs in a bucket (`delay`) waiting to be queued) 92 | 93 | 94 | #### local data, size = async:data(from, to) 95 | 96 | Returns raw data of executed jobs start and end times for a period of `from` to `to`. 97 | 98 | ```lua 99 | { 100 | { , , }, 101 | { , , }, 102 | ... 103 | { , , }, 104 | } 105 | ``` 106 | 107 | 108 | #### local stats = async:stats(opts) 109 | 110 | Returns statistics of the executed jobs. You can enable the `latency` and `runtime` 111 | statistics by passing the options: 112 | 113 | ```lua 114 | local stats = async:stats({ 115 | all = true, 116 | hour = true, 117 | minute = true, 118 | }) 119 | ``` 120 | 121 | Example output: 122 | 123 | ```lua 124 | { 125 | done = 20, 126 | pending = 0, 127 | running = 0, 128 | errored = 0, 129 | refused = 0, 130 | latency = { 131 | all = { 132 | size = 20, 133 | mean = 1, 134 | median = 2, 135 | p95 = 12, 136 | p99 = 20, 137 | p999 = 150, 138 | max = 200, 139 | min = 0, 140 | }, 141 | hour = { 142 | size = 10, 143 | mean = 1, 144 | median = 2, 145 | p95 = 12, 146 | p99 = 20, 147 | p999 = 150, 148 | max = 200, 149 | min = 0, 150 | }, 151 | minute = { 152 | size = 1, 153 | mean = 1, 154 | median = 2, 155 | p95 = 12, 156 | p99 = 20, 157 | p999 = 150, 158 | max = 200, 159 | min = 0, 160 | }, 161 | }, 162 | runtime = { 163 | all = { 164 | size = 20, 165 | mean = 1, 166 | median = 2, 167 | p95 = 12, 168 | p99 = 20, 169 | p999 = 150, 170 | max = 200, 171 | min = 0, 172 | }, 173 | hour = { 174 | size = 10, 175 | mean = 1, 176 | median = 2, 177 | p95 = 12, 178 | p99 = 20, 179 | p999 = 150, 180 | max = 200, 181 | min = 0, 182 | }, 183 | minute = { 184 | size = 1, 185 | mean = 1, 186 | median = 2, 187 | p95 = 12, 188 | p99 = 20, 189 | p999 = 150, 190 | max = 200, 191 | min = 0, 192 | }, 193 | } 194 | } 195 | ``` 196 | 197 | 198 | #### local ok, err = async:patch() 199 | 200 | Monkey patches `ngx.timer.*` APIs with this instance of `resty.async`. Because `ngx.timer.*` 201 | is essentially a global value, you can only call this on a single instance. If you try to call 202 | it twice on two different instances, it will give you an error on the last call. 203 | 204 | The original Nginx `ngx.timer.*` APIs are still available at instance: `async.ngx.timer.*`. 205 | 206 | 207 | #### local ok, err = async:unpatch() 208 | 209 | Removes monkey patches, and returns the `ngx.timer.*` API to its original state. If the 210 | `ngx.timer.*` API was patched by some other instance using `async:patch()`, the unpatching 211 | will fail, and return error instead. Also if the `async:patch()` was not called by this 212 | instance an error is returned. 213 | 214 | 215 | ## Design 216 | 217 | `lua-resty-async` is a Nginx worker based job scheduler library. The library starts three 218 | (out of which one is optional) Nginx timers using `ngx.timer.at` function when calling 219 | the `async:start()`: 220 | 221 | 1. A thread pool timer that spawns, monitors and respawns the light threads. 222 | 2. A queue timer that queues new jobs for the light threads. 223 | 3. An optional log timer that periodically logs statistics to Nginx error log 224 | 225 | The light threads, managed by the thread pool timer, receive work (or the jobs) from the queue timer 226 | or directly through `async:run(func, ...)` using a semaphore (`ngx.semaphore`) that the threads are 227 | waiting on. 228 | 229 | The queue works in buckets. Each interval (or delay) gets its own bucket. Each bucket has a fixed 230 | size and works as a circular wheel where head points to the latest added job and the tail points to 231 | the previously executed job. The queue timer loops through buckets, and on each bucket queues the 232 | expired jobs for a light thread by releasing a semaphore. The queuing process is fast and doesn't 233 | yield. 234 | 235 | The API is compatible with `ngx.timer.at` and `ngx.timer.every`, and takes the same parameters. 236 | 237 | (The above three (or two) timers could be reduced to just a one master timer, that spawns three 238 | light threads, but currently I don't see much benefit on it, and it adds another layer of indirection, 239 | so just to point it out, if someone is wondering.) 240 | 241 | 242 | ## TODO 243 | 244 | - Optionally allow individual jobs to be named OR grouped, and allow named jobs or groups to be 245 | stopped / removed: 246 | - `async:add(name, func, ...)` 247 | - `async:run(name, [...])` 248 | - `async:at(0, name, [...])` 249 | - `async:every(0, name, [...])` 250 | - `async:remove(name)` 251 | - `async:pause(name)` 252 | - Add `async:info()` function to give information about buckets etc., and their fill rate. 253 | - Add test suite and CI 254 | 255 | 256 | ## License 257 | 258 | ```license 259 | Copyright (c) 2022, Aapo Talvensaari 260 | All rights reserved. 261 | 262 | Redistribution and use in source and binary forms, with or without 263 | modification, are permitted provided that the following conditions are met: 264 | 265 | * Redistributions of source code must retain the above copyright notice, this 266 | list of conditions and the following disclaimer. 267 | 268 | * Redistributions in binary form must reproduce the above copyright notice, 269 | this list of conditions and the following disclaimer in the documentation 270 | and/or other materials provided with the distribution. 271 | 272 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 273 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 274 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 275 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 276 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 277 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 278 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 279 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 280 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 281 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 282 | ``` -------------------------------------------------------------------------------- /lib/resty/async.lua: -------------------------------------------------------------------------------- 1 | --- 2 | -- A thread pool based asynchronous job scheduler for OpenResty. 3 | -- 4 | -- @module resty.async 5 | 6 | 7 | local semaphore = require "ngx.semaphore" 8 | 9 | 10 | local ngx = ngx 11 | local log = ngx.log 12 | local now = ngx.now 13 | local wait = ngx.thread.wait 14 | local kill = ngx.thread.kill 15 | local sleep = ngx.sleep 16 | local spawn = ngx.thread.spawn 17 | local exiting = ngx.worker.exiting 18 | local timer_at = ngx.timer.at 19 | local fmt = string.format 20 | local sort = table.sort 21 | local math = math 22 | local min = math.min 23 | local max = math.max 24 | local huge = math.huge 25 | local ceil = math.ceil 26 | local floor = math.floor 27 | local type = type 28 | local traceback = debug.traceback 29 | local xpcall = xpcall 30 | local select = select 31 | local unpack = unpack 32 | local assert = assert 33 | local setmetatable = setmetatable 34 | 35 | 36 | local DEBUG = ngx.DEBUG 37 | local INFO = ngx.INFO 38 | local NOTICE = ngx.NOTICE 39 | local ERR = ngx.ERR 40 | local WARN = ngx.WARN 41 | local CRIT = ngx.CRIT 42 | 43 | 44 | local TIMER_INTERVAL = 0.1 45 | local WAIT_INTERVAL = 0.5 46 | local LOG_STEP = 0.5 47 | local LOG_INTERVAL = 60 48 | local THREADS = 100 49 | local RESPAWN_LIMIT = 1000 50 | local BUCKET_SIZE = 1000 51 | local LAWN_SIZE = 10000 52 | local QUEUE_SIZE = 100000 53 | 54 | 55 | local DELAYS = { 56 | second = 1, 57 | minute = 60, 58 | hour = 3600, 59 | day = 86400, 60 | week = 604800, 61 | month = 2629743.833, 62 | year = 31556926, 63 | } 64 | 65 | 66 | local MONKEY_PATCHED 67 | 68 | 69 | local function get_pending(queue, size) 70 | local head = queue.head 71 | local tail = queue.tail 72 | if head < tail then 73 | head = head + size 74 | end 75 | return head - tail 76 | end 77 | 78 | 79 | local function is_full(queue, size) 80 | return get_pending(queue, size) == size 81 | end 82 | 83 | 84 | local new_tab 85 | do 86 | local ok 87 | ok, new_tab = pcall(require, "table.new") 88 | if not ok then 89 | new_tab = function () 90 | return {} 91 | end 92 | end 93 | end 94 | 95 | 96 | local function release_timer(self) 97 | local timers = self.timers 98 | if timers > 0 then 99 | timers = timers - 1 100 | end 101 | 102 | if timers > 0 then 103 | self.timers = timers 104 | else 105 | self.timers = 0 106 | self.quit:post() 107 | end 108 | end 109 | 110 | 111 | local function job_thread(self, index) 112 | local wait_interval = self.opts.wait_interval 113 | local queue_size = self.opts.queue_size 114 | local respawn_limit = self.opts.respawn_limit 115 | local jobs_executed = 0 116 | 117 | while true do 118 | local ok, err = self.work:wait(wait_interval) 119 | if ok then 120 | local tail = self.tail == queue_size and 1 or self.tail + 1 121 | local job = self.jobs[tail] 122 | self.tail = tail 123 | self.jobs[tail] = nil 124 | self.running = self.running + 1 125 | self.time[tail][2] = now() * 1000 126 | ok, err = job() 127 | self.time[tail][3] = now() * 1000 128 | self.running = self.running - 1 129 | self.done = self.done + 1 130 | if not ok then 131 | self.errored = self.errored + 1 132 | log(ERR, "async thread #", index, " job error: ", err) 133 | end 134 | 135 | jobs_executed = jobs_executed + 1 136 | 137 | elseif err ~= "timeout" then 138 | log(ERR, "async thread #", index, " wait error: ", err) 139 | end 140 | 141 | if self.head == self.tail and (self.started == nil or self.aborted > 0 or exiting()) then 142 | break 143 | end 144 | 145 | if jobs_executed == respawn_limit then 146 | return index, true 147 | end 148 | end 149 | 150 | return index 151 | end 152 | 153 | 154 | local function job_timer(premature, self) 155 | if premature or self.started == nil then 156 | release_timer(self) 157 | return true 158 | end 159 | 160 | local t = self.threads 161 | local c = self.opts.threads 162 | 163 | for i = 1, c do 164 | t[i] = spawn(job_thread, self, i) 165 | end 166 | 167 | while true do 168 | local ok, err, respawn = wait(unpack(t, 1, c)) 169 | if respawn then 170 | log(DEBUG, "async respawning thread #", err) 171 | t[err] = spawn(job_thread, self, err) 172 | 173 | else 174 | if not ok then 175 | log(ERR, "async thread error: ", err) 176 | elseif self.started and not exiting() then 177 | log(ERR, "async thread #", err, " aborted") 178 | end 179 | 180 | self.aborted = self.aborted + 1 181 | 182 | break 183 | end 184 | end 185 | 186 | for i = 1, c do 187 | local ok, err = wait(t[i]) 188 | if not ok then 189 | log(ERR, "async thread error: ", err) 190 | elseif self.started and not exiting() then 191 | log(ERR, "async thread #", i, " aborted") 192 | end 193 | 194 | kill(t[i]) 195 | t[i] = nil 196 | 197 | self.aborted = self.aborted + 1 198 | end 199 | 200 | release_timer(self) 201 | 202 | if self.started and not exiting() then 203 | log(CRIT, "async job timer aborted") 204 | return 205 | end 206 | 207 | return true 208 | end 209 | 210 | 211 | local function queue_timer(premature, self) 212 | 213 | -- DO NOT YIELD IN THIS FUNCTION AS IT IS EXECUTED FREQUENTLY! 214 | 215 | local timer_interval = self.opts.timer_interval 216 | local lawn_size = self.opts.lawn_size 217 | local bucket_size = self.opts.bucket_size 218 | 219 | while true do 220 | premature = premature or self.started == nil or exiting() 221 | 222 | local ok = true 223 | 224 | if self.lawn.head ~= self.lawn.tail then 225 | local current_time = premature and huge or now() 226 | if self.closest <= current_time then 227 | local lawn = self.lawn 228 | local ttls = lawn.ttls 229 | local buckets = lawn.buckets 230 | local head = lawn.head 231 | local tail = lawn.tail 232 | while head ~= tail do 233 | tail = tail == lawn_size and 1 or tail + 1 234 | local ttl = ttls[tail] 235 | local bucket = buckets[ttl] 236 | if bucket.head == bucket.tail then 237 | lawn.tail = lawn.tail == lawn_size and 1 or lawn.tail + 1 238 | buckets[ttl] = nil 239 | ttls[tail] = nil 240 | 241 | else 242 | while bucket.head ~= bucket.tail do 243 | local bucket_tail = bucket.tail == bucket_size and 1 or bucket.tail + 1 244 | local job = bucket.jobs[bucket_tail] 245 | local expiry = job[1] 246 | if expiry >= current_time then 247 | break 248 | end 249 | 250 | local err 251 | ok, err = job[2](self) 252 | if not ok then 253 | log(ERR, err) 254 | break 255 | end 256 | 257 | bucket.jobs[bucket_tail] = nil 258 | bucket.tail = bucket_tail 259 | 260 | -- reschedule a recurring job 261 | if not premature and job[3] then 262 | local exp = now() + ttl 263 | bucket.head = bucket.head == bucket_size and 1 or bucket.head + 1 264 | bucket.jobs[bucket.head] = { 265 | exp, 266 | job[2], 267 | true, 268 | } 269 | 270 | self.closest = min(self.closest, exp) 271 | end 272 | 273 | if self.closest == 0 or self.closest > expiry then 274 | self.closest = expiry 275 | end 276 | end 277 | 278 | lawn.tail = lawn.tail == lawn_size and 1 or lawn.tail + 1 279 | lawn.head = lawn.head == lawn_size and 1 or lawn.head + 1 280 | ttls[lawn.head] = ttl 281 | ttls[tail] = nil 282 | 283 | if not ok then 284 | break 285 | end 286 | end 287 | end 288 | end 289 | end 290 | 291 | if premature then 292 | break 293 | end 294 | 295 | sleep(timer_interval) 296 | end 297 | 298 | release_timer(self) 299 | 300 | if self.started and not exiting() then 301 | log(CRIT, "async queue timer aborted") 302 | return 303 | end 304 | 305 | return true 306 | end 307 | 308 | 309 | local function log_timer(premature, self) 310 | if premature or self.started == nil then 311 | release_timer(self) 312 | return true 313 | end 314 | 315 | local log_interval = self.opts.log_interval 316 | local log_step = self.opts.log_step 317 | local queue_size = self.opts.queue_size 318 | 319 | local round = 0 320 | local rounds = ceil(log_interval / log_step) 321 | 322 | while true do 323 | if self.started == nil or exiting() then 324 | break 325 | end 326 | 327 | round = round + 1 328 | 329 | if round == rounds then 330 | round = 0 331 | 332 | local dbg = queue_size / 10000 333 | local nfo = queue_size / 1000 334 | local ntc = queue_size / 100 335 | local wrn = queue_size / 10 336 | local err = queue_size 337 | 338 | local pending = get_pending(self, queue_size) 339 | 340 | local msg = fmt("async jobs: %u running, %u pending, %u errored, %u refused, %u aborted, %u done", 341 | self.running, pending, self.errored, self.refused, self.aborted, self.done) 342 | if pending <= dbg then 343 | log(DEBUG, msg) 344 | elseif pending <= nfo then 345 | log(INFO, msg) 346 | elseif pending <= ntc then 347 | log(NOTICE, msg) 348 | elseif pending < wrn then 349 | log(WARN, msg) 350 | elseif pending < err then 351 | log(ERR, msg) 352 | else 353 | log(CRIT, msg) 354 | end 355 | end 356 | 357 | sleep(log_step) 358 | end 359 | 360 | release_timer(self) 361 | 362 | if self.started and not exiting() then 363 | log(CRIT, "async log timer aborted") 364 | return 365 | end 366 | 367 | return true 368 | end 369 | 370 | 371 | local function create_job(func, a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, ...) 372 | local argc = select("#", ...) 373 | if argc == 0 then 374 | return function() 375 | return xpcall(func, traceback, exiting(), 376 | a1, a2, a3, a4, a5, a6, a7, a8, a9, a10) 377 | end 378 | end 379 | 380 | local args = { ... } 381 | return function() 382 | local pok, res, err = xpcall(func, traceback, exiting(), 383 | a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, 384 | unpack(args, 1, argc)) 385 | if not pok then 386 | return nil, res 387 | end 388 | 389 | if not err then 390 | return true 391 | end 392 | 393 | return nil, err 394 | end 395 | end 396 | 397 | 398 | local function queue_job(self, job) 399 | local queue_size = self.opts.queue_size 400 | if is_full(self, queue_size) then 401 | self.refused = self.refused + 1 402 | return nil, "async queue is full" 403 | end 404 | 405 | self.head = self.head == queue_size and 1 or self.head + 1 406 | self.jobs[self.head] = job 407 | self.time[self.head][1] = now() * 1000 408 | self.work:post() 409 | 410 | return true 411 | end 412 | 413 | 414 | local function create_recurring_job(job) 415 | local running = false 416 | 417 | local recurring_job = function() 418 | running = true 419 | local ok, err = job() 420 | running = false 421 | return ok, err 422 | end 423 | 424 | return function(self) 425 | if running then 426 | return nil, "recurring job is already running" 427 | end 428 | 429 | return queue_job(self, recurring_job) 430 | end 431 | end 432 | 433 | 434 | local function create_at_job(job) 435 | return function(self) 436 | return queue_job(self, job) 437 | end 438 | end 439 | 440 | 441 | local function get_stats(self, from, to) 442 | local data, size = self:data(from, to) 443 | local names = { "max", "min", "mean", "median", "p95", "p99", "p999" } 444 | local stats = new_tab(2, 0) 445 | 446 | for i = 1, 2 do 447 | stats[i] = new_tab(0, #names + 1) 448 | stats[i].size = size 449 | if size == 0 then 450 | for j = 1, #names do 451 | stats[i][names[j]] = 0 452 | end 453 | 454 | elseif size == 1 then 455 | local time = i == 1 and data[1][2] - data[1][1] 456 | or data[1][3] - data[1][2] 457 | 458 | for j = 1, #names do 459 | stats[i][names[j]] = time 460 | end 461 | 462 | elseif size > 1 then 463 | local tot = 0 464 | local raw = new_tab(size, 0) 465 | local max_value 466 | local min_value 467 | 468 | for j = 1, size do 469 | local time = i == 1 and data[j][2] - data[j][1] 470 | or data[j][3] - data[j][2] 471 | raw[j] = time 472 | tot = tot + time 473 | max_value = max(time, max_value or time) 474 | min_value = min(time, min_value or time) 475 | end 476 | 477 | stats[i].max = max_value 478 | stats[i].min = min_value 479 | stats[i].mean = floor(tot / size + 0.5) 480 | 481 | sort(raw) 482 | 483 | local n = { "median", "p95", "p99", "p999" } 484 | local m = { 0.5, 0.95, 0.99, 0.999 } 485 | 486 | for j = 1, #n do 487 | local idx = size * m[j] 488 | if idx == floor(idx) then 489 | stats[i][n[j]] = floor((raw[idx] + raw[idx + 1]) / 2 + 0.5) 490 | else 491 | stats[i][n[j]] = raw[floor(idx + 0.5)] 492 | end 493 | end 494 | end 495 | end 496 | 497 | return { 498 | latency = stats[1], 499 | runtime = stats[2], 500 | } 501 | end 502 | 503 | 504 | local function schedule(self, delay, recurring, func, ...) 505 | if not self.started then 506 | return nil, "async not started" 507 | end 508 | 509 | if exiting() then 510 | return nil, "nginx worker exiting" 511 | end 512 | 513 | delay = DELAYS[delay] or delay 514 | 515 | if recurring then 516 | assert(type(delay) == "number" and delay > 0, "invalid delay, must be a number greater than zero or " .. 517 | "'second', 'minute', 'hour', 'month' or 'year'") 518 | else 519 | assert(type(delay) == "number" and delay >= 0, "invalid delay, must be a positive number, zero or " .. 520 | "'second', 'minute', 'hour', 'month' or 'year'") 521 | 522 | if delay == 0 then 523 | return queue_job(self, create_job(func, ...)) 524 | end 525 | end 526 | 527 | local lawn = self.lawn 528 | local lawn_size = self.opts.lawn_size 529 | local bucket_size = self.opts.bucket_size 530 | if is_full(lawn, lawn_size) then 531 | self.refused = self.refused + 1 532 | return nil, "async lawn (" .. delay .. ") is full" 533 | end 534 | 535 | local bucket = lawn.buckets[delay] 536 | if bucket then 537 | if is_full(bucket, bucket_size) then 538 | self.refused = self.refused + 1 539 | return nil, "async bucket (" .. delay .. ") is full" 540 | end 541 | 542 | else 543 | lawn.head = lawn.head == lawn_size and 1 or lawn.head + 1 544 | lawn.ttls[lawn.head] = delay 545 | 546 | bucket = { 547 | jobs = new_tab(bucket_size, 0), 548 | head = 0, 549 | tail = 0, 550 | } 551 | 552 | lawn.buckets[delay] = bucket 553 | end 554 | 555 | local job = create_job(func, ...) 556 | if recurring then 557 | job = create_recurring_job(job) 558 | else 559 | job = create_at_job(job) 560 | end 561 | 562 | local expiry = now() + delay 563 | 564 | bucket.head = bucket.head == bucket_size and 1 or bucket.head + 1 565 | bucket.jobs[bucket.head] = { 566 | expiry, 567 | job, 568 | recurring 569 | } 570 | 571 | self.closest = min(self.closest, expiry) 572 | 573 | return true 574 | end 575 | 576 | 577 | local async = { 578 | _VERSION = "0.1" 579 | } 580 | 581 | async.__index = async 582 | 583 | 584 | --- 585 | -- Creates a new instance of `resty.async` 586 | -- 587 | -- @tparam[opt] table options a table containing options 588 | -- 589 | -- The following options can be used: 590 | -- 591 | -- - `timer_interval` (the default is `0.1`) 592 | -- - `wait_interval` (the default is `0.5`) 593 | -- - `log_step` (the default is `0.5`) 594 | -- - `log_interval` (the default is `60`) 595 | -- - `threads` (the default is `100`) 596 | -- - `respawn_limit` (the default is `1000`) 597 | -- - `bucket_size` (the default is `1000`) 598 | -- - `lawn_size` (the default is `10000`) 599 | -- - `queue_size` (the default is `100000`) 600 | -- 601 | -- @treturn table an instance of `resty.async` 602 | function async.new(options) 603 | assert(options == nil or type(options) == "table", "invalid options") 604 | 605 | local opts = { 606 | timer_interval = options and options.timer_interval or TIMER_INTERVAL, 607 | wait_interval = options and options.wait_interval or WAIT_INTERVAL, 608 | log_interval = options and options.log_interval or LOG_INTERVAL, 609 | log_step = options and options.log_step or LOG_STEP, 610 | threads = options and options.threads or THREADS, 611 | respawn_limit = options and options.respawn_limit or RESPAWN_LIMIT, 612 | bucket_size = options and options.bucket_size or BUCKET_SIZE, 613 | lawn_size = options and options.lawn_size or LAWN_SIZE, 614 | queue_size = options and options.query_size or QUEUE_SIZE, 615 | } 616 | 617 | local time = new_tab(opts.queue_size, 0) 618 | for i = 1, opts.queue_size do 619 | time[i] = new_tab(3, 0) 620 | end 621 | 622 | return setmetatable({ 623 | opts = opts, 624 | jobs = new_tab(opts.queue_size, 0), 625 | time = time, 626 | quit = semaphore.new(), 627 | work = semaphore.new(), 628 | lawn = { 629 | head = 0, 630 | tail = 0, 631 | ttls = new_tab(opts.lawn_size, 0), 632 | buckets = {}, 633 | }, 634 | groups = {}, 635 | timers = 0, 636 | threads = new_tab(opts.threads, 0), 637 | closest = 0, 638 | running = 0, 639 | errored = 0, 640 | refused = 0, 641 | aborted = 0, 642 | done = 0, 643 | head = 0, 644 | tail = 0, 645 | }, async) 646 | end 647 | 648 | 649 | --- 650 | -- Start `resty.async` timers 651 | -- 652 | -- @treturn boolean|nil `true` on success, `nil` on error 653 | -- @treturn string|nil `nil` on success, error message `string` on error 654 | function async:start() 655 | if self.started then 656 | return nil, "async already started" 657 | end 658 | 659 | if exiting() then 660 | return nil, "nginx worker exiting" 661 | end 662 | 663 | self.started = now() 664 | self.aborted = 0 665 | 666 | local ok, err = timer_at(0, job_timer, self) 667 | if not ok then 668 | return nil, err 669 | end 670 | 671 | self.timers = self.timers + 1 672 | 673 | ok, err = timer_at(self.opts.timer_interval, queue_timer, self) 674 | if not ok then 675 | return nil, err 676 | end 677 | 678 | self.timers = self.timers + 1 679 | 680 | if self.opts.log_interval > 0 then 681 | ok, err = timer_at(self.opts.log_step, log_timer, self) 682 | if not ok then 683 | return nil, err 684 | end 685 | 686 | self.timers = self.timers + 1 687 | end 688 | 689 | return true 690 | end 691 | 692 | 693 | --- 694 | -- Stop `resty.async` timers 695 | -- 696 | -- @tparam[opt] number wait specifies the maximum time this function call should wait for 697 | -- (in seconds) everything to be stopped 698 | -- @treturn boolean|nil `true` on success, `nil` on error 699 | -- @treturn string|nil `nil` on success, error message `string` on error 700 | function async:stop(wait) 701 | if not self.started then 702 | return nil, "async not started" 703 | end 704 | 705 | if exiting() then 706 | return nil, "nginx worker exiting" 707 | end 708 | 709 | self.started = nil 710 | 711 | if wait then 712 | return self.quit:wait(wait) 713 | end 714 | 715 | return true 716 | end 717 | 718 | 719 | --- 720 | -- Run a function asynchronously 721 | -- 722 | -- @tparam function func a function to run asynchronously 723 | -- @tparam[opt] ... ... function arguments 724 | -- @treturn true|nil `true` on success, `nil` on error 725 | -- @treturn string|nil `nil` on success, error message `string` on error 726 | function async:run(func, ...) 727 | if not self.started then 728 | return nil, "async not started" 729 | end 730 | 731 | if exiting() then 732 | return nil, "nginx worker exiting" 733 | end 734 | 735 | return queue_job(self, create_job(func, ...)) 736 | end 737 | 738 | 739 | --- 740 | -- Run a function asynchronously and repeatedly but non-overlapping 741 | -- 742 | -- @tparam number|string delay function execution interval (a non-zero positive number 743 | -- or `"second"`, `"minute"`, `"hour"`, `"month" or `"year"`) 744 | -- @tparam function func a function to run asynchronously 745 | -- @tparam[opt] ... ... function arguments 746 | -- @treturn true|nil `true` on success, `nil` on error 747 | -- @treturn string|nil `nil` on success, error message `string` on error 748 | function async:every(delay, func, ...) 749 | return schedule(self, delay, true, func, ...) 750 | end 751 | 752 | 753 | --- 754 | -- Run a function asynchronously with a specific delay 755 | -- 756 | -- @tparam number|string delay function execution delay (a positive number, zero included, 757 | -- or `"second"`, `"minute"`, `"hour"`, `"month" or `"year"`) 758 | -- @tparam function func a function to run asynchronously 759 | -- @tparam[opt] ... ... function arguments 760 | -- @treturn true|nil `true` on success, `nil` on error 761 | -- @treturn string|nil `nil` on success, error message `string` on error 762 | function async:at(delay, func, ...) 763 | return schedule(self, delay, false, func, ...) 764 | end 765 | 766 | 767 | --- 768 | -- Return raw metrics 769 | -- 770 | -- @tparam[opt] number from data start time (from unix epoch) 771 | -- @tparam[opt] number to data end time (from unix epoch) 772 | -- @treturn table a table containing the metrics 773 | -- @treturn number number of metrics returned 774 | function async:data(from, to) 775 | local time = self.time 776 | local done = min(self.done, self.opts.queue_size) 777 | if not from and not to then 778 | return time, done 779 | end 780 | 781 | from = from and from * 1000 or 0 782 | to = to and to * 1000 or huge 783 | 784 | local data = new_tab(done, 0) 785 | local size = 0 786 | for i = 1, done do 787 | if time[i][1] >= from and time[i][3] <= to then 788 | size = size + 1 789 | data[size] = time[i] 790 | end 791 | end 792 | 793 | return data, size 794 | end 795 | 796 | 797 | --- 798 | -- Return statistics 799 | -- 800 | -- @tparam[opt] table options the options of which statistics to calculate 801 | -- 802 | -- The following options can be used: 803 | -- 804 | -- - `all` (boolean): calculate statistics for all the executed jobs 805 | -- - `hour` (boolean): calculate statistics for the last hour 806 | -- - `minute` (boolean): calculate statistics for the last minute 807 | -- 808 | -- @treturn table a table containing calculated statistics 809 | function async:stats(options) 810 | local stats 811 | local pending = get_pending(self, self.opts.queue_size) 812 | if not options then 813 | stats = get_stats(self) 814 | stats.done = self.done 815 | stats.pending = pending 816 | stats.running = self.running 817 | stats.errored = self.errored 818 | stats.refused = self.refused 819 | stats.aborted = self.aborted 820 | 821 | else 822 | local current_time = now() 823 | 824 | local all = options.all and get_stats(self) 825 | local minute = options.minute and get_stats(self, current_time - DELAYS.minute) 826 | local hour = options.hour and get_stats(self, current_time - DELAYS.hour) 827 | 828 | local latency 829 | local runtime 830 | if all or minute or hour then 831 | latency = { 832 | all = all and all.latency, 833 | minute = minute and minute.latency, 834 | hour = hour and hour.latency, 835 | } 836 | runtime = { 837 | all = all and all.runtime, 838 | minute = minute and minute.runtime, 839 | hour = hour and hour.runtime, 840 | } 841 | end 842 | 843 | stats = { 844 | done = self.done, 845 | pending = pending, 846 | running = self.running, 847 | errored = self.errored, 848 | refused = self.refused, 849 | aborted = self.aborted, 850 | latency = latency, 851 | runtime = runtime, 852 | } 853 | end 854 | 855 | return stats 856 | end 857 | 858 | 859 | --- 860 | -- Monkey patches `ngx.timer.*` APIs with `resty.async` APIs 861 | function async:patch() 862 | if MONKEY_PATCHED then 863 | if self.ngx and self.ngx.timer then 864 | return nil, "already monkey patched" 865 | end 866 | 867 | return nil, "already monkey patched by other instance" 868 | end 869 | 870 | MONKEY_PATCHED = true 871 | 872 | self.ngx = { 873 | timer = { 874 | at = ngx.timer.at, 875 | every = ngx.timer.every, 876 | } 877 | } 878 | 879 | ngx.timer.at = function(...) 880 | return self:at(...) 881 | end 882 | 883 | ngx.timer.every = function(...) 884 | return self:every(...) 885 | end 886 | 887 | return true 888 | end 889 | 890 | 891 | --- 892 | -- Removes monkey patches from `ngx.timer.*` APIs 893 | function async:unpatch() 894 | if not MONKEY_PATCHED then 895 | return nil, "not monkey patched" 896 | end 897 | 898 | if not self.ngx or not self.ngx.timer then 899 | return nil, "patched by other instance" 900 | end 901 | 902 | MONKEY_PATCHED = nil 903 | 904 | local timer = self.ngx.timer 905 | if timer.at then 906 | ngx.timer.at = timer.at 907 | end 908 | 909 | if timer.every then 910 | ngx.timer.every = timer.every 911 | end 912 | 913 | self.ngx = nil 914 | 915 | return true 916 | end 917 | 918 | 919 | return async 920 | -------------------------------------------------------------------------------- /lua-resty-async-dev-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "lua-resty-async" 2 | version = "dev-1" 3 | source = { 4 | url = "git://github.com/bungle/lua-resty-async.git" 5 | } 6 | description = { 7 | summary = "A thread pool based asynchronous job scheduler for OpenResty", 8 | detailed = "lua-resty-async is a thread pool based job scheduler utilizing Nginx light threads for OpenResty.", 9 | homepage = "https://github.com/bungle/lua-resty-async", 10 | maintainer = "Aapo Talvensaari ", 11 | license = "BSD" 12 | } 13 | dependencies = { 14 | "lua >= 5.1" 15 | } 16 | build = { 17 | type = "builtin", 18 | modules = { 19 | ["resty.async"] = "lib/resty/async.lua", 20 | } 21 | } 22 | --------------------------------------------------------------------------------