cogworker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/exe/cogworker +6 -0
  3. data/exe/cogworkerswarm +6 -0
  4. data/lib/cogworker/basic_fetch.rb +27 -0
  5. data/lib/cogworker/cli.rb +64 -0
  6. data/lib/cogworker/client.rb +61 -0
  7. data/lib/cogworker/component.rb +19 -0
  8. data/lib/cogworker/config.rb +84 -0
  9. data/lib/cogworker/config_loader.rb +22 -0
  10. data/lib/cogworker/heartbeat.rb +150 -0
  11. data/lib/cogworker/history/middleware.rb +18 -0
  12. data/lib/cogworker/history/storage.rb +81 -0
  13. data/lib/cogworker/history.rb +33 -0
  14. data/lib/cogworker/job.rb +75 -0
  15. data/lib/cogworker/job_record.rb +24 -0
  16. data/lib/cogworker/job_util.rb +60 -0
  17. data/lib/cogworker/launcher.rb +93 -0
  18. data/lib/cogworker/logging.rb +18 -0
  19. data/lib/cogworker/manager.rb +71 -0
  20. data/lib/cogworker/middleware/chain.rb +64 -0
  21. data/lib/cogworker/periodic/claim.lua +27 -0
  22. data/lib/cogworker/periodic/entry.rb +24 -0
  23. data/lib/cogworker/periodic/manager.rb +28 -0
  24. data/lib/cogworker/periodic/release_middleware.rb +27 -0
  25. data/lib/cogworker/periodic/ticker.rb +123 -0
  26. data/lib/cogworker/process.rb +36 -0
  27. data/lib/cogworker/process_set.rb +29 -0
  28. data/lib/cogworker/processor.rb +129 -0
  29. data/lib/cogworker/prometheus/exporter.rb +62 -0
  30. data/lib/cogworker/queue.rb +60 -0
  31. data/lib/cogworker/redis_connection.rb +37 -0
  32. data/lib/cogworker/redis_keys.rb +32 -0
  33. data/lib/cogworker/scheduled.rb +67 -0
  34. data/lib/cogworker/signals.rb +15 -0
  35. data/lib/cogworker/stats.rb +47 -0
  36. data/lib/cogworker/status/client_middleware.rb +19 -0
  37. data/lib/cogworker/status/server_middleware.rb +31 -0
  38. data/lib/cogworker/status/storage.rb +30 -0
  39. data/lib/cogworker/status/worker.rb +27 -0
  40. data/lib/cogworker/status.rb +40 -0
  41. data/lib/cogworker/swarm.rb +169 -0
  42. data/lib/cogworker/testing.rb +109 -0
  43. data/lib/cogworker/unique_jobs/client_middleware.rb +31 -0
  44. data/lib/cogworker/unique_jobs/release_middleware.rb +30 -0
  45. data/lib/cogworker/unique_jobs.rb +32 -0
  46. data/lib/cogworker/version.rb +5 -0
  47. data/lib/cogworker/web/action.rb +63 -0
  48. data/lib/cogworker/web/application.rb +62 -0
  49. data/lib/cogworker/web/assets/ag-grid/ag-grid-community.min.js +1 -0
  50. data/lib/cogworker/web/assets/ag-grid/ag-grid.min.css +7 -0
  51. data/lib/cogworker/web/assets/ag-grid/ag-theme-alpine.min.css +2 -0
  52. data/lib/cogworker/web/assets/chart.umd.min.js +13 -0
  53. data/lib/cogworker/web/assets/htmx.min.js +1 -0
  54. data/lib/cogworker/web/assets/tailwind.css +1 -0
  55. data/lib/cogworker/web/layout.rb +352 -0
  56. data/lib/cogworker/web/router.rb +27 -0
  57. data/lib/cogworker/web/routes/busy.rb +99 -0
  58. data/lib/cogworker/web/routes/dead.rb +93 -0
  59. data/lib/cogworker/web/routes/history.rb +226 -0
  60. data/lib/cogworker/web/routes/periodic.rb +67 -0
  61. data/lib/cogworker/web/routes/queues.rb +101 -0
  62. data/lib/cogworker/web/routes/retries.rb +89 -0
  63. data/lib/cogworker/web/routes/save_session.rb +21 -0
  64. data/lib/cogworker/web/routes/scheduled.rb +49 -0
  65. data/lib/cogworker/web/routes/stats.rb +298 -0
  66. data/lib/cogworker/web/views.rb +25 -0
  67. data/lib/cogworker/web.rb +257 -0
  68. data/lib/cogworker/work.rb +19 -0
  69. data/lib/cogworker/work_set.rb +23 -0
  70. data/lib/cogworker/worker.rb +5 -0
  71. data/lib/cogworker/workers.rb +8 -0
  72. data/lib/cogworker.rb +114 -0
  73. metadata +300 -0
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Multi-process supervisor: forks `COGWORKER_COUNT` worker children from
5
+ # one parent (copy-on-write memory sharing) and relays signals to them.
6
+ # `PHASED_RESTART=true` makes a restart trigger cycle children one at a
7
+ # time (quiet -> wait -> replace) instead of all at once, so the pool as a
8
+ # whole never has zero capacity.
9
+ #
10
+ # The parent process is never itself a job-processing Launcher — it only
11
+ # forks/reaps/signals. Each child re-runs the full CLI boot (re-requiring
12
+ # app code, resetting its own identity) *after* `fork`, never before, so
13
+ # nothing in Heartbeat/Scheduled/the periodic Ticker ever starts a
14
+ # background thread in the parent that would silently vanish in a child.
15
+ class Swarm
16
+ # How long a phased-restart replacement waits for the outgoing child to
17
+ # exit on its own before giving up and force-killing it. Graceful
18
+ # shutdown (Manager#stop! draining in-flight jobs) can legitimately take
19
+ # a while, but this loop is single-threaded and blocking — one child
20
+ # that never exits (a hung Redis call outliving even Manager#stop!'s
21
+ # own 25s join, or anything else) must not be able to wedge the whole
22
+ # restart cycle, and the supervisor's entire signal-handling loop with
23
+ # it, forever.
24
+ GRACEFUL_STOP_TIMEOUT = 20
25
+
26
+ def initialize(argv, count: ENV.fetch('COGWORKER_COUNT', 1).to_i, phased: ENV['PHASED_RESTART'] == 'true')
27
+ @argv = argv
28
+ @count = [count, 1].max
29
+ @phased = phased
30
+ @children = {} # pid => slot index
31
+ @signal_queue = ::Queue.new
32
+ @stopping = false
33
+ end
34
+
35
+ def run
36
+ install_signal_traps
37
+ @count.times { |slot| fork_child(slot) }
38
+ supervise
39
+ end
40
+
41
+ private
42
+
43
+ def fork_child(slot)
44
+ pid = ::Process.fork do
45
+ Cogworker.reset_identity!
46
+ CLI.new.run(@argv.dup)
47
+ # `exit!`, not plain `exit`: `Launcher#run` only returns once
48
+ # `Manager#stop!` has had its bounded (default 25s) chance to join
49
+ # every processor thread — but per that method's own doc, a thread
50
+ # stuck past its deadline (mid a hung Redis call, say) is left
51
+ # running, not killed. A non-daemon thread still alive there would
52
+ # make plain `exit`'s normal interpreter shutdown wait for it
53
+ # indefinitely, so this OS process would never actually go away —
54
+ # and `Swarm#initiate_restart`'s own `Process.waitpid(pid)` for this
55
+ # exact child would then block forever right along with it.
56
+ ::Process.exit!(true)
57
+ end
58
+ @children[pid] = slot
59
+ Cogworker.logger.info { "swarm: started child pid=#{pid} slot=#{slot}" }
60
+ end
61
+
62
+ def install_signal_traps
63
+ Signal.trap(Signals::QUIET) { @signal_queue << :quiet }
64
+ Signal.trap(Signals::STOP) { @signal_queue << :stop }
65
+ Signal.trap(Signals::INTERRUPT) { @signal_queue << :stop }
66
+ Signal.trap(Signals::RESTART) { @signal_queue << :restart }
67
+ end
68
+
69
+ def supervise
70
+ until @stopping && @children.empty?
71
+ drain_signals
72
+ reap_children
73
+ sleep 0.2
74
+ end
75
+ end
76
+
77
+ def drain_signals
78
+ until @signal_queue.empty?
79
+ case @signal_queue.pop
80
+ when :quiet then relay(Signals::QUIET)
81
+ when :stop then initiate_stop
82
+ when :restart
83
+ Cogworker.logger.info { "swarm: restart signal received, phased=#{@phased}" }
84
+ initiate_restart
85
+ else Cogworker.logger.warn { "Unknown signal: #{signal}" }
86
+ end
87
+ end
88
+ end
89
+
90
+ def relay(signal)
91
+ @children.each_key { |pid| safe_kill(pid, signal) }
92
+ end
93
+
94
+ def initiate_stop
95
+ @stopping = true
96
+ relay(Signals::STOP)
97
+ end
98
+
99
+ # Non-phased: signal every child at once; the normal reap/respawn path
100
+ # below brings each one back as it exits, so there's a brief capacity
101
+ # dip but no explicit special-casing needed. Phased: replace children
102
+ # one at a time, waiting for each replacement to finish forking before
103
+ # touching the next, so total capacity never drops by more than one
104
+ # slot at a time.
105
+ def initiate_restart
106
+ return relay(Signals::STOP) unless @phased
107
+
108
+ @children.keys.each do |pid| # rubocop:disable Style/HashEachMethods -- mutates @children (delete/fork_child) during iteration; each_key is unsafe here
109
+ next unless @children.key?(pid)
110
+
111
+ slot = @children.delete(pid)
112
+ Cogworker.logger.info { "swarm: phased restart stopping child pid=#{pid} slot=#{slot}" }
113
+ safe_kill(pid, Signals::STOP)
114
+ wait_for_exit(pid)
115
+ Cogworker.logger.info { "swarm: phased restart child pid=#{pid} slot=#{slot} exited, respawning" }
116
+ fork_child(slot)
117
+ end
118
+ end
119
+
120
+ # Polls instead of a plain blocking `Process.waitpid(pid)`, specifically
121
+ # so a child that never exits can't block this forever: past
122
+ # GRACEFUL_STOP_TIMEOUT it's force-killed and reaped instead of leaving
123
+ # `initiate_restart` (and every other signal this single-threaded
124
+ # supervisor needs to handle) stuck behind it indefinitely.
125
+ def wait_for_exit(pid)
126
+ deadline = monotonic_now + GRACEFUL_STOP_TIMEOUT
127
+ until ::Process.waitpid(pid, ::Process::WNOHANG)
128
+ if monotonic_now > deadline
129
+ Cogworker.logger.warn { "swarm: child pid=#{pid} didn't stop within #{GRACEFUL_STOP_TIMEOUT}s, killing" }
130
+ safe_kill(pid, 'KILL')
131
+ ::Process.waitpid(pid)
132
+ return
133
+ end
134
+ sleep 0.1
135
+ end
136
+ rescue Errno::ECHILD
137
+ nil
138
+ end
139
+
140
+ def monotonic_now
141
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
142
+ end
143
+
144
+ def reap_children
145
+ pid = begin
146
+ ::Process.waitpid(-1, ::Process::WNOHANG)
147
+ rescue Errno::ECHILD
148
+ nil
149
+ end
150
+ return unless pid
151
+
152
+ slot = @children.delete(pid)
153
+ return if slot.nil? # already handled by initiate_restart
154
+
155
+ if @stopping
156
+ Cogworker.logger.info { "swarm: child pid=#{pid} slot=#{slot} exited" }
157
+ else
158
+ Cogworker.logger.warn { "swarm: child pid=#{pid} slot=#{slot} exited unexpectedly, respawning" }
159
+ fork_child(slot)
160
+ end
161
+ end
162
+
163
+ def safe_kill(pid, signal)
164
+ ::Process.kill(signal, pid)
165
+ rescue Errno::ESRCH
166
+ nil
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # `Client.push`'s enqueue-mode switch, checked at the very end of the
5
+ # client middleware chain in place of the real `raw_push` — the same spot
6
+ # every enqueue path (`perform_async`/`perform_in`/`perform_at`, or a
7
+ # direct `Client.push` call) already funnels through, so flipping this
8
+ # mode covers all of them for free. Three states:
9
+ #
10
+ # - `:disabled` (default) — the real thing: pushes onto actual Redis, same
11
+ # as every non-test run. Nothing here changes any existing behavior.
12
+ # - `:fake` — the client middleware chain still runs (so custom middleware
13
+ # that mutates the job hash, e.g. tagging, is exercised same as in prod),
14
+ # but the normalized job hash is appended to `jobs_for(klass_name)`
15
+ # instead of touching Redis — nothing is scheduled or executed. Use
16
+ # `SomeJob.jobs` (added to `Job::ClassMethods`) to assert what got
17
+ # pushed, `SomeJob.clear`/`Testing.clear_jobs!` to reset between
18
+ # examples.
19
+ # - `:inline` — the job runs synchronously, right here in the calling
20
+ # thread, through the real server middleware chain
21
+ # (`Cogworker.config.server_chain`) — same as `Processor#execute`, minus
22
+ # the parts of that only make sense for an async worker process (work
23
+ # registration, stats counters, retry/dead routing on failure: there's
24
+ # no Processor here to retry anything later). An exception raised by
25
+ # `perform` propagates straight to the caller, same as calling
26
+ # `SomeJob.new.perform(*args)` directly would — this is a testing
27
+ # convenience, not a fire-and-forget execution path. `perform_in`/
28
+ # `perform_at`'s delay is ignored entirely; the job still runs
29
+ # immediately.
30
+ #
31
+ # A plain module-level ivar, not thread-local: this is a mode flipped once
32
+ # in a test's setup, not a per-request runtime toggle — same single-
33
+ # process assumption `Cogworker.config`/`Cogworker.server_process!` already
34
+ # make.
35
+ module Testing
36
+ class << self
37
+ def fake!(&block)
38
+ set(:fake, &block)
39
+ end
40
+
41
+ def inline!(&block)
42
+ set(:inline, &block)
43
+ end
44
+
45
+ def disable!(&block)
46
+ set(:disabled, &block)
47
+ end
48
+
49
+ def fake?
50
+ mode == :fake
51
+ end
52
+
53
+ def inline?
54
+ mode == :inline
55
+ end
56
+
57
+ def disabled?
58
+ mode == :disabled
59
+ end
60
+
61
+ def mode
62
+ @mode ||= :disabled
63
+ end
64
+
65
+ # `SomeJob.jobs` reads this via `Job::ClassMethods#jobs` — every job
66
+ # pushed for that exact class name while fake mode was on, oldest
67
+ # first, as the same normalized (string-keyed) job hash `Client.push`
68
+ # always produces (so `job['args']`/`job['at']`/`job['jid']` etc. all
69
+ # work exactly as they would reading a job back out of Redis).
70
+ def jobs_for(klass_name)
71
+ registry[klass_name] ||= []
72
+ end
73
+
74
+ def clear_jobs!
75
+ registry.clear
76
+ end
77
+
78
+ # Runs one job synchronously through the real server middleware chain.
79
+ # Only ever called from `Client.push` while `inline?`.
80
+ def perform_inline(job)
81
+ klass = Object.const_get(job['class'])
82
+ worker = klass.new
83
+ worker.jid = job['jid'] if worker.respond_to?(:jid=)
84
+
85
+ Cogworker.config.server_chain.invoke(worker, job, job['queue']) do
86
+ worker.perform(*job['args'])
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def registry
93
+ @registry ||= {}
94
+ end
95
+
96
+ def set(new_mode)
97
+ previous = mode
98
+ @mode = new_mode
99
+ return new_mode unless block_given?
100
+
101
+ begin
102
+ yield
103
+ ensure
104
+ @mode = previous
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module UniqueJobs
5
+ # Client middleware, registered unconditionally on every `Config` (like
6
+ # `Periodic::ReleaseMiddleware` on the server side) — a no-op for any
7
+ # job that isn't `unique: :until_executed`. For one that is, atomically
8
+ # claims `cogworker:unique:<digest>` (`SET NX`) before letting the job
9
+ # continue down the chain to the real push. A job that loses the claim
10
+ # (another copy is already enqueued/scheduled/running) never reaches
11
+ # `dispatch` at all — it sets `job['unique_skipped']` instead, which
12
+ # `Client.push` reads to return `nil` rather than a jid, the same
13
+ # "nothing was actually pushed" signal `sidekiq-unique-jobs` gives.
14
+ class ClientMiddleware
15
+ def call(_worker_class, job, _queue, redis_pool = Cogworker.config.redis_pool)
16
+ return yield unless UniqueJobs.until_executed?(job)
17
+
18
+ acquired = redis_pool.with do |c|
19
+ c.set(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'],
20
+ nx: true, ex: Cogworker.config.unique_lock_ttl)
21
+ end
22
+
23
+ if acquired
24
+ yield
25
+ else
26
+ job['unique_skipped'] = true
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ module UniqueJobs
5
+ # Releases the `cogworker:unique:<digest>` lock (claimed by
6
+ # `ClientMiddleware` for `unique: :until_executed` jobs) on success, or
7
+ # on the terminal failed attempt — mirrors `Periodic::ReleaseMiddleware`
8
+ # exactly, just keyed by job content (class+queue+args) instead of a
9
+ # periodic pjid, so a retried job keeps its lock (no duplicate can sneak
10
+ # in while it's still retrying) and only releases once it's genuinely
11
+ # done, one way or the other. A no-op for any job that isn't
12
+ # `unique: :until_executed`. Registered unconditionally in every
13
+ # `Config`, same as `Periodic::ReleaseMiddleware`.
14
+ class ReleaseMiddleware
15
+ def call(_worker, job, _queue)
16
+ yield
17
+ release(job) if UniqueJobs.until_executed?(job)
18
+ rescue Exception => e # rubocop:disable Lint/RescueException
19
+ release(job) if UniqueJobs.until_executed?(job) && JobUtil.terminal_failure?(job)
20
+ raise e
21
+ end
22
+
23
+ private
24
+
25
+ def release(job)
26
+ Cogworker.config.redis { |c| c.del(RedisKeys.unique_lock(UniqueJobs.digest(job))) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/sha1'
4
+ require 'json'
5
+
6
+ module Cogworker
7
+ # Shared helpers for `unique: :until_executed` on regular jobs (anything
8
+ # pushed through `Client.push` — `perform_async`/`perform_in`/
9
+ # `perform_at`). Distinct from `Periodic::Entry#until_executed?`, which
10
+ # locks one cron *slot* per pjid; this locks on job *content* instead, so
11
+ # `ClientMiddleware` (before push) and `ReleaseMiddleware` (after the job
12
+ # hash has round-tripped through Redis/JSON) always agree on the same key
13
+ # without needing to stash it on the job hash itself.
14
+ module UniqueJobs
15
+ module_function
16
+
17
+ # `job['unique']` round-trips through JSON exactly like `job['retry']`
18
+ # (see `JobUtil.max_retries`) — a caller's `cogworker_options unique:
19
+ # :until_executed`/direct push hash may still hold the Symbol at the
20
+ # point `ClientMiddleware` sees it (before the job is ever
21
+ # `JSON.generate`d), while `ReleaseMiddleware` only ever sees the
22
+ # String a popped job was parsed back from. Comparing against `.to_s`
23
+ # handles both, plus the absent (`nil`) case, uniformly.
24
+ def until_executed?(job)
25
+ job['unique'].to_s == 'until_executed'
26
+ end
27
+
28
+ def digest(job)
29
+ Digest::SHA1.hexdigest("#{job['class']}|#{job['queue']}|#{job['args'].to_json}")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module Cogworker
6
+ class Web
7
+ # A fresh instance per request (never reused across requests/threads, so
8
+ # there's no cross-thread state to worry about). A registered route
9
+ # block is `instance_exec`'d against one of these, giving it bare
10
+ # `request`/`params`/`erb`/`redirect` the way TZ describes.
11
+ class Action
12
+ attr_reader :request, :params
13
+
14
+ def initialize(request, path_params)
15
+ @request = request
16
+ @params = request.params.merge(path_params)
17
+ end
18
+
19
+ # An extension's own routes may read either style — `params['x']` or
20
+ # `url_params('x')` (the latter is what the real History tab uses).
21
+ def url_params(key)
22
+ params[key.to_s]
23
+ end
24
+
25
+ # htmx sends this header on every request it issues (both its
26
+ # polling `hx-trigger` fetches and its `hx-post` form submissions), so
27
+ # a route can tell "the page is asking for a fragment to swap in"
28
+ # apart from a plain browser navigation — and fall back to a normal
29
+ # full-page render (or redirect) for anyone/anything without JS.
30
+ def hx_request?
31
+ request.get_header('HTTP_HX_REQUEST') == 'true'
32
+ end
33
+
34
+ # Accepts a Symbol (a built-in template, looked up in web/views/) or a
35
+ # raw ERB-source String (an extension handing over `File.read(...)`
36
+ # itself, as the real History tab does).
37
+ def erb(template, locals: {})
38
+ source = template.is_a?(Symbol) ? Views.read(template) : template
39
+ locals_binding = build_binding(locals)
40
+ ERB.new(source).result(locals_binding)
41
+ end
42
+
43
+ def redirect(location)
44
+ # Header names must be lowercase per the Rack spec — Rack::Lint
45
+ # (which Puma's default dev environment runs requests through)
46
+ # raises on a capitalized 'Location'.
47
+ halt([302, { 'location' => location }, []])
48
+ end
49
+
50
+ def halt(response)
51
+ throw :halt, response
52
+ end
53
+
54
+ private
55
+
56
+ def build_binding(locals)
57
+ b = binding
58
+ locals.each { |k, v| b.local_variable_set(k, v) }
59
+ b
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class Web
5
+ # The shared route table. Built-in tabs register through the exact same
6
+ # `self.get`/`register` mechanism external extensions use (dogfooding),
7
+ # so there's only one code path to keep working.
8
+ module Application
9
+ class << self
10
+ def routes
11
+ @routes ||= []
12
+ end
13
+
14
+ def get(path, &block)
15
+ add_route('GET', path, &block)
16
+ end
17
+
18
+ def post(path, &block)
19
+ add_route('POST', path, &block)
20
+ end
21
+
22
+ # `mod.registered(self)` — `self` here is this Application module,
23
+ # so inside `registered` the extension calls `app.get(...)` against
24
+ # it directly, matching TZ's Sinatra-style `self.registered(app)`.
25
+ def register(mod)
26
+ mod.registered(self)
27
+ end
28
+
29
+ def call(env)
30
+ request = Rack::Request.new(env)
31
+ # Rack::URLMap sets PATH_INFO to "" (not "/") for a request that
32
+ # exactly matches a mount point with no trailing slash — normalize
33
+ # it so the root route still matches when this app is `map`'d.
34
+ path_info = request.path_info.empty? ? '/' : request.path_info
35
+ route = routes.reverse.find do |r|
36
+ r[:method] == request.request_method && Router.match(r[:pattern], path_info)
37
+ end
38
+ return not_found unless route
39
+
40
+ captures = Router.match(route[:pattern], path_info)
41
+ action = Action.new(request, captures)
42
+ result = catch(:halt) { action.instance_exec(&route[:block]) }
43
+ rack_triple?(result) ? result : [200, { 'content-type' => 'text/html; charset=utf-8' }, [result.to_s]]
44
+ end
45
+
46
+ private
47
+
48
+ def add_route(method, path, &block)
49
+ routes << { method: method, pattern: Router.compile(path), block: block }
50
+ end
51
+
52
+ def rack_triple?(value)
53
+ value.is_a?(Array) && value.size == 3 && value.first.is_a?(Integer)
54
+ end
55
+
56
+ def not_found
57
+ [404, { 'content-type' => 'text/plain' }, ['Not Found']]
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end