Class: OpenReceive::Rates::CachedPriceFeed

Inherits:
Object
  • Object
show all
Defined in:
lib/openreceive/rates.rb

Overview

Serves BTC fiat rates from a disposable process-local cache, refreshing from the primary feed first and the fallback second. Port of the JS CachedPriceFeed state machine: fresh entries are served for cache_seconds; a refresh failure fails closed for cache_seconds; a concurrent in-flight refresh serves the stale entry only while it is younger than the invoice quote TTL. Satisfies the openreceive-server price_provider contract via btc_fiat_price(currency).

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(currencies:, primary:, fallback:, cache_seconds: nil, clock: nil) ⇒ CachedPriceFeed

Returns a new instance of CachedPriceFeed.

Raises:

  • (ArgumentError)


303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# File 'lib/openreceive/rates.rb', line 303

def initialize(currencies:, primary:, fallback:, cache_seconds: nil, clock: nil)
  raise ArgumentError, "CachedPriceFeed requires at least one currency" if currencies.empty?
  cache_seconds ||= PRICE_FEED_CACHE_SECONDS
  unless cache_seconds.is_a?(Integer) && cache_seconds.positive?
    raise ArgumentError, "CachedPriceFeed cache_seconds must be a positive integer"
  end
  # A cache window wider than the quote TTL would let a read be reported
  # as fresh that is already too old to price an invoice.
  if cache_seconds > INVOICE_QUOTE_TTL_SECONDS
    raise ArgumentError,
          "CachedPriceFeed cache_seconds must not exceed the #{INVOICE_QUOTE_TTL_SECONDS}s invoice quote TTL"
  end
  @currencies = currencies.map(&:to_s).freeze
  @primary = primary
  @fallback = fallback
  @cache_seconds = cache_seconds
  @clock = clock || -> { Time.now.to_i }
  @source = "primary"
  @mutex = Mutex.new
  @refresh_done = ConditionVariable.new
  @state = nil
  @in_flight = nil
end

Instance Attribute Details

#sourceObject (readonly)

Representative source for the plain source reader; the true origin is reported per-call by btc_fiat_rates_with_source.



301
302
303
# File 'lib/openreceive/rates.rb', line 301

def source
  @source
end

Instance Method Details

#btc_fiat_price(currency) ⇒ Object

The openreceive-server price_provider contract: one decimal price string for one uppercase ISO 4217 currency.



348
349
350
# File 'lib/openreceive/rates.rb', line 348

def btc_fiat_price(currency)
  btc_fiat_rates([currency]).fetch("bitcoin").fetch(Rates.normalize_fiat_currency(currency))
end

#btc_fiat_rates(currencies) ⇒ Object



327
328
329
# File 'lib/openreceive/rates.rb', line 327

def btc_fiat_rates(currencies)
  btc_fiat_rates_with_source(currencies).fetch("rates")
end

#btc_fiat_rates_with_source(currencies) ⇒ Object



331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/openreceive/rates.rb', line 331

def btc_fiat_rates_with_source(currencies)
  now = @clock.call
  claim = read_or_claim_refresh(now)
  entry =
    case claim.fetch(:status)
    when :served then claim.fetch(:entry)
    when :pending then await_refresh(claim.fetch(:pending))
    else tracked_refresh(now, claim.fetch(:previous_entry), claim.fetch(:pending))
    end
  {
    "source" => entry.fetch("source"),
    "rates" => Rates.parse_simple_price_response(entry.fetch("rates"), currencies)
  }
end

#health_check(currencies = nil) ⇒ Object

Forces a live refresh, ignoring the cache, for explicit operational probes. Raises if both feeds fail. Tolerant of an upstream that drops an individual currency (pass no currencies to get everything cached).



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/openreceive/rates.rb', line 355

def health_check(currencies = nil)
  now = @clock.call
  pending = { "owner" => Thread.current, "done" => false }
  previous_entry = @mutex.synchronize do
    @in_flight = pending
    @state && @state["entry"]
  end
  entry = tracked_refresh(now, previous_entry, pending)
  rates =
    if currencies.nil? || currencies.empty?
      entry.fetch("rates")
    else
      Rates.parse_simple_price_response(entry.fetch("rates"), currencies)
    end
  { "source" => entry.fetch("source"), "rates" => rates }
end