orbitalqueue 0.0.2 → 0.0.4

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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +261 -2
  3. data/lib/orbitalqueue.rb +186 -13
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d331ea29dd6bc80aa3c1127749f782845d1f23c2a9729f987af9128f1922bf21
4
- data.tar.gz: 5f7c04a0cef45399d4726900330ac96b1714e3275545a2d1e4e209fe61aec6c9
3
+ metadata.gz: 7eab13f9b8b7353b492bfded166685f5ba54135bc23c561b723ca0ea7e0c8c72
4
+ data.tar.gz: 8d6b59b0a2448afed4df5fda116ae31352bd4289c9f47290d91bfdcb99d9bebd
5
5
  SHA512:
6
- metadata.gz: a7d5ead3e5494a3f38808e9b363b1ceb624168e294ee6a7165630092ec3ff3a58448234f434411f865539e89d8aa5f1cdf3100adde94ca6cc26066ec5c1d0795
7
- data.tar.gz: 7aab8b14af6f147b0b7276e37baee3db4324ca4052445c1fefcdd094f47e64e770f0a3357eec8a69d35ebbedd2263b7b3b5d208e457c73b5f6b3f34a5560ba39
6
+ metadata.gz: 8499346b48cc77f21035d529d1119ae0ad5a56efc99b6823890b642776925adf65b6768104589262bd5e5c80d542fc3b2dee9d716e0ba888e22799a379932444
7
+ data.tar.gz: 1d58db0f106088bd71ad7df70ebe537cbef6b699642cac5c73d0bfcadbac3906a7a478946f98ad2e641554850e4aa00b6a9289ebeadaee02b1e0089f6395c703
data/README.md CHANGED
@@ -49,15 +49,274 @@ item.complete
49
49
 
50
50
  Calling `#pop` retrieves a single item from the queue in no particular order.
51
51
 
52
+ ```ruby
53
+ queue = OrbitalQueue.new("/path/to/queue")
54
+ item = queue.pop!
55
+
56
+ # Something, something...
57
+ ```
58
+
52
59
  The retrieved item enters a checkout state, and must be finalized by calling `#complete` once processing is finished.
53
60
 
54
61
  If guaranteed completion is not required and the item should be removed immediately upon retrieval, use `#pop!` instead.
55
62
 
