recapi 0.1.4 → 0.1.7

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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -7
  3. data/lib/recapi.rb +85 -11
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfb3a3778d3f1733138f9f09c388edfd2e7cb946f6a2f38647b1070aa885d865
4
- data.tar.gz: cc7d5eed12f41976d3e898a43bfee08364eaf2224b23f36b062b101577f7cb06
3
+ metadata.gz: f8edb463693be71a5cac3a101441d5fe6c045e5e58f4003025291995ec5c9e44
4
+ data.tar.gz: 818ea20b257819a76f15c832af44b83694681fc36995f08b91987ac2b9e8afdf
5
5
  SHA512:
6
- metadata.gz: b48b41679b86910bb139237e630e5495bebfc4d1aa4fd9f924c2202344e5fbb903fb4c3966e5c608ffa406a90a2cb97c6a3fb75a989091509a41709f257e614f
7
- data.tar.gz: b711bee7d27e579947e075e02a3e4487a97a2b7fe433e4d067d035d5b747157cd2cec7efa1adf34f6bd62bc1dd4d0e63ceb8f5612eb90bb929b1282a981f4051
6
+ metadata.gz: cb54199fc5b2ff480e94be20d594598a2cbda5fb2b1b8ffb854b3dd4ac0dd3d9d2858b05e67cc79b264d93f41f4e5132b57c50812aa5c626582583d369763df7
7
+ data.tar.gz: 33fbcbfc1cca21caf75849d106e9500236d6adf2ac897c74b49e9731eb79b6680cb1054e637677babbd0ebc52fc853aa7a999ff10a1e11911da5ff01bb538c41
data/README.md CHANGED
@@ -30,7 +30,7 @@ line with your address): save it as `lib/recapi.rb` and
30
30
  `require Rails.root.join("lib/recapi").to_s` instead.
31
31
 
32
32
  ```sh
33
- curl -fLo lib/recapi.rb https://api.<your-domain>/sdk/ruby/recapi-0.1.4.rb
33
+ curl -fLo lib/recapi.rb https://api.<your-domain>/sdk/ruby/recapi-0.1.7.rb
34
34
  ```
35
35
 
36
36
  ## Quick start
@@ -41,7 +41,7 @@ require "recapi"
41
41
 
42
42
  # A secret key (rk_sec_…): it stays on your server.
43
43
  RECAPI = Recapi::Client.new(ENV.fetch("RECAPI_KEY"), "https://api.example.com",
44
- store: Rails.cache, logger: Rails.logger)
44
+ logger: Rails.logger)
45
45
  ```
46
46
 
47
47
  ```ruby
