rage-rb 1.28.2 → 1.29.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.
@@ -10,8 +10,10 @@ require "zlib"
10
10
  # * `remove_task` - called when a task has to be removed from the storage;
11
11
  # * `pending_tasks` - the method should iterate over the underlying storage and return a list of tasks to replay;
12
12
  # * `add_dead_task` - called when a task has exhausted its retries or aborted them;
13
- # * `list_dead_tasks` - return a list of dead tasks, newest first;
14
- # * `find_dead_task` - return a single dead task;
13
+ # * `each_dead_task` - yields one record for each task ID that exists when traversal starts;
14
+ # if multiple records have the same task ID, it yields the newest record;
15
+ # records added during traversal are not included;
16
+ # * `find_dead_task` - returns the newest dead-task record with the exact task ID;
15
17
  # * `remove_dead_tasks` - permanently delete dead tasks;
16
18
  #
17
19
  class Rage::Deferred::Backends::Disk
@@ -53,19 +55,23 @@ class Rage::Deferred::Backends::Disk
53
55
  @dead_tasks_storage.add(task_id, context, exception, task_class:, attempts:)
54
56
  end
55
57
 
56
- # Return a list of dead-lettered tasks, newest first.
57
- # @param limit [Integer, nil] the maximum number of records to return
58
- # @param offset [Integer] the number of records to skip
59
- # @return [Array<Hash>]
60
- def list_dead_tasks(limit: nil, offset: 0)
61
- @dead_tasks_storage.list(limit:, offset:)
58
+ # Yield one record for each task ID that exists when traversal starts.
59
+ # If multiple records have the same task ID, yield the newest record.
60
+ # Do not include records that Rage adds during traversal.
61
+ # Yield records from oldest to newest.
62
+ # @yieldparam record [Hash] a dead-task record
63
+ # @return [Rage::Deferred::Backends::Disk::DeadTasksStorage]
64
+ # @private
65
+ def each_dead_task(&block)
66
+ @dead_tasks_storage.each(&block)
62
67
  end
63
68
 
64
- # Return a single dead-lettered task.
65
- # @param id [String] the id of the dead-task record
66
- # @return [Hash, nil]
69
+ # Return the newest dead-task record with the exact task ID.
70
+ # @param id [String] the task ID
71
+ # @return [Hash, nil] the matching record, or nil if there is no matching record
72
+ # @private
67
73
  def find_dead_task(id)
68
- @dead_tasks_storage.find(id)
74
+ @dead_tasks_storage.find_by_id(id)
69
75
  end
70
76
 
71
77
  # Permanently delete dead-lettered tasks.
@@ -421,25 +427,76 @@ class Rage::Deferred::Backends::Disk
421
427
  end
422
428
  end
423
429
 
424
- # Return dead task records, newest first.
425
- # @param limit [Integer, nil] maximum number of records to return, or all records if nil
426
- # @param offset [Integer] number of newest records to skip
427
- # @return [Array<Hash>] task records that could be read successfully
430
+ # Yield each selected dead-task record from oldest to newest.
431
+ #
432
+ # The method locks the store and opens the current file.
433
+ # It sets a read boundary after the last complete record.
434
+ # It then releases the lock.
435
+ # A record that Rage adds after this step is not part of the traversal.
436
+ #
437
+ # The method scans each complete record before it yields the first record.
438
+ # For each task ID, it selects the newest record that has a valid frame.
439
+ #
440
+ # The method decodes one selected record immediately before it yields that record.
441
+ # It does not decode the next record while the caller processes the current record.
442
+ # Each call uses independent traversal state.
443
+ #
444
+ # @yieldparam record [Hash] a decoded dead-task record
445
+ # @return [self]
428
446
  # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
429
- def list(limit: nil, offset: 0)
430
- records = read_records.reverse
431
- records = records.drop(offset) if offset > 0
432
- records = records.first(limit) if limit
447
+ # @raise [SystemCallError] when an operation on the selected storage file fails
448
+ def each
449
+ with_snapshot do |storage, snapshot_end|
450
+ records_index(storage, snapshot_end).each do |task_id, (offset, length)|
451
+ storage.seek(offset, IO::SEEK_SET)
452
+
453
+ begin
454
+ record = Marshal.load(storage.read(length).undump)
455
+ rescue ArgumentError, NameError, TypeError => e
456
+ puts "ERROR: Can't deserialize the dead-lettered task with id #{task_id}: (#{e.class}) #{e.message}"
457
+ next
458
+ end
459
+
460
+ # yield outside the `rescue`, so that an error raised by the caller's block is not swallowed
461
+ yield record.merge(id: task_id)
462
+ end
433
463
 
