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.
- checksums.yaml +4 -4
- data/.yardopts +11 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.md +201 -0
- data/README.md +179 -7
- data/lib/lazy_data/dict.rb +194 -0
- data/lib/lazy_data/expiry.rb +95 -0
- data/lib/lazy_data/internal_state.rb +134 -0
- data/lib/lazy_data/retries.rb +164 -0
- data/lib/lazy_data/value.rb +561 -0
- data/lib/lazy_data/version.rb +23 -0
- data/lib/lazy_data.rb +96 -6
- metadata +33 -11
|
@@ -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
|