lazy_data 0.0.0 → 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.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Portions copyright 2023 Google LLC
4
+ #
5
+ # This code has been modified from the original Google code. The modified
6
+ # portions copyright 2026 Daniel Azuma
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # https://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+
20
+ # Main documentation is in lib/lazy_data.rb
21
+ module LazyData
22
+ class << self
23
+ ##
24
+ # Creates a special object that can be returned from a computation to
25
+ # indicate that a value expires after the given number of seconds. Any
26
+ # access after the expiration will cause a recomputation.
27
+ #
28
+ # @param lifetime [Numeric,nil] timeout in seconds, or nil to explicitly
29
+ # disable expiration
30
+ # @param value [Object] the computation result
31
+ #
32
+ def expiring_value(lifetime, value)
33
+ return value unless lifetime
34
+ ExpiringValue.new(lifetime, value)
35
+ end
36
+
37
+ ##
38
+ # Raise an error that, if it is the final result (i.e. retries have been
39
+ # exhausted), will expire after the given number of seconds. Any access
40
+ # after the expiration will cause a recomputation. If retries will not have
41
+ # been exhausted, expiration is ignored.
42
+ #
43
+ # The error can be specified as an exception object, a string (in which
44
+ # case a RuntimeError will be raised), or a class that descends from
45
+ # Exception (in which case an error of that type will be created, and
46
+ # passed any additional args given).
47
+ #
48
+ # @param lifetime [Numeric,nil] timeout in seconds, or nil to explicitly
49
+ # disable expiration
50
+ # @param error [String,Exception,Class] the error to raise
51
+ # @param args [Array] any arguments to pass to an error constructor
52
+ #
53
+ def raise_expiring_error(lifetime, error, *args)
54
+ raise error unless lifetime
55
+ raise ExpiringError, lifetime if error.equal?($!)
56
+ if error.is_a?(::Class) && error.ancestors.include?(::Exception)
57
+ error = error.new(*args)
58
+ elsif !error.is_a?(::Exception)
59
+ error = ::RuntimeError.new(error.to_s)
60
+ end
61
+ begin
62
+ raise error
63
+ rescue error.class
64
+ raise ExpiringError, lifetime
65
+ end
66
+ end
67
+ end
68
+
69
+ ##
70
+ # @private
71
+ # Internal type signaling a value with an expiration
72
+ #
73
+ class ExpiringValue
74
+ def initialize(lifetime, value)
75
+ @lifetime = lifetime
76
+ @value = value
77
+ end
78
+
79
+ attr_reader :lifetime
80
+ attr_reader :value
81
+ end
82
+
83
+ ##
84
+ # @private
85
+ # Internal type signaling an error with an expiration.
86
+ #
87
+ class ExpiringError < StandardError
88
+ def initialize(lifetime)
89
+ super()
90
+ @lifetime = lifetime
91
+ end
92
+
93
+ attr_reader :lifetime
94
+ end
95
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Daniel Azuma
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module LazyData
18
+ ##
19
+ # A representation of the internal state of a {LazyData::Value}.
20
+ #
21
+ class InternalState
22
+ ##
23
+ # The FAILED state indicates that computation has failed finally and no
24
+ # more retries will be done. If the state is FAILED, and error will be
25
+ # present and the value will be nil. The expires_at time will be a
26
+ # CLOCK_MONOTONIC timestamp if the failure can expire, or nil if not.
27
+ #
28
+ FAILED = :failed
29
+
30
+ ##
31
+ # The SUCCESS state indicates that computation has completed successfully,
32
+ # and the value is set. The error will be nil. The expires_at time will be
33
+ # a CLOCK_MONOTONIC timestamp if the value can expire, or nil if not.
34
+ #
35
+ SUCCESS = :success
36
+
37
+ ##
38
+ # The PENDING state indicates the value has not been computed, or that
39
+ # previous computation attempts have failed but there are retries pending.
40
+ # The error will be set to the most recent error, or nil if no computation
41
+ # attempt has yet been started. The value will be nil. The expires_at time
42
+ # will be the CLOCK_MONOTONIC timestamp when the current retry delay will
43
+ # end (or has ended, so it could be in the past), or nil if there are no
44
+ # retry delays.
45
+ #
46
+ PENDING = :pending
47
+
48
+ ##
49
+ # The COMPUTING state indicates that a thread is currently computing the
50
+ # value. The error and value will both be nil. The expires_at time will be
51
+ # the CLOCK_MONOTONIC timestamp when the computation had started.
52
+ #
53
+ COMPUTING = :computing
54
+
55
+ ##
56
+ # The general state of the value. Will be {PENDING}, {COMPUTING},
57
+ # {SUCCESS}, or {FAILED}.
58
+ #
59
+ # @return [Symbol]
60
+ #
61
+ attr_reader :state
62
+
63
+ ##
64
+ # The current computed value, if the state is {SUCCESS}, or nil for any
65
+ # other state
66
+ #
67
+ # @return [Object]
68
+ #
69
+ attr_reader :value
70
+
71
+ ##
72
+ # The last computation error, if the state is {FAILED} or {PENDING},
73
+ # otherwise nil.
74
+ #
75
+ # @return [Exception,nil]
76
+ #
77
+ attr_reader :error
78
+
79
+ ##
80
+ # The CLOCK_MONOTONIC timestamp of expiration, or the start of the current
81
+ # computation when in {COMPUTING} state.
82
+ #
83
+ # @return [Numeric,nil]
84
+ #
85
+ attr_reader :expires_at
86
+
87
+ ##
88
+ # Query whether the state is failure
89
+ #
90
+ # @return [boolean]
91
+ #
92
+ def failed?
93
+ state == FAILED
94
+ end
95
+
96
+ ##
97
+ # Query whether the state is success
98
+ #
99
+ # @return [boolean]
100
+ #
101
+ def success?
102
+ state == SUCCESS
103
+ end
104
+
105
+ ##
106
+ # Query whether the state is pending
107
+ #
108
+ # @return [boolean]
109
+ #
110
+ def pending?
111
+ state == PENDING
112
+ end
113
+
114
+ ##
115
+ # Query whether the state is computing
116
+ #
117
+ # @return [boolean]
118
+ #
119
+ def computing?
120
+ state == COMPUTING
121
+ end
122
+
123
+ ##
124
+ # @private
125
+ #
126
+ def initialize(state, value, error, expires_at)
127
+ @state = state
128
+ @value = value
129
+ @error = error
130
+ @expires_at = expires_at
131
+ freeze
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Portions copyright 2023 Google LLC
4
+ #
5
+ # This code has been modified from the original Google code. The modified
6
+ # portions copyright 2026 Daniel Azuma
7
+ #
8
+ # Licensed under the Apache License, Version 2.0 (the "License");
9
+ # you may not use this file except in compliance with the License.
10
+ # You may obtain a copy of the License at
11
+ #
12
+ # https://www.apache.org/licenses/LICENSE-2.0
13
+ #
14
+ # Unless required by applicable law or agreed to in writing, software
15
+ # distributed under the License is distributed on an "AS IS" BASIS,
16
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ # See the License for the specific language governing permissions and
18
+ # limitations under the License.
19
+
20
+ module LazyData
21
+ ##
22
+ # A simple retry manager with optional delay and backoff. It retries until
23
+ # either a configured maximum number of attempts has been reached, or a
24
+ # configurable total time has elapsed since the first failure.
25
+ #
26
+ # This class is not thread-safe by itself. Access should be protected by an
27
+ # external mutex.
28
+ #
29
+ class Retries
30
+ ##
31
+ # Create and initialize a retry manager.
32
+ #
33
+ # @param max_tries [Integer,nil] Maximum number of attempts before we give
34
+ # up altogether, or nil for no maximum. Default is 1, indicating one
35
+ # attempt and no retries.
36
+ # @param max_time [Numeric,nil] The maximum amount of time in seconds until
37
+ # we give up altogether, or nil for no maximum. Default is nil.
38
+ # @param initial_delay [Numeric] Initial delay between attempts, in
39
+ # seconds. Default is 0.
40
+ # @param max_delay [Numeric,nil] Maximum delay between attempts, in
41
+ # seconds, or nil for no max. Default is nil.
42
+ # @param delay_multiplier [Numeric] Multipler applied to the delay between
43
+ # attempts. Default is 1 for no change.
44
+ # @param delay_adder [Numeric] Value added to the delay between attempts.
45
+ # Default is 0 for no change.
46
+ # @param delay_includes_time_elapsed [true,false] Whether to deduct any
47
+ # time already elapsed from the retry delay. Default is false.
48
+ #
49
+ def initialize(max_tries: 1,
50
+ max_time: nil,
51
+ initial_delay: 0,
52
+ max_delay: nil,
53
+ delay_multiplier: 1,
54
+ delay_adder: 0,
55
+ delay_includes_time_elapsed: false)
56
+ @max_tries = max_tries&.to_i
57
+ raise ::ArgumentError, "max_tries must be positive" if @max_tries && !@max_tries.positive?
58
+ @max_time = max_time
59
+ raise ::ArgumentError, "max_time must be positive" if @max_time && !@max_time.positive?
60
+ @initial_delay = initial_delay
61
+ raise ::ArgumentError, "initial_delay must be nonnegative" if @initial_delay&.negative?
62
+ @max_delay = max_delay
63
+ raise ::ArgumentError, "max_delay must be nonnegative" if @max_delay&.negative?
64
+ @delay_multiplier = delay_multiplier
65
+ @delay_adder = delay_adder
66
+ @delay_includes_time_elapsed = delay_includes_time_elapsed
67
+ reset!
68
+ end
69
+
70
+ ##
71
+ # Create a duplicate in the reset state
72
+ #
73
+ # @return [Retries]
74
+ #
75
+ def reset_dup
76
+ Retries.new(max_tries: @max_tries,
77
+ max_time: @max_time,
78
+ initial_delay: @initial_delay,
79
+ max_delay: @max_delay,
80
+ delay_multiplier: @delay_multiplier,
81
+ delay_adder: @delay_adder,
82
+ delay_includes_time_elapsed: @delay_includes_time_elapsed)
83
+ end
84
+
85
+ ##
86
+ # Returns true if the retry limit has been reached.
87
+ #
88
+ # @return [true,false]
89
+ #
90
+ def finished?
91
+ @current_delay.nil?
92
+ end
93
+
94
+ ##
95
+ # Reset to the initial attempt.
96
+ #
97
+ # @return [self]
98
+ #
99
+ def reset!
100
+ @current_delay = :reset
101
+ self
102
+ end
103
+
104
+ ##
105
+ # Cause the retry limit to be reached immediately.
106
+ #
107
+ # @return [self]
108
+ #
109
+ def finish!
110
+ @current_delay = nil
111
+ self
112
+ end
113
+
114
+ ##
115
+ # Advance to the next attempt.
116
+ #
117
+ # Returns nil if the retry limit has been reached. Otherwise, returns the
118
+ # delay in seconds until the next retry (0 for no delay). Raises an error
119
+ # if the previous call already returned nil.
120
+ #
121
+ # @param start_time [Numeric,nil] Optional start time in monotonic time
122
+ # units. Used if delay_includes_time_elapsed is set.
123
+ # @return [Numeric,nil]
124
+ #
125
+ def next(start_time: nil)
126
+ raise "no tries remaining" if finished?
127
+ cur_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
128
+ if @current_delay == :reset
129
+ setup_first_retry(cur_time)
130
+ else
131
+ advance_delay
132
+ end
133
+ advance_retry(cur_time)
134
+ adjusted_delay(start_time, cur_time)
135
+ end
136
+
137
+ private
138
+
139
+ def setup_first_retry(cur_time)
140
+ @tries_remaining = @max_tries
141
+ @deadline = @max_time ? cur_time + @max_time : nil
142
+ @current_delay = @initial_delay
143
+ end
144
+
145
+ def advance_delay
146
+ @current_delay = (@delay_multiplier * @current_delay) + @delay_adder
147
+ @current_delay = @max_delay if @max_delay && @current_delay > @max_delay
148
+ end
149
+
150
+ def advance_retry(cur_time)
151
+ @tries_remaining -= 1 if @tries_remaining
152
+ @current_delay = nil if @tries_remaining&.zero? || (@deadline && cur_time + @current_delay > @deadline)
153
+ end
154
+
155
+ def adjusted_delay(start_time, cur_time)
156
+ delay = @current_delay
157
+ if @delay_includes_time_elapsed && start_time && delay
158
+ delay -= cur_time - start_time
159
+ delay = 0 if delay.negative?
160
+ end
161
+ delay
162
+ end
163
+ end
164
+ end