56
- Both `#pop` and `#pop!` return a queue item as a `Hash`. If the original object was not a `Hash`, it will be stored under the `:data` key. Regardless of the original type, the queue ID is always stored under the `:queue_id` key.
63
+ Both `#pop` and `#pop!` methods returns `OrbitalQueue::QueueObject` object.
64
+ You can access queue data via `OrbitalQueue::QueueObject#data`.
65
+
66
+ When calling `#complete`, `#destruct`, or `#defer` directly on `OrbitalQueue`, `queue_id` must be given.
67
+ If you use these methods via `OrbitalQueue::QueueObject`, the `queue_id` is internally handled and can be omitted.
68
+
69
+ ### Dequeue with block
70
+
71
+ You can call `#pop` with a block.
72
+
73
+ When call with block, block is called with queue data as an argument.
74
+
75
+ queue item automatically complete when blocke ends without error.
76
+
77
+ The `#pop` method can be called with an optional block.
78
+ When used this way, the block is invoked with the item's data.
79
+ After successful execution (without exceptions), the item is considered complete and removed from the queue.
80
+
81
+ ```ruby
82
+ queue = OrbitalQueue.new("/path/to/queue")
83
+ queue.pop do |data|
84
+ #...
85
+ end
86
+ ```
87
+
88
+ ### Dequeue loop
89
+
90
+ While technically possible to loop over `#pop`, it doesn't support block-based iteration.
91
+
92
+ Use `#each` when you want to process items with a block in a loop.
93
+ It provides a clean and idiomatic Ruby style for sequential processing.
94
+
95
+ For full control over each item, use `#each_item`, which yields a `QueueObject` instead of just the data.
96
+
97
+ `#each_item` iterates over queue items as `QueueObject` instances, not raw data.
98
+ This allows direct control over each job—for example, deferring its execution using `#defer`.
99
+
100
+ Unlike `#each`, which automatically marks items as complete after the block runs,
101
+ `#each_item` exposes queue control for cases where completion isn't guaranteed or deferred handling is needed.
102
+
103
+ ### Job deferral
104
+
105
+ `OrbitalQueue` supports job deferral, enabling queue items to be scheduled for retry or postponed execution with precise control.
106
+
107
+ Calling `#defer` transitions a queue item into the deferred state.
108
+ This moves the item's file into the `.defer` directory and creates a retry metadata file in `.retry`.
109
+
110
+ Once an item has been deferred, it is associated with retry information, referred to as `retry_data`.
111
+ This is a `Hash` object that tracks rescheduling behavior.
112
+
113
+ `#defer` uses `retry_data` to determine whether the item can be retried, including retry count limits.
114
+ When called with a block, `#defer` yields `retry_data` as an argument, allowing custom modifications.
115
+
116
+ Regardless of how it's called, `retry_data` is persisted after the method returns.
117
+ To control deferral behavior, modify values inside `retry_data`—typically by changing the `:until` field (Unix timestamp).
118
+ This field specifies when the item should become eligible for re-queueing, making it ideal for implementing backoff strategies.
119
+
120
+ ```ruby
121
+ queue.each_item do |item|
122
+ begin
123
+ # Something...
124
+ rescue
125
+ item.defer(Time.now + 300) # Retry after 5 minutes.
126
+ end
127
+ end
128
+ ```
129
+
130
+ with block:
131
+
132
+ ```ruby
133
+ queue.each_item do |item|
134
+ begin
135
+ # Something...
136
+ rescue
137
+ item.defer do |retry_item|
138
+ if retry_item[:count] > 5
139
+ item.destruct
140
+ else
141
+ retry_item[:until] = Time.now + 300 * retry_item[:count]
142
+ end
143
+ end
144
+ end
145
+ end
146
+ ```
147
+
148
+ The `#defer` method's main role is to move the queue item file into the `.defer` directory.
149
+ Since `OrbitalQueue` operates without a server, it cannot scan `.defer` efficiently or restore deferred items automatically.
150
+
151
+ Aside from the internal keys `:count` and `:until`, all other values in `retry_data` are preserved as-is.
152
+ You can freely store custom metadata inside it—such as failure reasons or backoff parameters.
153
+
154
+ Note: `defer` block is called after `:count` is incremented.
155
+
156
+ The `#destruct` method removes all files associated with a queue item and raises `OrbitalQueue::ItemDestructed`.
157
+ This exception is caught inside `#defer`, allowing `#destruct` to abort the entire deferral process.
158
+
159
+ ⚠️ Do not rescue `OrbitalQueue::ItemDestructed` within a `#defer` block.
160
+ If the block completes normally after destruction, queue integrity may be violated.
161
+
162
+ The `#archive` method creates a Marshal-serialized file under `.archive`, containing the original data, its `retry_data`, and an `archiveinfo` hash.
163
+ After archiving, it calls `#destruct` to remove the live queue item.
164
+
165
+ Archived files are never accessed by OrbitalQueue itself.
166
+
167
+ Note: Because `archive` discards in-memory `retry_data`, you cannot modify it before archiving.
168
+ Instead, extra metadata should be passed as arguments to `archive` and will be merged into `archiveinfo`.
169
+
170
+ ```ruby
171
+ queue.each_item do |item|
172
+ begin
173
+ #...
174
+ rescue
175
+ item.defer do |retry_data|
176
+ if retry_data[:count] > 5
177
+ item.archive({reason: "Host timeout"})
178
+ end
179
+ end
180
+ end
181
+ end
182
+ ```
183
+
184
+ ### Resume deferred job
185
+
186
+ Deferred queue items must be manually restored using the `resume` method.
187
+ This method is typically executed by a separate worker from the one handling regular queue operations.
188
+
189
+ It is defined as an instance method:
190
+
191
+ ```ruby
192
+ queue = OrbitalQueue.new("/path/to/queue")
193
+ queue.resume
194
+ ```
195
+
196
+ For convenience, resume can also be called as a class method:
197
+
198
+ ```ruby
199
+ OrbitalQueue.resume("/path/to/queue")
200
+ ```
57
201
 
58
202
  # About Orbital Design
59
203
 
60
204
  ## Description
61
205
 