434
- records
464
+ self
465
+ end
435
466
  end
436
467
 
437
- # Find a dead task by its id.
438
- # @param id [String] persisted task id
439
- # @return [Hash, nil] the task record, or nil when no record matches
468
+ # Return the newest record with the exact task ID.
469
+ #
470
+ # Scan all complete records in the fixed snapshot.
471
+ # Do not decode a candidate record during the scan.
472
+ # After the scan, decode only the newest matching record.
473
+ # @param id [String] the persisted task ID
474
+ # @return [Hash, nil] the decoded record, or nil if there is no matching record
440
475
  # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
441
- def find(id)
442
- read_records.find { |record| record[:id] == id }
476
+ # @raise [SystemCallError] when an operation on the selected storage file fails
477
+ def find_by_id(id)
478
+ with_snapshot do |storage, snapshot_end|
479
+ serialized_record_location = nil
480
+ each_framed_record(storage, snapshot_end) do |task_id, offset, length|
481
+ next unless task_id == id
482
+
483
+ serialized_record_location = [offset, length]
484
+ end
485
+
486
+ next unless serialized_record_location
487
+
488
+ offset, length = serialized_record_location
489
+ storage.seek(offset, IO::SEEK_SET)
490
+
491
+ begin
492
+ record = Marshal.load(storage.read(length).undump)
493
+ rescue ArgumentError, NameError, TypeError => e
494
+ puts "ERROR: Can't deserialize the dead-lettered task with id #{id}: (#{e.class}) #{e.message}"
495
+ next
496
+ end
497
+
498
+ record.merge(id: id)
499
+ end
443
500
  end
444
501
 
445
502
  # Permanently delete the records with the given ids.
@@ -459,7 +516,7 @@ class Rage::Deferred::Backends::Disk
459
516
  File.open(@tmp_storage_path, File::WRONLY | File::CREAT | File::TRUNC | File::BINARY, 0o644) do |tmp|
460
517
  File.open(@storage_path, File::RDONLY | File::BINARY) do |storage|
461
518
  storage.each_line do |entry|
462
- id = entry_id(entry)
519
+ id, = framed_record(entry.chomp)
463
520
 
464
521
  if id.nil?
465
522
  next # drop corrupted records; they can neither be listed nor deleted otherwise
@@ -489,95 +546,153 @@ class Rage::Deferred::Backends::Disk
489
546
 
490
547
  private
491
548
 
492
- # Persist changes to the live file's directory entry, including file creation and replacement.
493
- # @return [void]
494
- def sync_storage_directory
495
- File.open(@storage_path.dirname, File::RDONLY, &:fsync)
549
+ # Index the newest valid payload location for each task ID.
550
+ # If the index contains the duplicate task ID, the method removes the ID before it adds the newer record.
551
+ # This operation moves the ID to the position of its newest record.
552
+ # Hash insertion order then gives the required order from oldest to newest.
553
+ # @param storage [File] the descriptor for the fixed snapshot
554
+ # @param snapshot_end [Integer] the byte boundary after its last complete record
555
+ # @return [Hash{String => Array<(Integer, Integer)>}] ordered serialized-payload locations
556
+ def records_index(storage, snapshot_end)
557
+ records_index = {}
558
+ each_framed_record(storage, snapshot_end) do |task_id, offset, length|
559
+ records_index.delete(task_id)
560
+ records_index[task_id] = [offset, length].freeze
561
+ end
562
+
563
+ records_index
496
564
  end
497
565
 
498
- # Remove an incomplete final entry left by an interrupted append.
499
- # @param storage [File] store opened for reading and writing
566
+ # Scan each complete record up to the fixed snapshot boundary.
567
+ # Silently skip a malformed frame or an invalid CRC.
568
+ # Do not decode or inspect a serialized payload.
569
+ # @param storage [File] the descriptor for the fixed snapshot
570
+ # @param snapshot_end [Integer] the byte boundary after its last complete record
571
+ # @yieldparam task_id [String] the task ID from the storage frame
572
+ # @yieldparam offset [Integer] the serialized payload's byte offset
573
+ # @yieldparam length [Integer] the serialized payload's byte length
500
574
  # @return [void]
