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,561 @@
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 lazy value with thread-safe memoization. The first time accessed it will
23
+ # call a given block to compute its value, and will cache that value.
24
+ # Subsequent requests will return the cached value.
25
+ #
26
+ # At most one thread will be allowed to run the computation; if another
27
+ # thread is already in the middle of a computation, any new threads
28
+ # requesting the value will wait until the existing computation is complete,
29
+ # and will use that computation's result rather than kicking off their own
30
+ # computation.
31
+ #
32
+ # If a computation fails with an exception, that exception will also be
33
+ # memoized and reraised on subsequent accesses. A LazyData::Value can also be
34
+ # configured so subsequent accesses will retry the computation if the
35
+ # previous computation failed. The maximum number of retries is
36
+ # configurable, as is the retry "interval", i.e. the time since the last
37
+ # failure before an access will retry the computation.
38
+ #
39
+ # By default, a computation's memoized value (or final error after retries
40
+ # have been exhausted) is maintained for the lifetime of the Ruby process.
41
+ # However, a computation can also cause its result (or error) to expire after
42
+ # a specified number of seconds, forcing a recomputation on the next access
43
+ # following expiration, by calling {LazyData.expiring_value} or
44
+ # {LazyData.raise_expiring_error}.
45
+ #
46
+ class Value
47
+ ##
48
+ # Create a LazyData::Value.
49
+ #
50
+ # You must pass a block that will be called to compute the value the first
51
+ # time it is accessed. The block should evaluate to the desired value, or
52
+ # raise an exception on error. To specify a value that expires, use
53
+ # {LazyData.expiring_value}. To raise an exception that expires, use
54
+ # {LazyData.raise_expiring_error}.
55
+ #
56
+ # You can optionally pass a retry manager, which controls how subsequent
57
+ # accesses might try calling the block again if a compute attempt fails
58
+ # with an exception. A retry manager should either be an instance of
59
+ # {LazyData::Retries} or an object that duck types it.
60
+ #
61
+ # @param retries [LazyData::Retries] A retry manager. The default is a
62
+ # retry manager that tries only once.
63
+ # @param lifetime [Numeric,nil] The default lifetime of a computed value.
64
+ # Optional. No expiration by default if not provided. This can be
65
+ # overridden in the block by returning {LazyData.expiring_value} or
66
+ # calling {LazyData.raise_expiring_error} explicitly.
67
+ # @param block [Proc] A block that can be called to attempt to compute
68
+ # the value.
69
+ #
70
+ def initialize(retries: nil, lifetime: nil, &block)
71
+ @retries = retries || Retries.new
72
+ @default_lifetime = lifetime
73
+ @compute_handler = block
74
+ raise ArgumentError, "missing compute handler block" unless block
75
+
76
+ # Internally implemented by a state machine, protected by a mutex that
77
+ # ensures state transitions are consistent. The states themselves are
78
+ # implicit in the values of the various instance variables. The
79
+ # following are the major states:
80
+ #
81
+ # 1. **Pending** The value is not known and needs to be computed.
82
+ # @retries.finished? is false.
83
+ # @value is nil.
84
+ # @error is nil if no previous attempt has yet been made to
85
+ # compute the value, or set to the error that resulted from
86
+ # the most recent attempt.
87
+ # @expires_at is set to the monotonic time of the end of the
88
+ # current retry delay, or nil if the next computation attempt
89
+ # should happen immediately at the next access.
90
+ # @computing_thread is nil.
91
+ # @compute_notify is nil.
92
+ # @backfill_notify is set if currently backfilling, otherwise nil.
93
+ # From this state, calling #get will start computation (first
94
+ # waiting on @backfill_notify if present). Calling #expire! will
95
+ # have no effect.
96
+ #
97
+ # 2. **Computing** One thread has initiated computation. All other
98
+ # threads will be blocked (waiting on @compute_notify) until the
99
+ # computing thread finishes.
100
+ # @retries.finished? is false.
101
+ # @value and @error are nil.
102
+ # @expires_at is set to the monotonic time when computing started.
103
+ # @computing_thread is set to the thread that is computing.
104
+ # @compute_notify is set.
105
+ # @backfill_notify is nil.
106
+ # From this state, calling #get will cause the thread to wait
107
+ # (on @compute_notify) for the computing thread to complete.
108
+ # Calling #expire! will have no effect.
109
+ # When the computing thread finishes, it will transition either
110
+ # to Finished if the computation was successful or failed with
111
+ # no more retries, or back to Pending if computation failed with
112
+ # at least one retry remaining. It might also set @backfill_notify
113
+ # if other threads are waiting for completion.
114
+ #
115
+ # 3. **Finished** Computation has succeeded, or has failed and no
116
+ # more retries remain.
117
+ # @retries.finished? is true.
118
+ # either @value or @error is set, and the other is nil, depending
119
+ # on whether the final state is success or failure. (If both
120
+ # are nil, it is considered a @value of nil.)
121
+ # @expires_at is set to the monotonic time of expiration, or nil
122
+ # if there is no expiration.
123
+ # @computing_thread is nil.
124
+ # @compute_notify is nil.
125
+ # @backfill_notify is set if currently backfilling, otherwise nil.
126
+ # From this state, calling #get will either return the result or
127
+ # raise the error. If the current time exceeds @expires_at,
128
+ # however, it will block on @backfill_notify (if present), and
129
+ # and then transition to Pending first, and proceed from there.
130
+ # Calling #expire! will block on @backfill_notify (if present)
131
+ # and then transition to Pending,
132
+ #
133
+ # @backfill_notify can be set in the Pending or Finished states. This
134
+ # happens when threads that had been waiting on the previous
135
+ # computation are still clearing out and returning their results.
136
+ # Backfill must complete before the next computation attempt can be
137
+ # started from the Pending state, or before an expiration can take
138
+ # place from the Finished state. This prevents an "overlap" situation
139
+ # where a thread that had been waiting for a previous computation,
140
+ # isn't able to return the new result before some other thread starts
141
+ # a new computation or expires the value. Note that it is okay for
142
+ # #set! to be called during backfill; the threads still backfilling
143
+ # will simply return the new value.
144
+ #
145
+ # Note: One might ask if it would be simpler to extend the mutex
146
+ # across the entire computation, having it protect the computation
147
+ # itself, instead of the current approach of having explicit compute
148
+ # and backfill states with notifications and having the mutex protect
149
+ # only the state transition. However, this would not have been able
150
+ # to satisfy the requirement that we be able to detect whether a
151
+ # thread asked for the value during another thread's computation,
152
+ # and thus should "share" in that computation's result even if it's
153
+ # a failure (rather than kicking off a retry). Additionally, we
154
+ # consider it dangerous to have the computation block run inside a
155
+ # mutex, because arbitrary code can run there which might result in
156
+ # deadlocks.
157
+ @mutex = ::Thread::Mutex.new
158
+ # The evaluated, cached value, which could be nil.
159
+ @value = nil
160
+ # The last error encountered
161
+ @error = nil
162
+ # If non-nil, this is the CLOCK_MONOTONIC time when the current state
163
+ # expires. If the state is finished, this is the time the current
164
+ # value or error expires (while nil means it never expires). If the
165
+ # state is pending, this is the time the wait period before the next
166
+ # retry expires (and nil means there is no delay.) If the state is
167
+ # computing, this is the time when computing started.
168
+ @expires_at = nil
169
+ # Set to a condition variable during computation. Broadcasts when the
170
+ # computation is complete. Any threads wanting to get the value
171
+ # during computation must wait on this first.
172
+ @compute_notify = nil
173
+ # Set to a condition variable during backfill. Broadcasts when the
174
+ # last backfill thread is complete. Any threads wanting to expire the
175
+ # cache or start a new computation during backfill must wait on this
176
+ # first.
177
+ @backfill_notify = nil
178
+ # The number of threads waiting on backfill. Used to determine
179
+ # whether to activate backfill_notify when a computation completes.
180
+ @backfill_count = 0
181
+ # The thread running the current computation. This is tested against
182
+ # new requests to protect against deadlocks where a thread tries to
183
+ # re-enter from its own computation. This is also tested when a
184
+ # computation completes, to ensure that the computation is still
185
+ # relevant (i.e. if #set! interrupts a computation, this is reset to
186
+ # nil).
187
+ @computing_thread = nil
188
+ end
189
+
190
+ ##
191
+ # Returns the value. This will either return the value or raise an error
192
+ # indicating failure to compute the value.
193
+ #
194
+ # If the value was previously cached, it will return that cached value,
195
+ # otherwise it will either run the computation to try to determine the
196
+ # value, or wait for another thread that is already running the
197
+ # computation. Thus, this method could block.
198
+ #
199
+ # Any arguments passed will be forwarded to the block if called, but are
200
+ # ignored if a cached value is returned.
201
+ #
202
+ # @return [Object] the value
203
+ # @raise [Exception] if an error happened while computing the value
204
+ #
205
+ def get(*extra_args)
206
+ @mutex.synchronize do
207
+ # Wait for any backfill to complete, and handle expiration first
208
+ # because it might change the state.
209
+ wait_backfill
210
+ do_expire if should_expire?
211
+ # Main state handling
212
+ if @retries.finished?
213
+ # finished state: return value or error
214
+ return cached_value
215
+ elsif !@compute_notify.nil?
216
+ # computing state: wait for the computing thread to finish then
217
+ # return its result
218
+ wait_compute
219
+ return cached_value
220
+ else
221
+ # pending state
222
+ cur_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
223
+ # waiting for the next retry: return current error
224
+ raise @error if @expires_at && cur_time < @expires_at
225
+ # no delay: compute in the current thread
226
+ enter_compute(cur_time)
227
+ # and continue below
228
+ end
229
+ end
230
+
231
+ # Gets here if we just transitioned from pending to compute
232
+ perform_compute(extra_args)
233
+ end
234
+
235
+ ##
236
+ # This method calls {#get} repeatedly until a final result is available or
237
+ # retries have exhausted.
238
+ #
239
+ # Note: this method spins on {#get}, although honoring any retry delay.
240
+ # Thus, it is best to call this only if retries are limited or a retry
241
+ # delay has been configured.
242
+ #
243
+ # @param extra_args [Array] extra arguments to pass to the block
244
+ # @param transient_errors [Array<Class>] An array of exception classes that
245
+ # will be treated as transient and will allow await to continue
246
+ # retrying. Exceptions omitted from this list will be treated as fatal
247
+ # errors and abort the call. Default is `[StandardError]`.
248
+ # @param max_tries [Integer,nil] The maximum number of times this will call
249
+ # {#get} before giving up, or nil for a potentially unlimited number of
250
+ # attempts. Default is 1.
251
+ # @param max_time [Numeric,nil] The maximum time in seconds this will spend
252
+ # before giving up, or nil (the default) for a potentially unlimited
253
+ # timeout.
254
+ # @param delay_epsilon [Numeric] An extra delay in seconds to ensure that
255
+ # retries happen after the retry delay period
256
+ #
257
+ # @return [Object] the value
258
+ # @raise [Exception] if a fatal error happened, or retries have been
259
+ # exhausted.
260
+ #
261
+ def await(*extra_args, transient_errors: nil, max_tries: 1, max_time: nil, delay_epsilon: 0.0001)
262
+ transient_errors ||= [StandardError]
263
+ transient_errors = Array(transient_errors)
264
+ expiry_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + max_time if max_time
265
+ begin
266
+ get(*extra_args)
267
+ rescue *transient_errors
268
+ # A snapshot of the state. It is possible that another thread has
269
+ # changed this state since we received the error. This is okay because
270
+ # our specification for this method is conservative: whatever we return
271
+ # will have been correct at some point.
272
+ state = internal_state
273
+ # Don't retry unless we're in a state where retries can happen.
274
+ raise if [InternalState::FAILED, InternalState::SUCCESS].include?(state.state)
275
+ if max_tries
276
+ # Handle retry countdown
277
+ max_tries -= 1
278
+ raise unless max_tries.positive?
279
+ end
280
+ # Determine the next delay
281
+ delay = determine_await_retry_delay(state, expiry_time, delay_epsilon)
282
+ # nil means we've exceeded the max time
283
+ raise if delay.nil?
284
+ sleep(delay) if delay.positive?
285
+ retry
286
+ end
287
+ end
288
+
289
+ ##
290
+ # Returns the current low-level state immediately without waiting for
291
+ # computation.
292
+ #
293
+ # @return [InternalState]
294
+ #
295
+ def internal_state
296
+ @mutex.synchronize do
297
+ if @retries.finished?
298
+ if @error
299
+ InternalState.new(InternalState::FAILED, nil, @error, @expires_at)
300
+ else
301
+ InternalState.new(InternalState::SUCCESS, @value, nil, @expires_at)
302
+ end
303
+ elsif @compute_notify.nil?
304
+ InternalState.new(InternalState::PENDING, nil, @error, @expires_at)
305
+ else
306
+ InternalState.new(InternalState::COMPUTING, nil, nil, @expires_at)
307
+ end
308
+ end
309
+ end
310
+
311
+ ##
312
+ # Force this cache to expire immediately, if computation is complete. Any
313
+ # cached value will be cleared, the retry count is reset, and the next
314
+ # access will call the compute block as if it were the first access.
315
+ # Returns true if this took place. Has no effect and returns false if the
316
+ # computation is not yet complete (i.e. if a thread is currently computing,
317
+ # or if the last attempt failed and retries have not yet been exhausted.)
318
+ #
319
+ # @return [true,false] whether the cache was expired
320
+ #
321
+ def expire!
322
+ @mutex.synchronize do
323
+ wait_backfill
324
+ return false unless @retries.finished?
325
+ do_expire
326
+ true
327
+ end
328
+ end
329
+
330
+ ##
331
+ # Set the cache value explicitly and immediately. If a computation is in
332
+ # progress, it is "detached" and its result will no longer be considered.
333
+ #
334
+ # @param value [Object] the value to set
335
+ # @param lifetime [Numeric] the lifetime until expiration in seconds, or
336
+ # nil (the default) for no expiration.
337
+ # @return [Object] the value
338
+ #
339
+ def set!(value, lifetime: nil)
340
+ @mutex.synchronize do
341
+ @value = value
342
+ @expires_at = determine_expiry(lifetime)
343
+ @error = nil
344
+ @retries.finish!
345
+ unless @compute_notify.nil?
346
+ enter_backfill
347
+ leave_compute
348
+ end
349
+ value
350
+ end
351
+ end
352
+
353
+ private
354
+
355
+ ##
356
+ # Perform computation, and transition state on completion.
357
+ # This must be called from outside the mutex.
358
+ # Returns the final value, or raises the final error.
359
+ #
360
+ def perform_compute(extra_args)
361
+ value = @compute_handler.call(*extra_args)
362
+ if !value.is_a?(ExpiringValue) && @default_lifetime
363
+ value = ExpiringValue.new(@default_lifetime, value)
364
+ end
365
+ @mutex.synchronize do
366
+ handle_success(value)
367
+ end
368
+ rescue ::StandardError => e
369
+ if !e.is_a?(ExpiringError) && @default_lifetime
370
+ begin
371
+ raise ExpiringError, @default_lifetime
372
+ rescue ExpiringError => ee
373
+ e = ee
374
+ end
375
+ end
376
+ @mutex.synchronize do
377
+ handle_failure(e)
378
+ end
379
+ end
380
+
381
+ ##
382
+ # Either return the cached value or raise the cached error.
383
+ # This must be called from within the mutex.
384
+ #
385
+ def cached_value
386
+ raise @error if @error
387
+ @value
388
+ end
389
+
390
+ ##
391
+ # Determine whether we should expire a cached value and compute a new one.
392
+ # Happens in the Finished state if @expires_at is in the past.
393
+ # This must be called from within the mutex.
394
+ #
395
+ def should_expire?
396
+ @retries.finished? && @expires_at && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= @expires_at
397
+ end
398
+
399
+ ##
400
+ # Reset this cache, transitioning to the Pending state and resetting the
401
+ # retry count.
402
+ # This must be called from within the mutex.
403
+ #
404
+ def do_expire
405
+ @retries.reset!
406
+ @value = @error = @expires_at = nil
407
+ end
408
+
409
+ ##
410
+ # Wait for backfill to complete if it is in progress, otherwise just
411
+ # return immediately.
412
+ # This must be called from within the mutex.
413
+ #
414
+ def wait_backfill
415
+ @backfill_notify.wait @mutex while @backfill_notify
416
+ end
417
+
418
+ ##
419
+ # Wait for computation to complete.
420
+ # Also adds the current thread to the backfill list, ensuring that the
421
+ # computing thread will enter the backfill phase on completion. Once
422
+ # computation is done, also checks whether the current thread is the last
423
+ # one to backfill, and if so, turns off backfill mode.
424
+ # This must be called from within the mutex.
425
+ #
426
+ def wait_compute
427
+ if ::Thread.current.equal?(@computing_thread)
428
+ raise ::ThreadError, "deadlock: tried to call LazyData::Value#get from its own computation"
429
+ end
430
+ @backfill_count += 1
431
+ begin
432
+ @compute_notify.wait(@mutex)
433
+ ensure
434
+ @backfill_count -= 1
435
+ leave_backfill
436
+ end
437
+ end
438
+
439
+ ##
440
+ # Initializes compute mode.
441
+ # This must be called from within the mutex.
442
+ #
443
+ def enter_compute(cur_time)
444
+ @computing_thread = ::Thread.current
445
+ @compute_notify = ::Thread::ConditionVariable.new
446
+ @expires_at = cur_time
447
+ @value = @error = nil
448
+ end
449
+
450
+ ##
451
+ # Finishes compute mode, notifying threads waiting on it.
452
+ # This must be called from within the mutex.
453
+ #
454
+ def leave_compute
455
+ @computing_thread = nil
456
+ @compute_notify.broadcast
457
+ @compute_notify = nil
458
+ end
459
+
460
+ ##
461
+ # Checks for any threads that need backfill, and if so triggers backfill
462
+ # mode.
463
+ # This must be called from within the mutex.
464
+ #
465
+ def enter_backfill
466
+ return unless @backfill_count.positive?
467
+ @backfill_notify = ::Thread::ConditionVariable.new
468
+ end
469
+
470
+ ##
471
+ # Checks whether all threads are done with backfill, and if so notifies
472
+ # threads waiting for backfill to finish.
473
+ # This must be called from within the mutex.
474
+ #
475
+ def leave_backfill
476
+ return unless @backfill_count.zero?
477
+ @backfill_notify.broadcast
478
+ @backfill_notify = nil
479
+ end
480
+
481
+ ##
482
+ # Sets state to reflect a successful computation (as long as this
483
+ # computation wasn't interrupted by someone calling #set!).
484
+ # Then returns the computed value.
485
+ # This must be called from within the mutex.
486
+ #
487
+ def handle_success(value)
488
+ expires_at = nil
489
+ if value.is_a?(ExpiringValue)
490
+ expires_at = determine_expiry(value.lifetime)
491
+ value = value.value
492
+ end
493
+ if ::Thread.current.equal?(@computing_thread)
494
+ @retries.finish!
495
+ @error = nil
496
+ @value = value
497
+ @expires_at = expires_at
498
+ enter_backfill
499
+ leave_compute
500
+ end
501
+ cached_value
502
+ end
503
+
504
+ ##
505
+ # Sets state to reflect a failed computation (as long as this computation
506
+ # wasn't interrupted by someone calling #set!).
507
+ # Then raises the error.
508
+ # This must be called from within the mutex.
509
+ #
510
+ def handle_failure(error)
511
+ expires_at = nil
512
+ if error.is_a?(ExpiringError)
513
+ expires_at = determine_expiry(error.lifetime)
514
+ error = error.cause
515
+ end
516
+ if ::Thread.current.equal?(@computing_thread)
517
+ retry_delay = @retries.next(start_time: @expires_at)
518
+ @value = nil
519
+ @error = error
520
+ @expires_at =
521
+ if retry_delay.nil?
522
+ # No more retries; use the expiration for the error
523
+ expires_at
524
+ elsif retry_delay.positive?
525
+ determine_expiry(retry_delay)
526
+ end
527
+ enter_backfill
528
+ leave_compute
529
+ end
530
+ cached_value
531
+ end
532
+
533
+ ##
534
+ # Determines the delay until the next retry during an await
535
+ #
536
+ def determine_await_retry_delay(state, expiry_time, delay_epsilon)
537
+ cur_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
538
+ next_run_time =
539
+ if state.state == InternalState::PENDING && state.expires_at
540
+ # Run at end of the current retry delay, plus an epsilon,
541
+ # if in pending state
542
+ state.expires_at + delay_epsilon
543
+ else
544
+ # Default to run immediately otherwise
545
+ cur_time
546
+ end
547
+ # Signal nil if we're past the max time
548
+ return nil if expiry_time && next_run_time > expiry_time
549
+ # No delay if we're already past the time we want to run
550
+ return 0 if next_run_time < cur_time
551
+ next_run_time - cur_time
552
+ end
553
+
554
+ ##
555
+ # Determines the expires_at value in monotonic time, given a lifetime.
556
+ #
557
+ def determine_expiry(lifetime)
558
+ lifetime ? ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + lifetime : nil
559
+ end
560
+ end
561
+ end
@@ -0,0 +1,23 @@
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
+ # Current version of lazy_data
20
+ # @return [String]
21
+ #
22
+ VERSION = "0.1.0"
23
+ end
data/lib/lazy_data.rb CHANGED
@@ -1,8 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Daniel Azuma
1
4
  #