62
- ## Design pattern rules
206
+ Orbital Design is a programming pattern optimized for distributed systems.
207
+ It is especially well-suited to environments where:
208
+
209
+ * New data constantly arrives without pause
210
+ * Processing workloads vary in complexity and demand asymmetric distribution
211
+ * Systems start small but must scale seamlessly to clustered deployments
212
+
213
+ ## Philosophy
214
+
215
+ Orbital Design distinguishes between "agents" and "workers".
216
+ In most cases, an agent refers to a program, while a worker is a process.
217
+
218
+ The core principle is that **workers only need to care about what they do**.
219
+ Upon starting, a worker picks a single available job prepared for it and executes it—no coordination or negotiation required.
220
+
221
+ This behavior mirrors that of individuals in a larger society, or cells within a living organism.
222
+ Each unit performs its specific role independently.
223
+
224
+ This philosophy is deeply aligned with the Unix principle:
225
+ _"Do one thing, and do it well."_
226
+
227
+ ## Core Rules of Orbital Design
228
+
229
+ Orbital Design defines a set of principles to preserve decoupling, clarity, and safety in distributed systems:
230
+
231
+ - *Agents must remain small and focused*.
232
+ Each agent is responsible for doing one thing, and doing it well.
233
+
234
+ - *Workers must not access data unrelated to their task*, nor inspect other workers' state or progress.
235
+
236
+ - *Write access to a database or dataset must be held by exactly one agent*.
237
+ This prevents conflicting updates and maintains integrity.
238
+
239
+ - *Deletion from a database may only be performed by:*
240
+ - A worker with exclusive read access to the data, or
241
+ - A sweeper worker that receives notifications from all readers
242
+
243
+ - *Agents must not block on I/O*.
244
+ Blocking input/output disrupts concurrency and undermines distributed fairness.
245
+
246
+ ## Benefits of Orbital Design
247
+
248
+ ### Ease of Implementation
249
+
250
+ Each program is small and focused, with clearly defined responsibilities.
251
+ Because agents cannot access global state and avoid blocking operations, race conditions are structurally prevented.
252
+
253
+ This allows each unit to concentrate solely on its task—no need to worry about concurrency or system state.
254
+
255
+ ## Simplicity
256
+
257
+ Orbital Design requires minimal complexity.
258
+ It does not depend on heavy frameworks or advanced techniques.
259
+
260
+ It can be fully implemented using standard OS features such as file systems, processes, and signals.
261
+ No special measures are needed to achieve scalability.
262
+
263
+ ## Language Agnosticism
264
+
265
+ Programs are isolated and do not interfere with one another.
266
+ This allows you to implement each agent in any language that suits the task.
267
+
268
+ You can choose a language based on convenience, libraries, or performance.
269
+ Critical paths can be written in C, C++, Rust, or Nim as needed, while simpler agents may use scripting languages.
270
+
271
+ Even agents with similar functionality can be written in different languages depending on input format or operational context.
272
+
273
+ ## Parallelism and Decomposition
274
+
275
+ Restricted I/O paths eliminate contention during parallel execution.
276
+ By following the design pattern, concurrency becomes straightforward.
277
+
278
+ No locking or synchronization is required—so parallel processing not only becomes easier to write, but also more performance-effective.
279
+
280
+ Additionally, replacing I/O layers with network interfaces naturally extends the system into distributed computing.
281
+
282
+ ## Signal Friendliness
283
+
284
+ Although OS-level signals are simple and often underutilized for concurrency,
285
+ Orbital Design enables practical use of signals for multi-worker environments.
286
+
287
+ This can provide a minor advantage when building cooperative worker pools.
288
+
289
+ ## Compatibility with Systemd
290
+
291
+ Systemd's `@.service` unit files support multi-instance execution.
292
+
293
+ Agents designed with Orbital Design require no more than a worker name as an argument, making them trivially scalable to multiple instances.
294
+ This provides a low-effort pathway to multi-worker deployments, with restarts handled by Systemd itself.
295
+
296
+ ## Compatibility with Job Schedulers
297
+
298
+ Orbital Design is not limited to multi-worker or multi-instance models.
299
+ It is especially well-suited to systems that rely on periodic execution by job schedulers.
300
+
301
+ While worker-driven systems react to runtime state, job schedulers operate on time-based triggers.
302
+ Thanks to its stateless model, Orbital Design allows agents to run regardless of timing or system condition.
303
+
304
+ ## Shell Script Friendly
305
+
306
+ Each agent has a clear and narrow scope, with no need for shared database schemas.
307
+ This makes it easy to write parts of the system in shell scripts where appropriate.
308
+
309
+ In practice, this results in simpler and more maintainable solutions in more cases than expected.
310
+
311
+ ## Replaceable Components
312
+
313
+ Programs in Orbital Design are small and well-scoped.
314
+ When a language, library, or performance characteristic becomes a limitation, swapping out components comes at low cost.
315
+
316
+ This helps maintain long-term system health and avoids software decay.
317
+
318
+ # Finally
319
+
320
+ “The library is minimal. The idea is not.”
63
321
 
322
+ Orbital Design is not a framework. It's a way of thinking. It thrives where ideas are shared freely.
data/lib/orbitalqueue.rb CHANGED
@@ -14,19 +14,29 @@ class OrbitalQueue
14
14
  class QueueUnexisting < QueueError
15
15
  end
16
16
 