501
- def repair_torn_tail(storage)
575
+ # @private
576
+ def each_framed_record(storage, snapshot_end)
577
+ position = 0
578
+ storage.seek(0, IO::SEEK_SET)
579
+
580
+ while position < snapshot_end
581
+ offset = position
582
+ entry = storage.gets
583
+ break unless entry
584
+
585
+ position += entry.bytesize
586
+ length = entry.bytesize - 1
587
+ entry = entry.byteslice(0, length)
588
+ task_id, serialized_record = framed_record(entry)
589
+ next unless task_id
590
+
591
+ serialized_record_offset = offset + length - serialized_record.bytesize
592
+ yield task_id, serialized_record_offset, serialized_record.bytesize
593
+ end
594
+ end
595
+
596
+ # Lock the store, open the live file, and find the last complete record.
597
+ # Release the lock before the block scans, decodes, or yields a record.
598
+ # Keep the file descriptor open while the block runs.
599
+ # The descriptor continues to identify the same file if another operation replaces the live path.
600
+ # Close the descriptor when the block exits.
601
+ # If the block and `File#close` both raise exceptions, the method re-raises the block's exception.
602
+ # @yieldparam storage [File] the descriptor for the fixed snapshot
603
+ # @yieldparam snapshot_end [Integer] the fixed end position of the last complete record
604
+ # @yieldreturn [Object] the result from the read operation
605
+ # @return [Object] the block result
606
+ def with_snapshot
607
+ storage = nil
608
+ active_exception = nil
609
+
610
+ begin
611
+ snapshot_end = with_lock("read tasks from") do
612
+ storage = File.open(@storage_path, File::RDONLY | File::BINARY)
613
+ complete_record_end(storage)
614
+ end
615
+
616
+ yield storage, snapshot_end
617
+ rescue Exception => e
618
+ active_exception = e
619
+ raise
620
+ ensure
621
+ begin
622
+ storage.close if storage && !storage.closed?
623
+ rescue
624
+ raise unless active_exception
625
+ end
626
+ end
627
+ end
628
+
629
+ # Return the byte position after the last complete record.
630
+ # A complete record ends with a newline.
631
+ # If the file has an incomplete tail, scan backwards in fixed-size chunks.
632
+ # Do not parse a record during this scan.
633
+ # Do not change the file.
634
+ # Return zero if the file has no complete record.
635
+ # @param storage [File] an open dead-task file
636
+ # @return [Integer] the byte boundary after the last complete record
637
+ def complete_record_end(storage)
502
638
  storage.seek(0, IO::SEEK_END)
503
639
  end_position = storage.pos
504
- return if end_position == 0
640
+ return 0 if end_position == 0
505
641
 
506
642
  storage.seek(-1, IO::SEEK_END)
507
- return if storage.read(1) == "\n"
643
+ return end_position if storage.read(1) == "\n"
508
644
 
509
645
  position = end_position
510
- truncate_at = 0
511
-
512
646
  while position > 0
513
647
  chunk_start = [position - TAIL_SCAN_CHUNK_SIZE, 0].max
514
648
  storage.seek(chunk_start, IO::SEEK_SET)
515
649
  chunk = storage.read(position - chunk_start)
516
650
 
517
651
  if (newline_index = chunk.rindex("\n"))
518
- truncate_at = chunk_start + newline_index + 1
519
- break
652
+ return chunk_start + newline_index + 1
520
653
  end
521
654
 
522
655
  position = chunk_start
523
656
  end
524
657
 
525
- storage.truncate(truncate_at)
658
+ 0
526
659
  end
527
660
 