2
- # This is a placeholder Ruby file for gem "lazy_data".
3
- # It was generated on 2026-03-16 to reserve the gem name.
4
- # The actual gem is planned for release in the near future.
5
- # If this is a problem, or if the actual gem has not been
6
- # released in a timely manner, you can contact the owner at
7
- # dazuma@gmail.com
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
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
+ require "lazy_data/dict"
18
+ require "lazy_data/expiry"
19
+ require "lazy_data/internal_state"
20
+ require "lazy_data/retries"
21
+ require "lazy_data/value"
22
+ require "lazy_data/version"
23
+
24
+ ##
25
+ # LazyData provides data types featuring thread-safe lazy computation.
26
+ #
27
+ # LazyData objects are constructed with a block that can be called to compute
28
+ # the final value, but it is not actually called until the value is requested.
29
+ # Once requested, the computation takes place only once, in the first thread
30
+ # that requested the value. Future requests will return a cached value.
31
+ # Furthermore, any other threads that request the value during the initial
32
+ # computation will block until the first thread has completed the computation.
33
+ #
34
+ # * {LazyData::Value} holds a single value
35
+ # * {LazyData::Dict} holds a dictionary of values, where each key points to a
36
+ # separate lazy value
37
+ #
38
+ # This implementation also provides retry and expiration features. The code was
39
+ # extracted from the google-cloud-env gem that originally used it.
40
+ #
41
+ module LazyData
42
+ class << self
43
+ ##
44
+ # Create a LazyData::Value.
45
+ #
46
+ # You must pass a block that will be called to compute the value the first
47
+ # time it is accessed. The block should evaluate to the desired value, or
48
+ # raise an exception on error. To specify a value that expires, use
49
+ # {LazyData.expiring_value}. To raise an exception that expires, use
50
+ # {LazyData.raise_expiring_error}.
51
+ #
52
+ # You can optionally pass a retry manager, which controls how subsequent
53
+ # accesses might try calling the block again if a compute attempt fails
54
+ # with an exception. A retry manager should either be an instance of
55
+ # {LazyData::Retries} or an object that duck types it.
56
+ #
57
+ # @param retries [LazyData::Retries] A retry manager. The default is a
58
+ # retry manager that tries only once.
59
+ # @param lifetime [Numeric,nil] The default lifetime of a computed value.
60
+ # Optional. No expiration by default if not provided. This can be
61
+ # overridden in the block by returning {LazyData.expiring_value} or
62
+ # calling {LazyData.raise_expiring_error} explicitly.
63
+ # @param block [Proc] A block that can be called to attempt to compute
64
+ # the value.
65
+ #
66
+ def value(retries: nil, lifetime: nil, &block)
67
+ LazyData::Value.new(retries: retries, lifetime: lifetime, &block)
68
+ end
69
+
70
+ ##
71
+ # Create a LazyData::Dict.
72
+ #
73
+ # You must pass a block that will be called to compute the value the first
74
+ # time it is accessed. The block takes the key as an argument and should
75
+ # evaluate to the value for that key, or raise an exception on error. To
76
+ # specify a value that expires, use {LazyData.expiring_value}. To raise an
77
+ # exception that expires, use {LazyData.raise_expiring_error}.
78
+ #
79
+ # You can optionally pass a retry manager, which controls how subsequent
80
+ # accesses might try calling the block again if a compute attempt fails
81
+ # with an exception. A retry manager should either be an instance of
82
+ # {LazyData::Retries} or an object that duck types it.
83
+ #
84
+ # @param retries [Retries,Proc] A retry manager. The default is a retry
85
+ # manager that tries only once. You can provide either a static retry
86
+ # manager or a Proc that returns a retry manager.
87
+ # @param lifetime [Numeric,nil] The default lifetime of a computed value.
88
+ # Optional. No expiration by default if not provided. This can be
89
+ # overridden in the block by returning {LazyData.expiring_value} or
90
+ # calling {LazyData.raise_expiring_error} explicitly.
91
+ # @param block [Proc] A block that can be called to attempt to compute the
92
+ # value given the key.
93
+ #
94
+ def dict(retries: nil, lifetime: nil, &block)
95
+ LazyData::Dict.new(retries: retries, lifetime: lifetime, &block)
96
+ end
97
+ end
98
+ end