@@ -79,10 +79,10 @@ API failure `Recapi::Error` (`code`, `status`, `retry_after`, `retryable?`,
79
79
 
80
80
  `Recapi::Client.new(api_key, base_url, store:, timeout:, flush_interval:,
81
81
  max_batch:, logger:)` sets the options. The default `Recapi::MemoryStore`
82
- keeps the 10,000 most recent users in the process; pass `Rails.cache` (it
83
- has `read`/`write`/`delete`) or a Redis client (`get`/`set`/`del`) when
84
- several processes serve the same users. It stores the same JSON as the
85
- Python library, so both can share one store. Written to `Rails.cache` or a
82
+ keeps the 10,000 most recent users in the process; pass a Redis client
83
+ (`get`/`set`/`del`) or a `Rails.cache` on Redis, Memcached or Solid Cache (it
84
+ has `read`/`write`/`delete`) when several processes serve the same users. It
85
+ stores the same JSON as the Python library, so both can share one store. Written to `Rails.cache` or a
86
86
  redis-rb client, a user's state lasts 30 days from its last write, with no
87
87
  setting to choose: well past the 24 hours a click earns credit, and recapi gives
88
88
  a known `user_id` its history back afterwards. Any other store gets a plain
@@ -93,7 +93,29 @@ client; a forked worker (Puma cluster, Unicorn, Passenger) starts its own
93
93
  queue and sender at its first call. Proxies come from `HTTPS_PROXY`,
94
94
  `HTTP_PROXY` and `NO_PROXY`, as `Net::HTTP` reads them. Failures are logged
95
95
  to `logger:`, anything with `warn` and `error` (`Rails.logger`, a
96
- `Logger`); without one they go to `$stderr` as `recapi: …` lines.
96
+ `Logger`); without one they go to `$stderr` as `recapi: …` lines, with lost
97
+ events (a refused batch, rejected events) at most one line a minute for each
98
+ error code and for rejected events, so a revoked key on a busy server doesn't
99
+ flood the log, and a full queue one line until a send works. A failed write to
100
+ `$stderr` (a full disk, a closed stream) is ignored. A logger you give gets
101
+ every line, and if it raises, that reaches your code.
102
+
103
+ ### Which Rails caches work
104
+
105
+ Not every `Rails.cache` can hold this, and Rails' defaults often can't. The
106
+ library recognises ActiveSupport's own classes (a subclass of one is left alone)
107
+ and logs one line at start:
108
+
109
+ | `Rails.cache` | Where Rails sets it | What happens | Shared between |
110
+ |---|---|---|---|
111
+ | `RedisCacheStore`, `MemCacheStore`, Solid Cache | your choice; Solid Cache in new 8.x apps' production | used, no line | servers |
112
+ | `FileStore` | production on 7.2 and older | used, with a warning | one server's processes |
113
+ | `MemoryStore` | development on 8.x | the library's own store instead, with a warning | nothing |
114
+ | `NullStore` | development on 7.2 and older, without `rails dev:cache` | the library's own store instead, with a warning | nothing |
115
+
116
+ `FileStore` frees an expired entry only when it is read again or `cleanup` runs,
117
+ so a visitor who never returns stays on disk (about 8 KB each): run
118
+ `Rails.cache.cleanup` on a schedule, or leave `store:` out.
97
119
 
98
120
  ## Development
99
121
 
data/lib/recapi.rb CHANGED
@@ -25,7 +25,7 @@ rescue LoadError
25
25
  end
26
26
 
27
27
  module Recapi
28
- VERSION = "0.1.4"
28
+ VERSION = "0.1.7"
29
29
 
30
30
  # Decayed user history, exactly as docs/history-spec.md (times in unix ms).
31
31
  # A port of sdk/python's history.py, snapshot merging included.
@@ -319,7 +319,8 @@ module Recapi
319
319
  end
320
320
 
321
321
  # In-process store keeping the most recently used max_users users. Pass a
322
- # shared one (Redis, Rails.cache) when several processes serve the same users.
322
+ # shared one (Redis, a Rails.cache on Redis, Memcached or Solid Cache) when
323
+ # several processes serve the same users.
323
324
  class MemoryStore
324
325
  def initialize(max_users: 10_000)
325
326
  @max = [1, max_users].max
@@ -353,11 +354,21 @@ module Recapi
353
354
  # Bundler app without it in its Gemfile can't require.
354
355
  class StderrLogger
355
356
  def warn(message)
356
- $stderr.write("#{message}\n")
357
+ write_line(message)
357
358
  end
358
359
 
359
360
  def error(message)
361
+ write_line(message)
362
+ end
363
+
364
+ private
365
+
366
+ # A full disk or a closed stderr must not reach the app's track or kill the
367
+ # sender thread: a line nobody can read is no reason to stop sending.
368
+ def write_line(message)
360
369
  $stderr.write("#{message}\n")
370
+ rescue StandardError
371
+ nil
361
372
  end
362
373
  end
363
374
 
@@ -377,6 +388,7 @@ module Recapi
377
388
  LEVEL_NAMES = { "view" => 3, "cart" => 5, "purchase" => 10, "reject" => -10 }.freeze
378
389
  # Events waiting for a send: a minute-long outage at 150 events/s fits.
379
390
  MAX_QUEUE = 10_000
391
+ LOST_LINE_MS = 60_000
380
392
  MAX_FLUSH_BACKOFF = 60.0
381
393
  RETRIES = 3
382
394
  RETRY_BASE = 0.5