528
- # Read valid records, keeping only the latest entry for each task id.
529
- # Corrupted or unreadable records are reported and skipped.
530
- # @return [Array<Hash>]
531
- # @raise [Rage::Deferred::DeadTasksLockTimeout] if the store cannot be locked
532
- def read_records
533
- entries = with_lock("read tasks from") do
534
- result, corrupted_count = {}, 0
535
-
536
- File.open(@storage_path, File::RDONLY | File::BINARY) do |storage|
537
- storage.each_line(chomp: true) do |entry|
538
- id = entry_id(entry)
539
-
540
- if id.nil?
541
- corrupted_count += 1
542
- next
543
- end
544
-
545
- # the same task can be dead-lettered more than once if the worker crashed
546
- # before the task was removed from the write-ahead log
547
- result.delete(id)
548
- result[id] = entry
549
- end
550
- end
551
-
552
- if corrupted_count != 0
553
- puts "WARNING: Detected #{corrupted_count} corrupted dead-lettered task(s)"
554
- end
661
+ # Validate the storage frame.
662
+ # Return its task ID and serialized payload.
663
+ # Return `nil` for a malformed frame, invalid CRC, unsupported operation, or empty task ID.
664
+ # Do not decode the payload.
665
+ # @param entry [String] one complete physical record without the newline
666
+ # @return [Array<(String, String)>, nil]
667
+ def framed_record(entry)
668
+ return unless entry.bytesize > ENTRY_CRC_HEX_WIDTH + 1
669
+ return unless entry.getbyte(ENTRY_CRC_HEX_WIDTH) == 58 # `:`
670
+
671
+ signature = entry.byteslice(0, ENTRY_CRC_HEX_WIDTH)
672
+ payload = entry.byteslice((ENTRY_CRC_HEX_WIDTH + 1)..)
673
+ operation_prefix = "#{ENTRY_OP}:"
674
+ return unless payload.start_with?(operation_prefix)
675
+ return unless signature == Zlib.crc32(payload).to_s(16).rjust(ENTRY_CRC_HEX_WIDTH, "0")
555
676
 
556
- result
557
- end
677
+ id_start = operation_prefix.bytesize
678
+ separator_index = payload.index(":", id_start)
679
+ return unless separator_index && separator_index > id_start
558
680
 
559
- entries.filter_map do |id, entry|
560
- _, _, _, serialized_record = entry.split(":", 4)
561
- Marshal.load(serialized_record.undump)
562
- rescue ArgumentError, NameError, TypeError => e
563
- puts "ERROR: Can't deserialize the dead-lettered task with id #{id}: (#{e.class}) #{e.message}"
564
- nil
565
- end
681
+ [payload.byteslice(id_start...separator_index), payload.byteslice((separator_index + 1)..)]
566
682
  end
567
683
 
568
- # Validate a stored entry and extract its task id.
569
- # @param entry [String] serialized entry, optionally ending with a newline
570
- # @return [String, nil] task id, or nil if the entry is malformed or corrupted
571
- def entry_id(entry)
572
- entry = entry.chomp
573
-
574
- signature, payload = entry[0...ENTRY_CRC_HEX_WIDTH], entry[(ENTRY_CRC_HEX_WIDTH + 1)..]
575
- return if signature.nil? || payload.nil? || !payload.start_with?("#{ENTRY_OP}:")
576
- return unless signature == Zlib.crc32(payload).to_s(16).rjust(ENTRY_CRC_HEX_WIDTH, "0")
684
+ # Call `fsync` on the storage directory after file creation or replacement.
685
+ # @return [void]
686
+ def sync_storage_directory
687
+ File.open(@storage_path.dirname, File::RDONLY, &:fsync)
688
+ end
577
689
 
578
- id_start = ENTRY_CRC_HEX_WIDTH + 1 + ENTRY_OP.length + 1
579
- separator_index = entry.index(":", id_start)
580
- entry[id_start...separator_index] if separator_index
690
+ # Remove bytes after the last complete record. An interrupted append can leave these bytes.
691
+ # @param storage [File] the store opened for reading and writing
692
+ # @return [void]
693
+ def repair_torn_tail(storage)
694
+ truncate_at = complete_record_end(storage)
695
+ storage.truncate(truncate_at) if truncate_at < storage.size
581
696
  end
582
697
 
583
698
  # Serialize a task record as a checksummed, newline-delimited entry.
@@ -611,12 +726,21 @@ class Rage::Deferred::Backends::Disk
611
726
  end
612
727
 
613
728
  @locked = true
729
+ active_exception = nil
614
730
 
615
731
  begin
616
732
  yield
733
+ rescue Exception => e
734
+ active_exception = e
735
+ raise
617
736
  ensure
618
- @lock_file.flock(File::LOCK_UN)
619
- @locked = false
737
+ begin
738
+ @lock_file.flock(File::LOCK_UN)
739
+ rescue
740
+ raise unless active_exception
741
+ ensure
742
+ @locked = false
743
+ end
620
744
  end