17
+ class ItemDestructed < QueueError
18
+ end
19
+
20
+ # Return deferred item to queue
21
+ def self.resume dir
22
+ self.new(dir).resume
23
+ end
24
+
17
25
  # Create queue master in presented dir.
18
26
  #
19
- # dir: Queue directory
20
- # create: If true is given, creates the queue directory when it is missing
27
+ # dir:: Queue directory
28
+ # create:: If true is given, creates the queue directory when it is missing
21
29
  def initialize dir, create=false
22
30
  @queue_dir = dir
23
31
 
24
- unless File.exist?(File.join(dir, ".checkout"))
25
- if create
26
- require 'fileutils'
27
- FileUtils.mkdir_p(File.join(dir, ".checkout"))
28
- else
29
- raise QueueUnexisting.new("Queue directory #{dir} does not exist.")
32
+ %w:.checkout .defer .retry .archive:.each do |subdir|
33
+ unless File.exist?(File.join(dir, subdir))
34
+ if create
35
+ require 'fileutils'
36
+ FileUtils.mkdir_p(File.join(dir, subdir))
37
+ else
38
+ raise QueueUnexisting.new("Queue directory #{dir} does not exist.")
39
+ end
30
40
  end
31
41
  end
32
42
  end
@@ -47,6 +57,10 @@ class OrbitalQueue
47
57
  # Popped queue items are placed in the checkout directory. After processing is complete, +#complete+ must be called to remove the item from the queue.
48
58
  #
49
59
  # If block is given, complete automatically after yield.
60
+ #
61
+ # :call-seq:
62
+ # pop() -> queue_object
63
+ # pop() {|data| ... } -> queue_id
50
64
  def pop
51
65
  queue_data = nil
52
66
  queue_id = nil
@@ -66,7 +80,7 @@ class OrbitalQueue
66
80
  break
67
81
  end
68
82
 
69
- if block_given?
83
+ if queue_data && block_given?
70
84
  yield queue_data.data
71
85
  complete queue_id
72
86
  else
@@ -74,7 +88,10 @@ class OrbitalQueue
74
88
  end
75
89
  end
76
90
 
77
- # Pop data from queue and remove it from queue.
91
+ # Pop data and remove it from queue.
92
+ #
93
+ # :call-seq:
94
+ # pop!() -> queue_object
78
95
  def pop!
79
96
  queue_item = pop
80
97
  if queue_item
@@ -84,16 +101,138 @@ class OrbitalQueue
84
101
  queue_item
85
102
  end
86
103
 
104
+ # Iterate each queue item data.
105
+ def each
106
+ while item = pop
107
+ yield item.data
108
+ item.complete
109
+ end
110
+ end
111
+
112
+ # Iterate each queue item.
113
+ def each_item
114
+ while item = pop
115
+ yield item
116
+ item.complete unless item.deferred?
117
+ end
118
+ end
119
+
87
120
  # Remove checked out queue item.
88
121
  def complete queue_id
89
122
  begin
90
- File.delete(File.join(@queue_dir, ".checkout", (queue_id + ".marshal")))
123
+ checkout_file = File.join(@queue_dir, ".checkout", (queue_id + ".marshal"))
124
+ retry_file = File.join(@queue_dir, ".retry", (queue_id + ".marshal"))
125
+ File.delete(checkout_file)
126
+ File.delete(retry_file) if File.exist?(retry_file)
91
127
  rescue SystemCallError => e
92
128
  raise QueueRemoveError, "Failed to complete queue #{queue_id}: #{e.class}"
93
129
  end
94
130
 
95
131
  queue_id
96
132
  end
