tina4ruby 3.10.90 → 3.10.91

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.
data/lib/tina4/graphql.rb CHANGED
@@ -68,23 +68,36 @@ module Tina4
68
68
  register_scalars
69
69
  end
70
70
 
71
- def add_type(type)
72
- @types[type.name] = type
71
+ # add_type(name, fields) — parity with PHP/Python/Node
72
+ # add_type(type_object) — legacy Ruby form (type_object responds to .name)
73
+ def add_type(name_or_type, fields = nil)
74
+ if fields
75
+ # New form: add_type("User", { "id" => "ID", "name" => "String" })
76
+ @types[name_or_type] = fields
77
+ else
78
+ # Legacy form: add_type(GraphQLType.new(...))
79
+ @types[name_or_type.name] = name_or_type
80
+ end
73
81
  end
74
82
 
75
83
  def get_type(name)
76
84
  @types[name]
77
85
  end
78
86
 
79
- # Register a query field
80
- # schema.add_query("user", type: "User", args: { id: { type: "ID!" } }) { |root, args, ctx| ... }
81
- def add_query(name, type:, args: {}, description: nil, &resolve)
82
- @queries[name] = { type: type, args: args, resolve: resolve, description: description }
87
+ # Register a query field.
88
+ # Cross-framework form: add_query(name, args, return_type, resolver)
89
+ # Block form also accepted: add_query(name, args, return_type) { |root, args, ctx| ... }
90
+ def add_query(name, args = {}, return_type = nil, resolver = nil, &block)
91
+ resolve = resolver || block
92
+ @queries[name] = { type: return_type, args: args, resolve: resolve }
83
93
  end
84
94
 
85
- # Register a mutation field
86
- def add_mutation(name, type:, args: {}, description: nil, &resolve)
87
- @mutations[name] = { type: type, args: args, resolve: resolve, description: description }
95
+ # Register a mutation field.
96
+ # Cross-framework form: add_mutation(name, args, return_type, resolver)
97
+ # Block form also accepted: add_mutation(name, args, return_type) { |root, args, ctx| ... }
98
+ def add_mutation(name, args = {}, return_type = nil, resolver = nil, &block)
99
+ resolve = resolver || block
100
+ @mutations[name] = { type: return_type, args: args, resolve: resolve }
88
101
  end
89
102
 
90
103
  # ── ORM Auto-Schema ──────────────────────────────────────────────────
@@ -127,17 +140,13 @@ module Tina4
127
140
  # ── Queries ──
128
141
 
129
142
  # Single record: user(id: ID!): User
130
- add_query(table_lower, type: type_name,
131
- args: { pk_field => { type: "ID!" } },
132
- description: "Fetch a single #{model_name} by #{pk_field}") do |_root, args, _ctx|
143
+ add_query(table_lower, { pk_field => { type: "ID!" } }, type_name) do |_root, args, _ctx|
133
144
  record = klass.find_by_id(args[pk_field])
134
145
  record&.to_hash
135
146
  end
136
147
 
137
148
  # List: users(limit: Int, offset: Int): [User]
138
- add_query(plural, type: "[#{type_name}]",
139
- args: { "limit" => { type: "Int" }, "offset" => { type: "Int" } },
140
- description: "Fetch a list of #{model_name} records") do |_root, args, _ctx|
149
+ add_query(plural, { "limit" => { type: "Int" }, "offset" => { type: "Int" } }, "[#{type_name}]") do |_root, args, _ctx|
141
150
  limit = args["limit"] || 100
142
151
  offset = args["offset"] || 0
143
152
  result = klass.all(limit: limit, offset: offset)
@@ -147,17 +156,13 @@ module Tina4
147
156
  # ── Mutations ──
148
157
 
149
158
  # Create