621
745
  end
622
746
  end
@@ -17,8 +17,8 @@ class Rage::Deferred::Backends::Nil
17
17
  def add_dead_task(_, _, _, **)
18
18
  end
19
19
 
20
- def list_dead_tasks(**)
21
- []
20
+ def each_dead_task
21
+ self
22
22
  end
23
23
 
24
24
  def find_dead_task(_)
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Provides information about one dead task.
5
+ #
6
+ # A dead task is a deferred task that is no longer retried.
7
+ # Creating this object does not load the task class.
8
+ # The first call to {#args} or {#kwargs} deserializes the execution context.
9
+ class Rage::Deferred::DeadTask
10
+ # @return [String] the ID that Rage assigned when it enqueued the task
11
+ attr_reader :id
12
+
13
+ # @return [String] the name of the task class
14
+ attr_reader :task_class
15
+
16
+ # @return [Integer] the number of processing attempts
17
+ attr_reader :attempts
18
+
19
+ # @return [Time] the time when Rage enqueued the task
20
+ attr_reader :enqueued_at
21
+
22
+ # @return [Time] the time when the last attempt failed
23
+ attr_reader :failed_at
24
+
25
+ # @return [String] the class name of the last exception
26
+ attr_reader :exception_class
27
+
28
+ # @return [String] the message from the last exception
29
+ attr_reader :exception_message
30
+
31
+ # @return [Array<String>] the frozen backtrace from the last exception
32
+ attr_reader :backtrace
33
+
34
+ # @private
35
+ def initialize(record)
36
+ @id = record[:id].dup.freeze
37
+ @task_class = record[:task_class].dup.freeze
38
+ @attempts = record[:attempts]
39
+ @enqueued_at = Time.at(record[:enqueued_at])
40
+ @failed_at = Time.at(record[:failed_at])
41
+
42
+ @exception_class = record[:exception_class].dup.freeze
43
+ @exception_message = record[:exception_message].dup.freeze
44
+ @backtrace = record[:backtrace].map { |line| line.dup.freeze }.freeze
45
+ @__context = record[:context].dup.freeze
46
+ end
47
+
48
+ # Return the original positional arguments.
49
+ #
50
+ # On the first call, the method deserializes the execution context.
51
+ # The method freezes all objects in the decoded data.
52
+ # After a successful call, the method returns the same Array on each later call.
53
+ # If the stored value is `nil`, the method returns a frozen empty Array.
54
+ #
55
+ # @return [Array] frozen positional arguments
56
+ # @raise [Rage::Deferred::DeadTaskContextDeserializationError] when the context cannot be decoded
57
+ def args
58
+ decode_context unless defined?(@__args)
59
+ @__args
60
+ end
61
+
62
+ # Return the original keyword arguments.
63
+ #
64
+ # On the first call, the method deserializes the execution context.
65
+ # The method freezes all objects in the decoded data.
66
+ # After a successful call, the method returns the same Hash on each later call.
67
+ # If the stored value is `nil`, the method returns a frozen empty Hash.
68
+ #
69
+ # @return [Hash] frozen keyword arguments
70
+ # @raise [Rage::Deferred::DeadTaskContextDeserializationError] when the context cannot be decoded
71
+ def kwargs
72
+ decode_context unless defined?(@__kwargs)
73
+ @__kwargs
74
+ end
75
+
76
+ private
77
+
78
+ # @private
79
+ def decode_context
80
+ context = Marshal.load(@__context, freeze: true)
81
+ args = Rage::Deferred::Context.get_args(context)
82
+ kwargs = Rage::Deferred::Context.get_kwargs(context)
83
+ args = [].freeze if args.nil?
84
+ kwargs = {}.freeze if kwargs.nil?
85
+
86
+ @__args = args
87
+ @__kwargs = kwargs
88
+ rescue => e
89
+ error = Rage::Deferred::DeadTaskContextDeserializationError.new(
90
+ "Could not deserialize execution context for dead task #{@id}"
91
+ )
92
+ raise error, cause: e
93
+ end
94
+
95
+ private_class_method :new
96
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Provides access to deferred tasks that Rage no longer retries.
5
+ #
6
+ # {Rage::Deferred.dead_tasks} returns this collection.
7
+ class Rage::Deferred::DeadTasks
8
+ include Enumerable
9
+
10
+ # @private
11
+ def initialize(backend)
12
+ @backend = backend
13
+ end
14
+
15
+ # Yield each dead task from oldest to newest.
16
+ #
17
+ # Without a block, the method returns a new Enumerator.
18
+ #
19
+ # @yieldparam dead_task [Rage::Deferred::DeadTask] information about one dead task
20
+ # @return [Enumerator, self] a new Enumerator without a block, or this collection with a block
21
+ def each
22
+ return enum_for(__method__) unless block_given?
23
+
24
+ @backend.each_dead_task do |record|
25
+ yield Rage::Deferred::DeadTask.send(:new, record)
26
+ end
27
+
28
+ self
29
+ end
30
+
31
+ # Return the dead task with the exact persisted ID.
32
+ #
33
+ # The ID must be a String. The method does not convert other values to a String.
34
+ #
35
+ # @param id [String] the exact persisted task ID
36
+ # @return [Rage::Deferred::DeadTask, nil] the matching task, or nil
37
+ # @raise [TypeError] when the ID is not a String
38
+ def find_by_id(id)
39
+ raise TypeError, "dead task id must be a String" unless id.is_a?(String)
40
+
41
+ record = @backend.find_dead_task(id)
42
+ Rage::Deferred::DeadTask.send(:new, record) if record
43
+ end
44
+ end
@@ -51,6 +51,24 @@ module Rage::Deferred
51
51
  Rage::Deferred::Proxy.new(instance, delay:, delay_until:)