133
+
134
+ # Delete all related files with queue_id, and raise ItemDectructed exception.
135
+ def destruct queue_id
136
+ queue_files = Dir.glob([@queue_dir, "**", (queue_id + ".marshal")].join("/"), File::FNM_DOTMATCH)
137
+ File.delete(*queue_files) unless queue_files.empty?
138
+ raise ItemDestructed, "#{queue_id} is destructed."
139
+ end
140
+
141
+ # Archive current queue relative data and call +destruct+.
142
+ # This method should be called from QueueObject.
143
+ def archive queue_id, data, archiveinfo_additional={} # :nodoc:
144
+ archiveinfo = archiveinfo_additional.merge({
145
+ archived_at: Time.now.to_i
146
+ })
147
+
148
+ retry_data = load_retryobj queue_id
149
+
150
+ archive_data = {
151
+ archiveinfo: archiveinfo,
152
+ retry_data: retry_data,
153
+ data: data
154
+ }
155
+
156
+ File.open(File.join(@queue_dir, ".archive", (["archive", archiveinfo[:archived_at], queue_id].join("-") + ".marshal")), "w") {|f| Marshal.dump archive_data, f}
157
+
158
+ destruct queue_id
159
+ end
160
+
161
+ # Mark queue item as deferred.
162
+ #
163
+ # :call-seq:
164
+ # defer(queue_id, time_at, max_count=nil) -> retry_data | nil
165
+ # defer() {|retry_data| ... } -> retry_data | nil
166
+ def defer queue_id, time_at=nil, max_count=nil
167
+ retry_data = load_retryobj queue_id
168
+ retry_data[:count] += 1
169
+ if block_given?
170
+ yield retry_data
171
+ retry_data[:until] = retry_data[:until].to_i
172
+ else
173
+ unless time_at
174
+ raise ArgumentError, "time_at is required when no block is given."
175
+ end
176
+
177
+ if max_count && retry_data[:count] > max_count
178
+ destruct queue_id
179
+ end
180
+ retry_data[:until] = time_at.to_i
181
+ end
182
+
183
+ dump_retryobj queue_id, retry_data
184
+
185
+ checkout_path = File.join(@queue_dir, ".checkout", (queue_id) + ".marshal")
186
+ defer_path = File.join(@queue_dir, ".defer", (queue_id) + ".marshal")
187
+ File.rename checkout_path, defer_path
188
+
189
+ retry_data
190
+ rescue ItemDestructed
191
+ nil
192
+ end
193
+
194
+ # Return deferred item to queue.
195
+ def resume
196
+ now = Time.now.to_i
197
+ deferred_files = Dir.children(File.join(@queue_dir, ".retry"))
198
+ deferred_files.each do |fn|
199
+ retry_path = File.join(@queue_dir, ".retry", fn)
200
+ retry_data = Marshal.load File.read retry_path
201
+
202
+ if retry_data[:until] < now
203
+ queue_path = File.join(@queue_dir, fn)
204
+ defer_path = File.join(@queue_dir, ".defer", fn)
205
+ File.rename(defer_path, queue_path)
206
+ end
207
+ end
208
+
209
+ nil
210
+ end
211
+
212
+ private
213
+
214
+ # Save to .retry
215
+ def dump_retryobj queue_id, data
216
+ retry_path = File.join(@queue_dir, ".retry", (queue_id) + ".marshal")
217
+ File.open(retry_path, "w") {|f| Marshal.dump data, f }
218
+ nil
219
+ end
220
+
221
+ # Load from .retry
222
+ def load_retryobj queue_id
223
+ retry_path = File.join(@queue_dir, ".retry", (queue_id) + ".marshal")
224
+ retry_data = nil
225
+ if File.exist? retry_path
226
+ retry_data = Marshal.load File.read retry_path
227
+ else
228
+ retry_data = {
229
+ count: 0,
230
+ until: nil
231
+ }
232
+ end
233
+
234
+ retry_data
235
+ end
97
236
  end
98
237
 
99
238
  # Queue item capsule.
@@ -103,6 +242,7 @@ class OrbitalQueue::QueueObject
103
242
  @data = data
104
243
  @queue_id = queue_id
105
244
  @completed = false
245
+ @deferred = false
106
246
  end
107
247
 
108
248
  attr_reader :data
@@ -118,8 +258,41 @@ class OrbitalQueue::QueueObject
118
258
  end
119
259
  end
120
260
 
261
+ # Wrap for the end of queue item.
262
+ def destruct
263
+ @completed = true
264
+ @queue.destruct(@queue_id)
265
+ end
266
+
267
+ # Archive current queue relative data and call +destruct+.
268
+ def archive archiveinfo_additional={}
269
+ @completed = true
270
+ @queue.archive @queue_id, @data, archiveinfo_additional
271
+ end
272
+
121
273
  # Terrible redundunt method.
122
- def complete?
274
+ def complete? # :nodoc:
123
275
  @completed
124
276
  end
125
- end
277
+
278
+ # Retry later.
279
+ #
280
+ # time_at:: Deferring retry until this time
281
+ # max_count:: Retry count limit
282
+ #
283
+ # :call-seq:
284
+ # defer(time_at, max_count=nil) -> retry_data
285
+ # defer() {|retry_data| ... } -> retry_data
286
+ def defer time_at=nil, max_count=nil, &block
287
+ if block
288
+ @queue.defer(@queue_id, &block)
289
+ else
290
+ @queue.defer(@queue_id, time_at, max_count)
291
+ end
292
+ @deferred = true
293
+ end
294
+
295
+ def deferred?
296
+ @deferred
297
+ end
298
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: orbitalqueue
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaki Haruka