@@ -396,6 +408,16 @@ module Recapi
396
408
  # starts afresh. It bounds a Redis at about one month's visitors instead of
397
409
  # growing for ever.
398
410
  STATE_LIFETIME_SECONDS = 30 * 24 * 60 * 60
411
+ # ActiveSupport's caches that lose visitors' state, by exact class: a subclass
412
+ # may have changed what makes them so. They keep nothing or only in this
413
+ # process, where our MemoryStore is as local, holds more and competes with
414
+ # nothing else in the app's cache.
415
+ USE_OURS = {
416
+ "ActiveSupport::Cache::NullStore" => "keeps nothing",
417
+ "ActiveSupport::Cache::MemoryStore" => "keeps its entries in this process only"
418
+ }.freeze
419
+ FILE_STORE = "ActiveSupport::Cache::FileStore"
420
+ private_constant :USE_OURS, :FILE_STORE
399
421
  # Where redis-rb defines set: 4.x and 5 on, older versions, Redis::Distributed.
400
422
  REDIS_RB_SET = %w[Redis::Commands::Strings Redis Redis::Distributed].freeze
401
423
  # Open connections kept between calls: one per call in flight at the busiest moment.
@@ -426,7 +448,8 @@ module Recapi
426
448
  # api_key: secret key (rk_sec_...): a server can then restore a user's
427
449
  # history from recapi when the store lacks it. base_url: your recapi API
428
450
  # origin. store: per-user state, anything with get/set/delete (a Redis
429
- # client) or read/write/delete (Rails.cache); defaults to a MemoryStore.
451
+ # client) or read/write/delete (Rails.cache); defaults to a MemoryStore,
452
+ # which also replaces ActiveSupport's NullStore and MemoryStore.
430
453
  # timeout: per-request seconds (recommend() uses at most 1.5).
431
454
  # flush_interval: seconds between background sends. max_batch: events per
432
455
  # request, at most the API's 1,000. logger: where failures are logged,
@@ -443,12 +466,14 @@ module Recapi
443
466
  raise ArgumentError, "base_url must be an http(s) URL"
444
467
  end
445
468
 
446
- @store = store || MemoryStore.new
469
+ @logger = logger || StderrLogger.new
470
+ @limit_lost_lines = logger.nil?
471
+ @lost_printed = {}
472
+ @store = store ? store_to_use(store) : MemoryStore.new
447
473
  @store_lifetime = store_lifetime
448
474
  @timeout = timeout.to_f
449
475
  @interval = flush_interval.to_f
450
476
  @max_batch = max_batch.to_i.clamp(1, MAX_EVENTS_PER_REQUEST)
451
- @logger = logger || StderrLogger.new
452
477
  # Two environments' keys may share one Redis: their users must not mix.
453
478
  @prefix = "recapi:#{Digest::SHA256.hexdigest(api_key)[0, 12]}:"
454
479
  # Indirections the tests replace (clock, backoff sleep, jitter).
@@ -718,6 +743,7 @@ module Recapi
718
743
  @send_lock = Mutex.new
719
744
  @thread = nil
720
745
  @failures = 0
746
+ @overflow_printed = false
721
747
  @closed = false
722
748
  @idle = []
723
749
  @idle_lock = Mutex.new
@@ -777,6 +803,13 @@ module Recapi
777
803
  def drop_overflow
778
804
  dropped = @queue.size - MAX_QUEUE
779
805
  @queue.shift(dropped)
806
+ # A full queue drops on every track through an outage: on $stderr, which
807
+ # the operator can't filter, one line until a send works is enough, and
808
+ # the "sending failed" lines keep showing the outage. A logger given gets
809
+ # them all. Runs under @mutex.
810
+ return if @limit_lost_lines && @overflow_printed
811
+
812
+ @overflow_printed = true
780
813
  @logger.warn("recapi: event queue full, dropped the #{dropped} oldest events")
781
814
  end
782
815
 
@@ -809,7 +842,7 @@ module Recapi
809
842
  send_events(batch, retry_errors)
810
843
  rescue StandardError => e
811
844
  if e.is_a?(Error) && !e.retryable?