52
52
  end
53
53
 
54
+ # Return the collection of tasks that Rage no longer retries.
55
+ #
56
+ # The method returns the same collection object on each call.
57
+ # The collection uses the same backend object as the deferred queue.
58
+ #
59
+ # @example List dead tasks
60
+ # Rage::Deferred.dead_tasks.each do |task|
61
+ # puts "#{task.id}: #{task.exception_message}"
62
+ # end
63
+ #
64
+ # @example Find one dead task
65
+ # task = Rage::Deferred.dead_tasks.find_by_id("1735689600-12345-1")
66
+ #
67
+ # @return [Rage::Deferred::DeadTasks]
68
+ def self.dead_tasks
69
+ @__dead_tasks ||= Rage::Deferred::DeadTasks.new(__backend)
70
+ end
71
+
54
72
  # @private
55
73
  def self.__backend
56
74
  @__backend ||= Rage.config.deferred.backend
@@ -100,11 +118,20 @@ module Rage::Deferred
100
118
  class PushTimeout < StandardError
101
119
  end
102
120
 
103
- # Raised when a dead-tasks store operation cannot acquire the lock within the retry budget.
121
+ # Raise this error when the dead-task store cannot be locked after all retries.
104
122
  class DeadTasksLockTimeout < StandardError
105
123
  end
124
+
125
+ # Raise this error when a dead task's execution context cannot be deserialized.
126
+ #
127
+ # The error message identifies the dead task.
128
+ # `Exception#cause` contains the original deserialization error.
129
+ class DeadTaskContextDeserializationError < StandardError
130
+ end
106
131
  end
107
132
 
133
+ require_relative "dead_task"
134
+ require_relative "dead_tasks"
108
135
  require_relative "task"
109
136
  require_relative "scheduler"
110
137
  require_relative "queue"
@@ -16,6 +16,10 @@ class Rage::Extension
16
16
  Iodine.on_state(:pre_start, &block)
17
17
  end
18
18
 
19
+ def routes(&block)
20
+ __routes << block
21
+ end
22
+
19
23
  def __initializers
20
24
  @@initializers ||= Hash.new { |h, k| h[k] = [] }
21
25
  end
@@ -23,5 +27,9 @@ class Rage::Extension
23
27
  def __configurations
24
28
  @@configurations ||= []
25
29
  end
30
+
31
+ def __routes
32
+ @@routes ||= []
33
+ end
26
34
  end
27
35
  end
data/lib/rage/fiber.rb CHANGED
@@ -111,7 +111,7 @@ class Fiber
111
111
  end
112
112
 
113
113
  # @private
114
- attr_accessor :__awaited_fileno, :__wait_generation, :__block_channel, :__await_channel
114
+ attr_accessor :__awaited_fileno, :__wait_generation, :__await_channel
115
115
 
116
116
  # @private
117
117
  # pause a fiber and resume in the next iteration of the event loop