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.
- checksums.yaml +4 -4
- data/README.md +204 -36
- data/docs/keylockhash.md +141 -0
- data/docs/lockhash.md +39 -15
- data/docs/lockvar.md +26 -23
- data/docs/tvar.md +53 -10
- data/examples/01_bank_transfer.rb +43 -0
- data/examples/02_seat_booking.rb +40 -0
- data/examples/03_feature_flags.rb +30 -0
- data/examples/04_progress.rb +33 -0
- data/examples/05_exactly_once.rb +38 -0
- data/examples/06_metrics_board.rb +47 -0
- data/examples/07_word_count.rb +36 -0
- data/examples/08_lru_cache.rb +59 -0
- data/examples/09_audit_log.rb +31 -0
- data/examples/10_price_quotes.rb +36 -0
- data/examples/11_webshop.rb +104 -0
- data/examples/12_kvstore_wal.rb +84 -0
- data/examples/13_buffered_logger.rb +85 -0
- data/examples/14_api_gateway.rb +180 -0
- data/examples/15_cache_backend.rb +53 -0
- data/examples/16_session_store.rb +74 -0
- data/examples/README.md +35 -0
- data/ext/ractor/lock/keylockhash.c +445 -0
- data/ext/ractor/lock/lock.c +35 -0
- data/ext/ractor/lock/lock.h +10 -0
- data/ext/ractor/lock/lockhash.c +4 -2
- data/ext/ractor/lock/lockvar.c +4 -25
- data/ext/ractor/tvar/tvar.c +17 -13
- data/lib/ractor/keylockhash.rb +3 -0
- data/lib/ractor/sharing/version.rb +1 -1
- data/lib/ractor/sharing.rb +3 -0
- metadata +21 -1
|
@@ -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"
|
data/examples/README.md
ADDED
|
@@ -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.
|