ractor-sharing 0.2.0 → 0.3.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,104 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # A small shop, with every tool in its natural seat:
4
+ #
5
+ # prices TVar read by every checkout, changed once mid-run (a sale)
6
+ # stock TVar/SKU a checkout reserves EVERY item in the cart or nothing
7
+ # ledger LockHash order count, revenue, per-SKU figures that must agree
8
+ # audit ActiveObject fire-and-forget order log, ordered by its owner
9
+ #
10
+ # The heart is the checkout transaction: prices and all the cart's stock levels
11
+ # are read and written in ONE Ractor.atomically, so a cart is never half
12
+ # reserved and never priced across a sale boundary.
13
+ #
14
+ # ruby -Ilib examples/11_webshop.rb
15
+ Warning[:experimental] = false
16
+ require "ractor/sharing"
17
+
18
+ SKUS = %i[mug tee cap].freeze
19
+ START_STOCK = { mug: 120, tee: 90, cap: 60 }.freeze
20
+
21
+ PRICES = Ractor::TVar.new({ mug: 12, tee: 25, cap: 15 }.freeze)
22
+ STOCK = Ractor.make_shareable(START_STOCK.transform_values { |n| Ractor::TVar.new(n) })
23
+
24
+ ledger = Ractor::LockHash.new(orders: 0, revenue: 0)
25
+
26
+ class AuditLog < Ractor::ActiveObject
27
+ def initialize = @lines = []
28
+ async def order(shopper, cart, cost) = @lines << [shopper, cart, cost].freeze
29
+ async def rejection(shopper, cart) = @lines << [shopper, cart, :out_of_stock].freeze
30
+ sync def entries = @lines.dup
31
+ end
32
+ audit = AuditLog.new
33
+
34
+ shoppers = 4.times.map do |i|
35
+ Ractor.new(PRICES, STOCK, ledger, audit, i) do |prices, stock, led, log, id|
36
+ rng = Random.new(42 + id)
37
+ sold = 0
38
+ 40.times do
39
+ cart = SKUS.sample(rng.rand(1..2), random: rng).tally # e.g. {mug: 1, tee: 1}
40
+
41
+ # All or nothing: every stock level and the price list, one transaction.
42
+ cost = Ractor.atomically do
43
+ next nil if cart.any? { |sku, n| stock[sku].value < n }
44
+
45
+ cart.each { |sku, n| stock[sku].value -= n }
46
+ p = prices.value
47
+ cart.sum { |sku, n| p[sku] * n }
48
+ end
49
+
50
+ if cost.nil?
51
+ log.rejection(id, cart)
52
+ next
53
+ end
54
+
55
+ # The books: order count, revenue and the per-SKU units move together,
56
+ # so no snapshot ever shows an order whose units are missing.
57
+ led.synchronize do |h|
58
+ h[:orders] += 1
59
+ h[:revenue] += cost
60
+ cart.each { |sku, n| h[sku] = (h[sku] || 0) + n }
61
+ end
62
+ log.order(id, cart, cost)
63
+ sold += 1
64
+ end
65
+ sold
66
+ end
67
+ end
68
+
69
+ # The sale: one atomic price flip, mid-run. A checkout sees old or new, never a mix.
70
+ sleep 0.01
71
+ Ractor.atomically { PRICES.value = PRICES.value.merge(tee: 19).freeze }
72
+
73
+ # An accountant snapshots the books while shoppers are still at it: order count
74
+ # and revenue always came from the same synchronize, so cross-checking entries
75
+ # never catches them mid-write.
76
+ accountant = Ractor.new(ledger) do |led|
77
+ 60.times.count do
78
+ snap = led.to_h
79
+ abort "negative books: #{snap}" if snap[:orders].negative? || snap[:revenue].negative?
80
+ true
81
+ end
82
+ end
83
+
84
+ orders_placed = shoppers.sum(&:value)
85
+ accountant.join
86
+ books = ledger.to_h
87
+ entries = audit.entries
88
+ sold_entries = entries.reject { |e| e[2] == :out_of_stock }
89
+
90
+ # Conservation, the real test of the checkout transaction:
91
+ SKUS.each do |sku|
92
+ sold = sold_entries.sum { |_, cart, _| cart[sku] || 0 }
93
+ left = STOCK[sku].value
94
+ abort "#{sku}: #{START_STOCK[sku]} - #{sold} != #{left}" unless START_STOCK[sku] - sold == left
95
+ abort "#{sku} ledger units off" unless (books[sku] || 0) == sold
96
+ abort "#{sku} oversold" if left.negative?
97
+ end
98
+ abort "books vs audit: #{books[:orders]} vs #{sold_entries.size}" unless books[:orders] == sold_entries.size
99
+ abort "revenue drifted" unless books[:revenue] == sold_entries.sum { |_, _, c| c }
100
+ abort "ledger vs shoppers" unless books[:orders] == orders_placed
101
+
102
+ rej = entries.size - sold_entries.size
103
+ puts "ok: #{books[:orders]} orders, #{rej} rejected out-of-stock, revenue #{books[:revenue]}; " \
104
+ "stock left #{SKUS.map { |s| "#{s}:#{STOCK[s].value}" }.join(' ')}"
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # A crash-safe key-value store: every write is appended to a write-ahead log
4
+ # on disk before anything else sees it, then the process "crashes" and a fresh
5
+ # store replays the log -- and must arrive at the identical state.
6
+ #
7
+ # The store is an ActiveObject rather than an ActorHash for one load-bearing
8
+ # reason: THE STORE ITSELF writes its log, inside its own owner, so the log
9
+ # order and the apply order are the same order by construction. Writers are
10
+ # async (a set does not wait for the disk); reads are sync; sync def flush is
11
+ # the durability barrier.
12
+ #
13
+ # ruby -Ilib examples/12_kvstore_wal.rb
14
+ Warning[:experimental] = false
15
+ require "ractor/sharing"
16
+ require "tempfile"
17
+
18
+ class KVStore < Ractor::ActiveObject
19
+ def initialize(wal_path, replay: false)
20
+ @h = {}
21
+ @wal = File.open(wal_path, replay ? "r" : "w")
22
+ if replay
23
+ @wal.each_line do |line|
24
+ op, key, arg = line.chomp.split("\t", 3)
25
+ apply(op, key, arg)
26
+ end
27
+ @wal.close
28
+ @wal = nil
29
+ end
30
+ end
31
+
32
+ async def set(key, value) = log_and_apply("set", key, value.to_s)
33
+ async def incr(key, by = 1) = log_and_apply("incr", key, by.to_s)
34
+ async def del(key) = log_and_apply("del", key)
35
+
36
+ sync def get(key) = @h[key]
37
+ sync def snapshot = @h.dup
38
+ sync def flush = (@wal&.fsync; @h.size) # the barrier: everything before is on disk
39
+
40
+ private
41
+
42
+ def log_and_apply(op, key, arg = nil)
43
+ @wal.puts([op, key, arg].compact.join("\t"))
44
+ apply(op, key, arg)
45
+ end
46
+
47
+ def apply(op, key, arg)
48
+ case op
49
+ when "set" then @h[key] = arg
50
+ when "incr" then @h[key] = (@h[key] || "0").to_i + Integer(arg)
51
+ when "del" then @h.delete(key)
52
+ end
53
+ end
54
+ end
55
+
56
+ wal = Tempfile.create(["kvstore", ".wal"])
57
+ wal.close
58
+
59
+ store = KVStore.new(wal.path)
60
+
61
+ writers = 4.times.map do |i|
62
+ Ractor.new(store, i) do |s, id|
63
+ 100.times do |j|
64
+ s.set("user:#{id}:name", "shopper-#{id}")
65
+ s.incr("hits")
66
+ s.incr("user:#{id}:visits")
67
+ s.del("user:#{id}:name") if j == 50
68
+ end
69
+ s.flush # my writes are applied and on disk
70
+ :ok
71
+ end
72
+ end
73
+ writers.each(&:join)
74
+
75
+ before = store.snapshot
76
+ abort "hits wrong: #{before}" unless before["hits"] == 400
77
+
78
+ # The crash: the store object is simply abandoned. All we hold is the file.
79
+ recovered = KVStore.new(wal.path, replay: true).snapshot
80
+
81
+ abort "recovery diverged:\n live #{before}\n replay #{recovered}" unless recovered == before
82
+ File.unlink(wal.path)
83
+ puts "ok: #{before.size} keys survived the crash byte for byte; " \
84
+ "hits=#{recovered['hits']}, wal replayed from #{File.basename(wal.path)}"
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # A production-shaped logger: callers fire and forget, the owner BATCHES --
4
+ # it buffers lines and writes the file only when the buffer fills or grows
5
+ # stale, so six hundred log calls become a handful of write syscalls.
6
+ #
7
+ # Notice how little machinery this takes: the buffer, the high-water mark and
8
+ # the "when did I last flush" clock are ordinary ivars, because the owner is
9
+ # the only one who ever touches them. The batching policy is just an if.
10
+ #
11
+ # ruby -Ilib examples/13_buffered_logger.rb
12
+ Warning[:experimental] = false
13
+ require "ractor/sharing"
14
+ require "tempfile"
15
+
16
+ class BufferedLogger < Ractor::ActiveObject
17
+ FLUSH_AT = 64 # lines
18
+ FLUSH_AFTER = 0.05 # seconds without a flush
19
+
20
+ def initialize(path)
21
+ @io = File.open(path, "a")
22
+ @buf = []
23
+ @last_flush = now
24
+ @writes = 0 # actual write syscalls, to show the batching
25
+ end
26
+
27
+ async def log(line)
28
+ @buf << "#{line}\n"
29
+ flush! if @buf.size >= FLUSH_AT || now - @last_flush > FLUSH_AFTER
30
+ end
31
+
32
+ # For quiet periods: anybody may poke the logger now and then, and stale
33
+ # lines go out even when nothing new arrives.
34
+ async def tick
35
+ flush! if @buf.any? && now - @last_flush > FLUSH_AFTER
36
+ end
37
+
38
+ sync def close
39
+ flush!
40
+ @io.fsync
41
+ @io.close
42
+ @writes
43
+ end
44
+
45
+ private
46
+
47
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+
49
+ def flush!
50
+ return if @buf.empty?
51
+
52
+ @io.write(@buf.join) # one syscall for the whole batch
53
+ @writes += 1
54
+ @buf.clear
55
+ @last_flush = now
56
+ end
57
+ end
58
+
59
+ file = Tempfile.create(["app", ".log"])
60
+ file.close
61
+ logger = BufferedLogger.new(file.path)
62
+
63
+ workers = 4.times.map do |i|
64
+ Ractor.new(logger, i) do |log, id|
65
+ 150.times { |seq| log.log("w#{id} seq=#{seq}") } # never waits for the disk
66
+ :ok
67
+ end
68
+ end
69
+ ticker = Thread.new { 5.times { sleep 0.02; logger.tick } }
70
+
71
+ workers.each(&:join)
72
+ ticker.join
73
+ writes = logger.close
74
+
75
+ lines = File.readlines(file.path, chomp: true)
76
+ abort "lines lost: #{lines.size}" unless lines.size == 600
77
+ # The owner serializes, and ports are FIFO per sender: each worker's own lines
78
+ # land in the file in the order it logged them.
79
+ 4.times do |id|
80
+ seqs = lines.filter_map { |l| $1.to_i if l =~ /\Aw#{id} seq=(\d+)\z/ }
81
+ abort "w#{id} lines reordered or lost" unless seqs == (0...150).to_a
82
+ end
83
+ abort "no batching happened: #{writes} writes" unless writes < 600 / 8
84
+ File.unlink(file.path)
85
+ puts "ok: 600 log calls became #{writes} write syscalls; every worker's lines in order"
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # An API gateway: three middlewares you meet in every real backend, each on
4
+ # the member of the family that fits it.
5
+ #
6
+ # token buckets LockHash one frozen {tokens:, at:} record per client,
7
+ # refilled and debited in one synchronize
8
+ # idempotency LockHash "seen this request id?" is an atomic
9
+ # check-and-claim across keys that grow forever
10
+ # circuit breaker LockVar one state machine, and the "breaker opened"
11
+ # audit line fires EXACTLY once per transition,
12
+ # because an update block never reruns
13
+ #
14
+ # A request runs the gauntlet in order: bucket -> idempotency -> breaker ->
15
+ # upstream. The upstream and the audit trail are ActiveObjects.
16
+ #
17
+ # ruby -Ilib examples/14_api_gateway.rb
18
+ Warning[:experimental] = false
19
+ require "ractor/sharing"
20
+
21
+ CAPACITY = 20 # bucket size per client
22
+ REFILL = 50.0 # tokens per second
23
+ THRESHOLD = 5 # consecutive failures that open the breaker
24
+ COOLDOWN = 0.03 # seconds before the breaker lets a probe through
25
+
26
+ class Upstream < Ractor::ActiveObject
27
+ def initialize = (@hits = Hash.new(0); @down = false)
28
+ sync def set_down(flag) = @down = flag
29
+ sync def call(id) = @down ? :error : (@hits[id] += 1; "resp-#{id}".freeze)
30
+ sync def hits = @hits.dup
31
+ end
32
+
33
+ class Audit < Ractor::ActiveObject
34
+ def initialize = @events = []
35
+ async def event(name) = @events << name
36
+ sync def events = @events.dup
37
+ end
38
+
39
+ def mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
40
+
41
+ # --- the gateway, called from any Ractor -----------------------------------
42
+
43
+ def gateway_call(gw, client, id)
44
+ client = -client # LockHash keys must be shareable:
45
+ id = -id # -string is the frozen, deduped copy
46
+
47
+ # 1. Token bucket: refill by elapsed time, take one, all under the hash lock.
48
+ allowed = nil
49
+ gw[:buckets].synchronize do |h|
50
+ rec = h[client] || { tokens: CAPACITY.to_f, at: mono }.freeze
51
+ tokens = [rec[:tokens] + (mono - rec[:at]) * REFILL, CAPACITY.to_f].min
52
+ allowed = tokens >= 1.0
53
+ h[client] = { tokens: allowed ? tokens - 1.0 : tokens, at: mono }.freeze
54
+ end
55
+ return [:rate_limited] unless allowed
56
+
57
+ # 2. Idempotency: claim the id or find it done. The claim is atomic; the
58
+ # handler itself runs outside the lock, so the section stays short.
59
+ # `mine` is decided INSIDE the synchronize: reading :claimed back out
60
+ # is not the same thing as having claimed it -- the first draft of this
61
+ # example confused the two, and two racing retries both executed.
62
+ mine = false
63
+ claim = nil
64
+ gw[:idem].synchronize do |h|
65
+ if h.key?(id)
66
+ claim = h[id]
67
+ else
68
+ h[id] = :claimed
69
+ mine = true
70
+ end
71
+ end
72
+ unless mine
73
+ if claim.is_a?(Symbol) # somebody is on it: wait for them
74
+ 200.times do
75
+ done = gw[:idem][id]
76
+ return [:cached, done] unless done.is_a?(Symbol)
77
+ sleep 0.001
78
+ end
79
+ abort "idempotency claim never resolved for #{id}"
80
+ end
81
+ return [:cached, claim] # already done: same answer again
82
+ end
83
+
84
+ # 3. Circuit breaker: decide under the lock, call outside it, record under it.
85
+ state = gw[:breaker].update do |b|
86
+ if b[:state] == :open && mono - b[:opened_at] >= COOLDOWN
87
+ b.merge(state: :half_open).freeze # one probe may pass
88
+ else
89
+ b
90
+ end
91
+ end
92
+ if state[:state] == :open
93
+ gw[:idem].synchronize { |h| h.delete(id) } # release the claim: not handled
94
+ return [:circuit_open]
95
+ end
96
+
97
+ resp = gw[:upstream].call(id)
98
+
99
+ gw[:breaker].update do |b|
100
+ if resp == :error
101
+ f = b[:failures] + 1
102
+ if f >= THRESHOLD && b[:state] != :open
103
+ gw[:audit].event(:breaker_opened) # runs once: updates never rerun
104
+ { state: :open, failures: f, opened_at: mono }.freeze
105
+ else
106
+ b.merge(failures: f).freeze
107
+ end
108
+ else
109
+ gw[:audit].event(:breaker_closed) if b[:state] == :half_open
110
+ { state: :closed, failures: 0, opened_at: 0.0 }.freeze
111
+ end
112
+ end
113
+
114
+ if resp == :error
115
+ gw[:idem].synchronize { |h| h.delete(id) } # failed calls may be retried
116
+ [:upstream_error]
117
+ else
118
+ gw[:idem].synchronize { |h| h[id] = resp }
119
+ [:ok, resp]
120
+ end
121
+ end
122
+
123
+ GW = Ractor.make_shareable({
124
+ buckets: Ractor::LockHash.new,
125
+ idem: Ractor::LockHash.new,
126
+ breaker: Ractor::LockVar.new({ state: :closed, failures: 0, opened_at: 0.0 }.freeze),
127
+ upstream: Upstream.new,
128
+ audit: Audit.new
129
+ })
130
+
131
+ # --- act 1: the polite and the greedy --------------------------------------
132
+ polite, greedy = %w[polite greedy].map do |who|
133
+ Ractor.new(GW, who) do |gw, me|
134
+ n = me == "polite" ? CAPACITY : CAPACITY * 3
135
+ n.times.map { |i| gateway_call(gw, me, "#{me}-#{i}").first }.tally
136
+ end
137
+ end
138
+ p_tally, g_tally = polite.value, greedy.value
139
+ abort "polite got limited: #{p_tally}" unless p_tally == { ok: CAPACITY }
140
+ abort "greedy was not limited: #{g_tally}" unless g_tally[:rate_limited] &&
141
+ g_tally[:ok] <= CAPACITY + 3
142
+
143
+ # --- act 2: at-most-once under retries --------------------------------------
144
+ retriers = 3.times.map do |i|
145
+ Ractor.new(GW, i) do |gw, me|
146
+ # Ten ids SHARED by all three clients, each sent twice: 60 requests, and
147
+ # the upstream must see each id exactly once.
148
+ 2.times.flat_map { (0...10).map { |k| gateway_call(gw, "retrier#{me}", "order-#{k}") } }
149
+ end
150
+ end
151
+ responses = retriers.flat_map(&:value)
152
+ hits = GW[:upstream].hits
153
+ (0...10).each do |k|
154
+ abort "order-#{k} executed #{hits["order-#{k}"]} times" unless hits["order-#{k}"] == 1
155
+ answers = responses.select { |_, r| r == "resp-order-#{k}" }
156
+ abort "order-#{k}: divergent answers" unless answers.size == 6 # 3 clients x 2 tries
157
+ end
158
+
159
+ # --- act 3: the upstream falls over ------------------------------------------
160
+ GW[:upstream].set_down(true)
161
+ storm = Ractor.new(GW) do |gw|
162
+ 40.times.map { |i| gateway_call(gw, "monitor#{i % 8}", "storm-#{i}").first }.tally
163
+ end.value
164
+ abort "breaker never rejected fast: #{storm}" unless storm[:circuit_open]&.positive?
165
+ storm_hits = GW[:upstream].hits.count { |k, _| k.start_with?("storm-") }
166
+ abort "breaker let the storm through: #{storm_hits} upstream calls" if storm_hits > THRESHOLD + 3
167
+
168
+ GW[:upstream].set_down(false)
169
+ sleep COOLDOWN * 1.5
170
+ probe = gateway_call(GW, "prober", "probe-1")
171
+ abort "did not recover: #{probe}" unless probe.first == :ok
172
+
173
+ events = GW[:audit].events
174
+ abort "opened #{events.count(:breaker_opened)} times" unless events.count(:breaker_opened) >= 1
175
+ abort "never announced recovery" unless events.last == :breaker_closed
176
+ abort "breaker not closed: #{GW[:breaker].value}" unless GW[:breaker].value[:state] == :closed
177
+
178
+ puts "ok: greedy limited to #{g_tally[:ok]}/#{CAPACITY * 3}; 10 shared ids -> 10 upstream calls " \
179
+ "for 60 requests; storm of 40 hit upstream #{storm_hits} times, breaker " \
180
+ "#{events.tally.map { |e, n| "#{e} x#{n}" }.join(', ')}"
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # A cache backend on KeyLockHash, and the reason it is one line: update(key)
4
+ # holds THAT KEY's lock while the block runs, so when eight workers miss the
5
+ # same key at once, exactly one computes and seven wait a few ms and read --
6
+ # the dog-pile (cache stampede) problem, solved by the locking shape itself.
7
+ # The price is the same shape: a slow compute blocks that key, and only that
8
+ # key. Real cache backends make exactly this trade.
9
+ #
10
+ # ruby -Ilib examples/15_cache_backend.rb
11
+ Warning[:experimental] = false
12
+ require "ractor/sharing"
13
+
14
+ CACHE = Ractor::KeyLockHash.new
15
+
16
+ def fetch_fragment(key)
17
+ computed = false
18
+ html = CACHE.update(key) do |cached|
19
+ cached || begin
20
+ computed = true
21
+ sleep 0.02 # an expensive render, allegedly
22
+ "<div id=#{key}>rendered</div>"
23
+ end
24
+ end
25
+ [html, computed]
26
+ end
27
+
28
+ workers = 8.times.map do |i|
29
+ Ractor.new(i) do |id|
30
+ renders = 0
31
+ hits = 0
32
+ 24.times do |n|
33
+ key = "fragment-#{(n + id) % 6}" # eight workers, six hot keys
34
+ html, computed = fetch_fragment(key)
35
+ abort "wrong fragment" unless html.include?(key)
36
+ computed ? renders += 1 : hits += 1
37
+ end
38
+ [renders, hits]
39
+ end
40
+ end
41
+
42
+ renders = workers.map(&:value)
43
+ total_renders = renders.sum(&:first)
44
+ total_hits = renders.sum(&:last)
45
+ abort "dog-pile: #{total_renders} renders for 6 keys" unless total_renders == 6
46
+ abort "misplaced arithmetic" unless total_renders + total_hits == 8 * 24
47
+
48
+ CACHE.delete("fragment-0") # invalidation is just delete
49
+ _, recomputed = fetch_fragment("fragment-0")
50
+ abort "invalidation did not take" unless recomputed
51
+
52
+ puts "ok: 192 fetches, 6 renders (one per key, stampede absorbed), " \
53
+ "#{total_hits} lock-protected hits; invalidate + refetch rendered once more"
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # A session store with "log out everywhere" -- the LockHash shape as it
4
+ # actually occurs in a web backend. Two directions live in one hash:
5
+ #
6
+ # "sid:..." => user the session token a request authenticates with
7
+ # "user:..." => [sids] the index that makes logout-all possible
8
+ #
9
+ # Login writes both directions; logout-all deletes N sessions AND the index.
10
+ # Each is one synchronize, because the gap matters: revoke sessions one by one
11
+ # and a racing request can still authenticate with a not-yet-deleted token
12
+ # while the account believes it logged out. The auditor checks the two
13
+ # directions agree in every snapshot it ever takes.
14
+ #
15
+ # ruby -Ilib examples/16_session_store.rb
16
+ Warning[:experimental] = false
17
+ require "ractor/sharing"
18
+
19
+ STORE = Ractor::LockHash.new
20
+
21
+ def login(store, user, device)
22
+ sid = "sid:#{user}:#{device}"
23
+ store.synchronize do |h|
24
+ h[sid] = user
25
+ h["user:#{user}"] = (h["user:#{user}"] || []) + [sid]
26
+ end
27
+ sid
28
+ end
29
+
30
+ def authenticate(store, sid) = store[sid]
31
+
32
+ def logout_everywhere(store, user)
33
+ store.synchronize do |h|
34
+ (h["user:#{user}"] || []).each { |sid| h.delete(sid) }
35
+ h.delete("user:#{user}")
36
+ end
37
+ end
38
+
39
+ workers = %w[ann ben cho dee].map do |user|
40
+ Ractor.new(STORE, user) do |store, me|
41
+ authed = 0
42
+ 5.times do |round|
43
+ sids = 4.times.map { |d| login(store, me, "device#{round}-#{d}") }
44
+ sids.each { |sid| authed += 1 if authenticate(store, sid) == me }
45
+ logout_everywhere(store, me)
46
+ abort "a token survived logout-all" if sids.any? { |sid| authenticate(store, sid) }
47
+ end
48
+ authed
49
+ end
50
+ end
51
+
52
+ # The auditor never catches the store between the two directions: every session
53
+ # it sees is listed in its user's index, and every listed session exists.
54
+ auditor = Ractor.new(STORE) do |store|
55
+ 400.times.count do
56
+ snap = store.to_h
57
+ snap.each do |k, v|
58
+ next unless k.start_with?("sid:")
59
+ abort "session #{k} not in its index" unless (snap["user:#{v}"] || []).include?(k)
60
+ end
61
+ snap.each do |k, sids|
62
+ next unless k.start_with?("user:")
63
+ sids.each { |sid| abort "index lists a dead session #{sid}" unless snap.key?(sid) }
64
+ end
65
+ true
66
+ end
67
+ end
68
+
69
+ audits = auditor.value
70
+ authed = workers.sum(&:value)
71
+ abort "sessions leaked: #{STORE.to_h}" unless STORE.to_h.empty?
72
+ abort "logins failed to authenticate" unless authed == 4 * 5 * 4
73
+ puts "ok: #{authed} authentications, 20 logout-everywheres, store empty at the end; " \
74
+ "#{audits} audits and the two directions never disagreed"
@@ -0,0 +1,35 @@
1
+ # Examples
2
+
3
+ Each file runs on its own and checks its own result: it prints one `ok:` line
4
+ or aborts. From a checkout:
5
+
6
+ ```
7
+ ruby -Ilib examples/01_bank_transfer.rb
8
+ ```
9
+
10
+ | file | class | the situation |
11
+ |---|---|---|
12
+ | [01_bank_transfer.rb](01_bank_transfer.rb) | `TVar` | money moves between accounts; an auditor never catches the total wrong |
13
+ | [02_seat_booking.rb](02_seat_booking.rb) | `TVar` | booking adjacent seat pairs; the with-locks version of this deadlocks |
14
+ | [03_feature_flags.rb](03_feature_flags.rb) | `TVar` | read-mostly config: flags read constantly, flipped rarely, no lock on the read |
15
+ | [04_progress.rb](04_progress.rb) | `LockVar` | workers count up, the main Ractor peeks whenever it redraws |
16
+ | [05_exactly_once.rb](05_exactly_once.rb) | `LockVar` | the side effect that a TVar retry would repeat, counted both ways |
17
+ | [06_metrics_board.rb](06_metrics_board.rb) | `LockHash` | per-endpoint records and a global total that agree in every snapshot |
18
+ | [07_word_count.rb](07_word_count.rb) | `ActorHash` | map-reduce whose reduce side mutates tallies in place |
19
+ | [08_lru_cache.rb](08_lru_cache.rb) | `ActiveObject` | an LRU cache: a Hash plus an eviction order nobody wants to freeze |
20
+ | [09_audit_log.rb](09_audit_log.rb) | `ActiveObject` | fire-and-forget logging with `async`, strictly ordered by the owner |
21
+ | [10_price_quotes.rb](10_price_quotes.rb) | `ActiveObject` | fan out `future` calls, gather the answers later |
22
+
23
+ Two of them are small applications rather than single tricks:
24
+
25
+ | file | classes | the application |
26
+ |---|---|---|
27
+ | [11_webshop.rb](11_webshop.rb) | `TVar` + `LockHash` + `ActiveObject` | a shop: carts reserved all-or-nothing, a mid-run sale, books that always balance, an audit log |
28
+ | [12_kvstore_wal.rb](12_kvstore_wal.rb) | `ActiveObject` | a crash-safe KV store: write-ahead log on disk, crash, replay, identical state |
29
+ | [13_buffered_logger.rb](13_buffered_logger.rb) | `ActiveObject` | a batching file logger: buffer, high-water mark and flush clock are just ivars |
30
+ | [14_api_gateway.rb](14_api_gateway.rb) | `LockHash` + `LockVar` + `ActiveObject` | an API gateway: token buckets, idempotency keys, a circuit breaker that announces each transition exactly once |
31
+ | [15_cache_backend.rb](15_cache_backend.rb) | `KeyLockHash` | a cache backend: get-or-create per key, dog-piles absorbed by the key lock itself |
32
+ | [16_session_store.rb](16_session_store.rb) | `LockHash` | a session store with "log out everywhere": token and index move together, or a revoked token still authenticates |
33
+
34
+ The [test suite](../test/examples_test.rb) runs every one of them, so they
35
+ cannot rot.