812
- @logger.error("recapi: dropped #{batch.size} events: #{e.message}")
845
+ log_lost(e.code, "recapi: dropped #{batch.size} events: #{e.message}")
813
846
  next
814
847
  end
815
848
  # Anything else keeps the batch, a bug of ours included: event ids
@@ -823,7 +856,10 @@ module Recapi
823
856
  @logger.warn("recapi: sending #{batch.size} events failed, kept: #{e.message}")
824
857
  return
825
858
  end
826
- @mutex.synchronize { @failures = 0 }
859
+ @mutex.synchronize do
860
+ @failures = 0
861
+ @overflow_printed = false
862
+ end
827
863
  end
828
864
  end
829
865
  end
@@ -850,7 +886,23 @@ module Recapi
850
886
  detail = rejected.first(5).map do |r|
851
887
  r.is_a?(Hash) && r["index"].is_a?(Integer) ? r.merge("event" => events[r["index"]]) : r
852
888
  end
853
- @logger.error("recapi: #{rejected.size} event(s) rejected: #{JSON.generate(detail)}")
889
+ log_lost(:rejected, "recapi: #{rejected.size} event(s) rejected: #{JSON.generate(detail)}")
890
+ end
891
+
892
+ # A revoked key refuses every batch and the sender runs on with no backoff,
893
+ # so $stderr gets one line a minute per kind (error code, or rejected
894
+ # events): one line per outage would have aged out of the log by the time
895
+ # someone looks. A logger given gets them all. Runs under @send_lock.
896
+ def log_lost(kind, message)
897
+ if @limit_lost_lines
898
+ now = @clock.call
899
+ last = @lost_printed[kind]
900
+ # abs: a clock set back must not mute the kind until it catches up.
901
+ return if last && (now - last).abs < LOST_LINE_MS
902
+
903
+ @lost_printed[kind] = now
904
+ end
905
+ @logger.error(message)
854
906
  end
855
907
 
856
908
  # Calls the block, retrying retryable errors with jittered exponential
@@ -1020,10 +1072,30 @@ module Recapi
1020
1072
  @logger.error("recapi: store write failed: #{e.class}: #{e.message}")
1021
1073
  end
1022
1074
 
1075
+ # The store, or our own in its place if it is an ActiveSupport cache that
1076
+ # loses state. Says so in one line, at start only. Any doubt, or a store
1077
+ # that fails here, means the store as given.
1078
+ def store_to_use(store)
1079
+ name = store.class.name
1080
+ if USE_OURS.key?(name)
1081
+ @logger.warn("recapi: #{name} #{USE_OURS[name]}, so recapi uses its own in-process store (10,000 users). " \
1082
+ "For several processes or servers, use Redis, Memcached or Solid Cache.")
1083
+ return MemoryStore.new
1084
+ end
1085
+ if name == FILE_STORE
1086
+ @logger.warn("recapi: #{name} isn't shared between servers, and frees an expired entry only when it is " \
1087
+ "read again or cleanup runs. Run Rails.cache.cleanup on a schedule; for several servers, " \
1088
+ "use Redis, Memcached or Solid Cache.")
1089
+ end
1090
+ store
1091
+ rescue StandardError
1092
+ store
1093
+ end
1094
+
1023
1095
  # What makes this store's write expire the entry. By class name, since the
1024
1096
  # library requires neither gem. Any doubt (a subclass's own set, a wrapper
1025
- # such as ConnectionPool::Wrapper) means no option, as for a store of the
1026
- # customer's.
1097
+ # such as ConnectionPool::Wrapper, a store that fails here) means no option,
1098
+ # as for a store of the customer's.
1027
1099
  def store_lifetime
1028
1100
  klass = @store.class
1029
1101
  if klass.public_method_defined?(:set)
@@ -1033,6 +1105,8 @@ module Recapi
1033
1105
  else
1034
1106
  {}
1035
1107
  end
1108
+ rescue StandardError
1109
+ {}
1036
1110
  end
1037
1111
 
1038
1112
  # Redis clients have get/set/del, Rails.cache read/write/delete.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - recapi