150
- add_mutation("create#{model_name}", type: type_name,
151
- args: { "input" => { type: "#{type_name}Input!" } },
152
- description: "Create a new #{model_name}") do |_root, args, _ctx|
159
+ add_mutation("create#{model_name}", { "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
153
160
  record = klass.create(args["input"] || {})
154
161
  record.respond_to?(:to_hash) ? record.to_hash : record
155
162
  end
156
163
 
157
164
  # Update
158
- add_mutation("update#{model_name}", type: type_name,
159
- args: { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } },
160
- description: "Update an existing #{model_name}") do |_root, args, _ctx|
165
+ add_mutation("update#{model_name}", { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
161
166
  record = klass.find_by_id(args[pk_field])
162
167
  return nil unless record
163
168
  (args["input"] || {}).each { |k, v| record.send(:"#{k}=", v) if record.respond_to?(:"#{k}=") }
@@ -166,9 +171,7 @@ module Tina4
166
171
  end
167
172
 
168
173
  # Delete
169
- add_mutation("delete#{model_name}", type: "Boolean",
170
- args: { pk_field => { type: "ID!" } },
171
- description: "Delete a #{model_name} by #{pk_field}") do |_root, args, _ctx|
174
+ add_mutation("delete#{model_name}", { pk_field => { type: "ID!" } }, "Boolean") do |_root, args, _ctx|
172
175
  record = klass.find_by_id(args[pk_field])
173
176
  return false unless record
174
177
  record.delete
data/lib/tina4/health.rb CHANGED
@@ -8,7 +8,7 @@ module Tina4
8
8
 
9
9
  class << self
10
10
  def register!
11
- Tina4::Router.add_route("GET", "/health", method(:handle))
11
+ Tina4::Router.add("GET", "/health", method(:handle))
12
12
  end
13
13
 
14
14
  def handle(_request, response)
data/lib/tina4/job.rb CHANGED
@@ -7,7 +7,7 @@ module Tina4
7
7
  attr_reader :id, :topic, :payload, :created_at, :attempts, :priority, :available_at
8
8
  attr_accessor :status
9
9
 
10
- def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil, attempts: 0)
10
+ def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil, attempts: 0, queue: nil)
11
11
  @id = id || SecureRandom.uuid
12
12
  @topic = topic
13
13
  @payload = payload
@@ -16,15 +16,19 @@ module Tina4
16
16
  @priority = priority
17
17
  @available_at = available_at
18
18
  @status = :pending
19
+ @queue = queue
19
20
  end
20
21
 
21
22
  # Re-queue this message with incremented attempts.
22
- # Delegates to the queue's backend via the queue reference.
23
- def retry(queue:, delay_seconds: 0)
23
+ # Uses the stored queue reference (set at construction time).
24
+ def retry(delay_seconds: 0)
25
+ q = @queue
26
+ raise ArgumentError, "No queue reference — set at construction" unless q
27
+
24
28
  @attempts += 1
25
29
  @status = :pending
26
30
  @available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
27
- queue.backend.enqueue(self)
31
+ q.backend.enqueue(self)
28
32
  self
29
33
  end
30
34
 
@@ -64,6 +64,27 @@ module Tina4
64
64
  value
65
65
  end
66
66
 
67
+ def set_locale(locale)
68
+ self.current_locale = locale.to_s
69
+ end
70
+
71
+ def get_locale
72
+ current_locale
73
+ end
74
+
75
+ def translate(key, params: nil, locale: nil)
76
+ t(key, locale: locale, **(params || {}))
77
+ end
78
+
79
+ def load_translations(locale)
80
+ load(Dir.pwd) if translations.empty?
81
+ translations[locale.to_s] || {}
82
+ end
83
+
84
+ def add_translation(locale, key, value)
85
+ add(locale, key, value)
86
+ end
87
+
67
88
  def add(locale, key, value)
68
89
  translations[locale.to_s] ||= {}
69
90
  keys = key.to_s.split(".")
@@ -16,6 +16,11 @@ module Tina4
16
16
  @global_middleware ||= []
17
17
  end
18
18
 
19
+ # Parity alias matching Python/PHP/Node orchestrators.
20
+ def get_global
21
+ global_middleware.dup
22
+ end
23
+
19
24
  def before(pattern = nil, &block)
20
25
  before_handlers << { pattern: pattern, handler: block }
21
26
  end
@@ -37,9 +42,13 @@ module Tina4
37
42
  end
38
43
 
39
44
  # Run all "before" hooks: block-based handlers, then class-based before_* methods.
45
+ #
46
+ # Signature matches Python/PHP/Node orchestrators: pass the list of
47
+ # middleware classes explicitly.
48
+ #
40
49
  # Returns [request, response] on success, or false to halt the request.
41
- def run_before(request, response)
42
- # 1. Block-based before handlers (backward compat)
50
+ def run_before(middleware_classes, request, response)
51
+ # 1. Block-based before handlers (pattern-matched)
43
52
  before_handlers.each do |entry|
44
53
  next unless matches_pattern?(request.path, entry[:pattern])
45
54
  result = entry[:handler].call(request, response)
@@ -47,7 +56,7 @@ module Tina4
47
56
  end
48
57
 
49
58
  # 2. Class-based middleware: call every before_* method
50
- global_middleware.each do |klass|
59
+ middleware_classes.each do |klass|
51
60
  before_methods_for(klass).each do |method_name|
52
61
  result = klass.send(method_name, request, response)
53
62
  # Support returning [request, response] (Python convention) or false to halt
@@ -65,15 +74,18 @@ module Tina4
65
74
  end
66
75
 
67
76
  # Run all "after" hooks: block-based handlers, then class-based after_* methods.
68
- def run_after(request, response)
69
- # 1. Block-based after handlers (backward compat)
77
+ #
78
+ # Signature matches Python/PHP/Node orchestrators: pass the list of
79
+ # middleware classes explicitly.
80
+ def run_after(middleware_classes, request, response)
81
+ # 1. Block-based after handlers (pattern-matched)
70
82
  after_handlers.each do |entry|
71
83
  next unless matches_pattern?(request.path, entry[:pattern])
72
84
  entry[:handler].call(request, response)
73
85
  end
74
86
 
75
87
  # 2. Class-based middleware: call every after_* method
76
- global_middleware.each do |klass|
88
+ middleware_classes.each do |klass|
77
89
  after_methods_for(klass).each do |method_name|
78
90
  result = klass.send(method_name, request, response)
79
91
  if result.is_a?(Array) && result.length == 2
@@ -61,30 +61,75 @@ module Tina4
61
61
  end
62
62
 
63
63
  # Create a new migration file
64
- def create(name)
64
+ #
65
+ # kind="ruby" — creates {timestamp}_{description}.rb with MigrationBase subclass (default)
66
+ # kind="sql" — creates {timestamp}_{description}.sql + .down.sql
67
+ # kind="python" — alias for "ruby" (class-based scaffold for cross-framework parity)
68
+ def create(description, kind = "sql")
65
69
  FileUtils.mkdir_p(@migrations_dir)
66
70
  timestamp = Time.now.strftime("%Y%m%d%H%M%S")
67
- filename = "#{timestamp}_#{name.gsub(/\s+/, '_')}.rb"
68
- filepath = File.join(@migrations_dir, filename)
71
+ created_at = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
72
+ safe_name = description.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
73
+
74
+ if kind == "ruby" || kind == "python"
75
+ filename = "#{timestamp}_#{safe_name}.rb"
76
+ filepath = File.join(@migrations_dir, filename)
77
+
78
+ File.write(filepath, <<~RUBY)
79
+ # frozen_string_literal: true
80
+ # Migration: #{description}
81
+ # Created: #{created_at}
82
+
83
+ class #{classify(description)} < Tina4::MigrationBase
84
+ def up(db = nil)
85
+ # db.execute("CREATE TABLE ...")
86
+ end
87
+
88
+ def down(db = nil)
89
+ # db.execute("DROP TABLE IF EXISTS ...")
90
+ end
91
+ end
92
+ RUBY
69
93
 
70
- File.write(filepath, <<~RUBY)
71
- # frozen_string_literal: true
72
- # Migration: #{name}
73
- # Created: #{Time.now}
94
+ Tina4::Log.info("Created migration: #{filename}")
95
+ return filepath
96
+ end
74
97
 
75
- class #{classify(name)} < Tina4::MigrationBase
76
- def up(db)
77
- # db.exec("CREATE TABLE ...")
78
- end
98
+ # Default: SQL
99
+ up_filename = "#{timestamp}_#{safe_name}.sql"
100
+ down_filename = "#{timestamp}_#{safe_name}.down.sql"
101
+ up_path = File.join(@migrations_dir, up_filename)
102
+ down_path = File.join(@migrations_dir, down_filename)
79
103
 
80
- def down(db)
81
- # db.exec("DROP TABLE IF EXISTS ...")
82
- end
83
- end
84
- RUBY
104
+ File.write(up_path, "-- Migration: #{description}\n-- Created: #{created_at}\n\n")
105
+ File.write(down_path, "-- Rollback: #{description}\n-- Created: #{created_at}\n\n")
85
106
 
86
- Tina4::Log.info("Created migration: #{filename}")
87
- filepath
107
+ Tina4::Log.info("Created migration: #{up_filename}")
108
+ up_path
109
+ end
110
+
111
+ # Insert a record into the migration tracking table.
112
+ #
113
+ # @param name [String] Migration filename (e.g. "20240101000000_create_users.sql")
114
+ # @param batch [Integer] Batch number this migration belongs to
115
+ # @param passed [Integer] 1 if successful (default), 0 if failed
116
+ def record_migration(name, batch, passed: 1)
117
+ _record_migration(name, batch, passed: passed)
118
+ end
119
+
120
+ # Delete a migration record from the tracking table by filename.
121
+ #
122
+ # @param name [String] Migration filename to remove
123
+ def remove_migration_record(name)
124
+ _remove_migration_record(name)
125
+ end
126
+
127
+ # Create a migration file — static helper for parity with Python/Node.
128
+ # @param description [String] Human-readable migration name
129
+ # @param migrations_dir [String] Directory for migration files (default: 'migrations')
130
+ # @param kind [String] File kind: 'sql' or 'ruby' (default: 'sql')
131
+ def self.create_migration(description, migrations_dir: "migrations", kind: "sql")
132
+ new(nil, migrations_dir: migrations_dir).create(description, kind)
88
133
  end
89
134
 
90
135
  # Get list of applied migration records (public alias for completed_migrations)
@@ -92,6 +137,11 @@ module Tina4
92
137
  completed_migrations
93
138
  end
94
139
 
140
+ # Alias for get_applied — parity with PHP/Node
141
+ def get_applied_migrations
142
+ get_applied
143
+ end
144
+
95
145
  # Get list of pending migration filenames (public alias for pending_migrations)
96
146
  def get_pending
97
147
  pending_migrations.map { |f| File.basename(f) }
@@ -119,6 +169,7 @@ module Tina4
119
169
  end
120
170
 
121
171
  def ensure_tracking_table
172
+ return unless @db
122
173
  unless @db.table_exists?(TRACKING_TABLE)
123
174
  if firebird?
124
175
  # Firebird: no AUTOINCREMENT, no TEXT type, use generator for IDs
@@ -201,11 +252,11 @@ module Tina4
201
252
  else
202
253
  execute_sql_file(file)
203
254
  end
204
- record_migration(name, batch, passed: 1)
255
+ _record_migration(name, batch, passed: 1)
205
256
  { name: name, status: "success" }
206
257
  rescue => e
207
258
  Tina4::Log.error("Migration failed: #{name} - #{e.message}")
208
- record_migration(name, batch, passed: 0)
259
+ _record_migration(name, batch, passed: 0)
209
260
  { name: name, status: "failed", error: e.message }
210
261
  end
211
262
  end
@@ -224,7 +275,7 @@ module Tina4
224
275
  Tina4::Log.warning("No rollback file for: #{name}")
225
276
  end
226
277
  end
227
- remove_migration_record(name)
278
+ _remove_migration_record(name)
228
279
  { name: name, status: "rolled_back" }
229
280
  rescue => e
230
281
  Tina4::Log.error("Rollback failed: #{name} - #{e.message}")
@@ -345,7 +396,7 @@ module Tina4
345
396
  end
346
397
  end
347
398
 
348
- def record_migration(name, batch, passed: 1)
399
+ def _record_migration(name, batch, passed: 1)
349
400
  # Extract description from filename (strip numeric prefix and extension)
350
401
  stem = File.basename(name, File.extname(name))
351
402
  desc = stem.sub(/\A\d+_/, "").tr("_", " ")
@@ -367,7 +418,7 @@ module Tina4
367
418
  end
368
419
  end
369
420
 
370
- def remove_migration_record(name)
421
+ def _remove_migration_record(name)
371
422
  @db.delete(TRACKING_TABLE, { migration_name: name })
372
423
  end
373
424
 
@@ -389,11 +440,11 @@ module Tina4
389
440
 
390
441
  # Base class for Ruby migrations
391
442
  class MigrationBase
392
- def up(db)
443
+ def up(db = nil)
393
444
  raise NotImplementedError, "Implement #up in your migration"
394
445
  end
395
446
 
396
- def down(db)
447
+ def down(db = nil)
397
448
  raise NotImplementedError, "Implement #down in your migration"
398
449
  end
399
450
  end
data/lib/tina4/orm.rb CHANGED
@@ -243,14 +243,14 @@ module Tina4
243
243
  end
244
244
  end
245
245
 
246
- def where(conditions, params = [], include: nil) # -> list[Self]
246
+ def where(conditions, params = [], limit: 20, offset: 0, include: nil) # -> list[Self]
247
247
  sql = "SELECT * FROM #{table_name}"
248
248
  if soft_delete
249
249
  sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
250
250
  else
251
251
  sql += " WHERE #{conditions}"
252
252
  end
253
- results = db.fetch(sql, params)
253
+ results = db.fetch(sql, params, limit: limit, offset: offset)
254
254
  instances = results.map { |row| from_hash(row) }
255
255
  eager_load(instances, include) if include
256
256
  instances
@@ -394,13 +394,32 @@ module Tina4
394
394
  end
395
395
 
396
396
  # Find a single record by primary key. Returns instance or nil.
397
- def find_by_id(id) # -> Self | nil
397
+ def find_by_id(id, include: nil) # -> Self | nil
398
398
  pk = primary_key_field || :id
399
399
  sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
400
400
  if soft_delete
401
401
  sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
402
402
  end
403
- select_one(sql, [id])
403
+ select_one(sql, [id], include: include)
404
+ end
405
+
406
+ # Clear the relationship cache on all loaded instances (class-level helper).
407
+ # Useful after bulk operations when you want to force relationship re-loads.
408
+ def clear_rel_cache # -> nil
409
+ @_rel_cache = {}
410
+ nil
411
+ end
412
+
413
+ # Return the database connection used by this model.
414
+ def get_db # -> Database
415
+ db
416
+ end
417
+
418
+ # Map a Ruby property name to its database column name using field_mapping.
419
+ # Returns the column name as a symbol.
420
+ def get_db_column(property) # -> Symbol
421
+ col = field_mapping[property.to_s] || property
422
+ col.to_sym
404
423
  end
405
424
 
406
425
  private
data/lib/tina4/queue.rb CHANGED
@@ -30,7 +30,7 @@ module Tina4
30
30
  # delay_seconds: delay before the message becomes available (default 0).
31
31
  def push(payload, priority: 0, delay_seconds: 0)
32
32
  available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
33
- message = Job.new(topic: @topic, payload: payload, priority: priority, available_at: available_at)
33
+ message = Job.new(topic: @topic, payload: payload, priority: priority, available_at: available_at, queue: self)
34
34
  @backend.enqueue(message)
35
35
  message
36
36
  end
@@ -40,6 +40,21 @@ module Tina4
40
40
  @backend.dequeue(@topic)
41
41
  end
42
42
 
43
+ # Pop up to count jobs at once. Returns a partial batch if fewer available.
44
+ def pop_batch(count)
45
+ if @backend.respond_to?(:dequeue_batch)
46
+ @backend.dequeue_batch(@topic, count)
47
+ else
48
+ jobs = []
49
+ count.times do
50
+ job = @backend.dequeue(@topic)
51
+ break if job.nil?
52
+ jobs << job
53
+ end
54
+ jobs
55
+ end
56
+ end
57
+
43
58
  # Clear all pending jobs from this queue's topic. Returns count removed.
44
59
  def clear # -> int
45
60
  return 0 unless @backend.respond_to?(:clear)
@@ -52,34 +67,37 @@ module Tina4
52
67
  @backend.failed(@topic, max_retries: @max_retries)
53
68
  end
54
69
 
55
- # Retry failed jobs on this queue's topic. Returns true if re-queued.
56
- def retry(delay_seconds = 0) # -> bool
70
+ # Retry a specific failed job by ID, or all dead-letter jobs if no id given.
71
+ # Returns true if re-queued.
72
+ def retry(job_id = nil, delay_seconds: 0) # -> bool
57
73
  return false unless @backend.respond_to?(:retry_job)
58
- @backend.retry_job(@topic, delay_seconds: delay_seconds)
74
+ @backend.retry_job(@topic, job_id: job_id, delay_seconds: delay_seconds)
59
75
  end
60
76
 
61
77
  # Get dead letter jobs — messages that exceeded max retries.
62
- def dead_letters # -> list[dict]
78
+ # Pass max_retries to override the queue's default.
79
+ def dead_letters(max_retries: nil) # -> list[dict]
63
80
  return [] unless @backend.respond_to?(:dead_letters)
64
- @backend.dead_letters(@topic, max_retries: @max_retries)
81
+ @backend.dead_letters(@topic, max_retries: max_retries || @max_retries)
65
82
  end
66
83
 
67
84
  # Delete messages by status (completed, failed, dead).
68
- def purge(status) # -> int
85
+ def purge(status, max_retries: nil) # -> int
69
86
  return 0 unless @backend.respond_to?(:purge)
70
87
  @backend.purge(@topic, status)
71
88
  end
72
89
 
73
90
  # Re-queue failed messages (under max_retries) back to pending.
74
91
  # Returns the number of jobs re-queued.
75
- def retry_failed # -> int
92
+ def retry_failed(max_retries: nil) # -> int
76
93
  return 0 unless @backend.respond_to?(:retry_failed)
77
- @backend.retry_failed(@topic, max_retries: @max_retries)
94
+ @backend.retry_failed(@topic, max_retries: max_retries || @max_retries)
78
95
  end
79
96
 
80
97
  # Produce a message onto a topic. Convenience wrapper around push().
81
- def produce(topic, payload, priority = 0)
82
- message = Job.new(topic: topic, payload: payload, priority: priority)
98
+ def produce(topic, payload, priority: 0, delay_seconds: 0)
99
+ available_at = delay_seconds > 0 ? Time.now + delay_seconds : nil
100
+ message = Job.new(topic: topic, payload: payload, priority: priority, available_at: available_at, queue: self)
83
101
  @backend.enqueue(message)
84
102
  message
85
103
  end
@@ -108,7 +126,7 @@ module Tina4
108
126
  # queue.consume("emails", poll_interval: 5) { |job| process(job) }
109
127
  # queue.consume("emails", id: "abc-123") { |job| process(job) }
110
128
  #
111
- def consume(topic = nil, id: nil, poll_interval: 1.0, iterations: 0, &block)
129
+ def consume(topic = nil, id: nil, poll_interval: 1.0, iterations: 0, batch_size: 1, &block)
112
130
  topic ||= @topic
113
131
 
114
132
  if id
@@ -125,16 +143,30 @@ module Tina4
125
143
  # iterations>0 → stop after consuming N jobs
126
144
  if block_given?
127
145
  consumed = 0
128
- loop do
129
- job = @backend.dequeue(topic)
130
- if job.nil?
131
- break if poll_interval <= 0
132
- sleep(poll_interval)
133
- next
146
+ if batch_size > 1
147
+ loop do
148
+ jobs = pop_batch(batch_size)
149
+ if jobs.empty?
150
+ break if poll_interval <= 0
151
+ sleep(poll_interval)
152
+ next
153
+ end
154
+ yield jobs
155
+ consumed += jobs.length
156
+ break if iterations > 0 && consumed >= iterations
157
+ end
158
+ else
159
+ loop do
160
+ job = @backend.dequeue(topic)
161
+ if job.nil?
162
+ break if poll_interval <= 0
163
+ sleep(poll_interval)
164
+ next
165
+ end
166
+ yield job
167
+ consumed += 1
168
+ break if iterations > 0 && consumed >= iterations
134
169
  end
135
- yield job
136
- consumed += 1
137
- break if iterations > 0 && consumed >= iterations
138
170
  end
139
171
  else
140
172
  Enumerator.new do |yielder|
@@ -178,6 +210,49 @@ module Tina4
178
210
  end
179
211
  end
180
212
 
213
+ # Get the topic name this queue was constructed with.
214
+ def get_topic
215
+ @topic
216
+ end
217
+
218
+ # Consume all available jobs and pass each to handler, then stop.
219
+ #
220
+ # Simpler alternative to consume() for drain-and-exit use cases.
221
+ #
222
+ # queue.process { |job| handle(job); job.complete }
223
+ # queue.process(topic: "emails", max_jobs: 10) { |job| ... }
224
+ #
225
+ def process(topic: nil, max_jobs: nil, batch_size: 1, &handler)
226
+ raise ArgumentError, "block required" unless block_given?
227
+ drain_topic = topic || @topic
228
+ processed = 0
229
+ loop do
230
+ break if max_jobs && processed >= max_jobs
231
+ if batch_size > 1
232
+ remaining = max_jobs ? [batch_size, max_jobs - processed].min : batch_size
233
+ jobs = @backend.respond_to?(:dequeue_batch) ?
234
+ @backend.dequeue_batch(drain_topic, remaining) :
235
+ (1..remaining).map { @backend.dequeue(drain_topic) }.compact
236
+ break if jobs.empty?
237
+ begin
238
+ handler.call(jobs)
239
+ rescue => e
240
+ jobs.each { |job| job.fail(e.message) }
241
+ end
242
+ processed += jobs.length
243
+ else
244
+ job = @backend.dequeue(drain_topic)
245
+ break if job.nil?
246
+ begin
247
+ handler.call(job)
248
+ rescue => e
249
+ job.fail(e.message)
250
+ end
251
+ processed += 1
252
+ end
253
+ end
254
+ end
255
+
181
256
  # Get the underlying backend instance.
182
257
  def backend
183
258
  @backend