ask-session 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +220 -0
- data/lib/ask/session/codec.rb +55 -0
- data/lib/ask/session/event.rb +85 -0
- data/lib/ask/session/host.rb +162 -0
- data/lib/ask/session/provider_store.rb +316 -0
- data/lib/ask/session/record.rb +95 -0
- data/lib/ask/session/sink.rb +189 -0
- data/lib/ask/session/state.rb +54 -0
- data/lib/ask/session/store.rb +154 -0
- data/lib/ask/session/subscription.rb +56 -0
- data/lib/ask/session/version.rb +7 -0
- data/lib/ask/session.rb +28 -0
- data/lib/ask-session.rb +3 -0
- metadata +90 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Session
|
|
5
|
+
class Store
|
|
6
|
+
def initialize
|
|
7
|
+
@sessions = {}
|
|
8
|
+
@events = {}
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create(id: nil, status: :active, metadata: {}, created_at: nil)
|
|
13
|
+
record = Record.create(id: id, status: status, metadata: metadata, created_at: created_at)
|
|
14
|
+
@mutex.synchronize do
|
|
15
|
+
if @sessions.key?(record.id)
|
|
16
|
+
raise DuplicateSessionError, "Session already exists: #{record.id}"
|
|
17
|
+
end
|
|
18
|
+
@sessions[record.id] = record
|
|
19
|
+
@events[record.id] = []
|
|
20
|
+
end
|
|
21
|
+
record
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def load(id)
|
|
25
|
+
@mutex.synchronize { @sessions[id] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def load!(id)
|
|
29
|
+
load(id) || raise(NotFoundError, "Session not found: #{id}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list
|
|
33
|
+
@mutex.synchronize { @sessions.values.dup }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def append_event(event, expected_sequence:)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
session_events = @events[event.session_id]
|
|
39
|
+
raise NotFoundError, "Session not found: #{event.session_id}" unless session_events
|
|
40
|
+
|
|
41
|
+
current_seq = session_events.size
|
|
42
|
+
unless current_seq == expected_sequence
|
|
43
|
+
raise ConcurrencyError,
|
|
44
|
+
"Expected sequence #{expected_sequence} but got #{current_seq} for session #{event.session_id}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless event.seq == current_seq + 1
|
|
48
|
+
raise ConcurrencyError,
|
|
49
|
+
"Event seq #{event.seq} does not match expected next sequence #{current_seq + 1} for session #{event.session_id}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
session_events << event
|
|
53
|
+
end
|
|
54
|
+
event
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def state(session_id)
|
|
58
|
+
State.reduce(session_id, events(session_id))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def events_after(session_id, after_seq:)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
session_events = @events[session_id]
|
|
64
|
+
raise NotFoundError, "Session not found: #{session_id}" unless session_events
|
|
65
|
+
|
|
66
|
+
session_events.select { |e| e.seq > after_seq }.freeze
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def current_sequence(session_id)
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
session_events = @events[session_id]
|
|
73
|
+
raise NotFoundError, "Session not found: #{session_id}" unless session_events
|
|
74
|
+
|
|
75
|
+
session_events.size
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def events(session_id)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
session_events = @events[session_id]
|
|
82
|
+
raise NotFoundError, "Session not found: #{session_id}" unless session_events
|
|
83
|
+
|
|
84
|
+
session_events.dup.freeze
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def export(session_id = nil)
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
if session_id
|
|
91
|
+
record = @sessions[session_id]
|
|
92
|
+
raise NotFoundError, "Session not found: #{session_id}" unless record
|
|
93
|
+
|
|
94
|
+
session_events = @events[session_id] || []
|
|
95
|
+
{
|
|
96
|
+
sessions: [record.to_h],
|
|
97
|
+
events: session_events.map(&:to_h)
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
{
|
|
101
|
+
sessions: @sessions.values.map(&:to_h),
|
|
102
|
+
events: @events.values.flatten.map(&:to_h)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def import(data)
|
|
109
|
+
sessions = data[:sessions] || data["sessions"] || []
|
|
110
|
+
events = data[:events] || data["events"] || []
|
|
111
|
+
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
pending_sessions = {}
|
|
114
|
+
pending_events = {}
|
|
115
|
+
|
|
116
|
+
sessions.each do |s|
|
|
117
|
+
id = s[:id] || s["id"]
|
|
118
|
+
raise SerializationError, "Session record missing id" unless id && !id.to_s.empty?
|
|
119
|
+
raise DuplicateSessionError, "Session already exists: #{id}" if @sessions.key?(id)
|
|
120
|
+
raise DuplicateSessionError, "Duplicate session in import data: #{id}" if pending_sessions.key?(id)
|
|
121
|
+
|
|
122
|
+
record = Record.from_h(s)
|
|
123
|
+
pending_sessions[record.id] = record
|
|
124
|
+
pending_events[record.id] = []
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
events.each do |e|
|
|
128
|
+
session_id = e[:session_id] || e["session_id"]
|
|
129
|
+
unless pending_events.key?(session_id) || @events.key?(session_id)
|
|
130
|
+
raise NotFoundError, "Session not found: #{session_id}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
event = Event.from_h(e)
|
|
134
|
+
target = pending_events.key?(session_id) ? pending_events : @events
|
|
135
|
+
session_events = target[session_id]
|
|
136
|
+
expected_seq = session_events.size
|
|
137
|
+
|
|
138
|
+
unless event.seq == expected_seq + 1
|
|
139
|
+
raise ConcurrencyError,
|
|
140
|
+
"Expected sequence #{expected_seq + 1} but got #{event.seq} for session #{session_id}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
session_events << event
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
pending_sessions.each do |id, record|
|
|
147
|
+
@sessions[id] = record
|
|
148
|
+
@events[id] = pending_events[id]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Session
|
|
7
|
+
class Subscription
|
|
8
|
+
SENTINEL = Object.new
|
|
9
|
+
|
|
10
|
+
attr_reader :id, :session_id
|
|
11
|
+
|
|
12
|
+
def initialize(id:, session_id:)
|
|
13
|
+
@id = id
|
|
14
|
+
@session_id = session_id
|
|
15
|
+
@queue = Queue.new
|
|
16
|
+
@closed = false
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def closed?
|
|
21
|
+
@mutex.synchronize { @closed }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def close
|
|
25
|
+
@mutex.synchronize { @closed = true }
|
|
26
|
+
@queue.push(SENTINEL)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def next(timeout: nil)
|
|
30
|
+
return nil if closed?
|
|
31
|
+
|
|
32
|
+
if timeout
|
|
33
|
+
val = Timeout.timeout(timeout) { @queue.pop }
|
|
34
|
+
val.equal?(SENTINEL) ? nil : val
|
|
35
|
+
else
|
|
36
|
+
val = @queue.pop
|
|
37
|
+
val.equal?(SENTINEL) ? nil : val
|
|
38
|
+
end
|
|
39
|
+
rescue Timeout::Error
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
alias_method :wait, :next
|
|
44
|
+
|
|
45
|
+
def each
|
|
46
|
+
while (event = self.next)
|
|
47
|
+
yield event
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def enqueue(event)
|
|
52
|
+
@queue.push(event) unless closed?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/ask/session.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
require_relative "session/version"
|
|
8
|
+
require_relative "session/record"
|
|
9
|
+
require_relative "session/event"
|
|
10
|
+
require_relative "session/store"
|
|
11
|
+
require_relative "session/provider_store"
|
|
12
|
+
require_relative "session/state"
|
|
13
|
+
require_relative "session/codec"
|
|
14
|
+
require_relative "session/subscription"
|
|
15
|
+
require_relative "session/host"
|
|
16
|
+
require_relative "session/sink"
|
|
17
|
+
|
|
18
|
+
module Ask
|
|
19
|
+
module Session
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
class ConcurrencyError < Error; end
|
|
22
|
+
class NotFoundError < Error; end
|
|
23
|
+
class DuplicateSessionError < Error; end
|
|
24
|
+
class SerializationError < Error; end
|
|
25
|
+
class InvalidTransitionError < Error; end
|
|
26
|
+
class SessionMismatchError < Error; end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/ask-session.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ask-session
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kaka Ruto
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.25'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.25'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: Provides immutable session records, event envelopes, an in-memory store
|
|
41
|
+
with concurrency guardrails, a durable provider-backed store for restart-safe sessions,
|
|
42
|
+
a replayable host with subscriptions, a runtime tool-lifecycle sink, and a state
|
|
43
|
+
reducer that reconstructs session snapshots from event history.
|
|
44
|
+
email:
|
|
45
|
+
- kaka@myrrlabs.com
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE
|
|
52
|
+
- README.md
|
|
53
|
+
- lib/ask-session.rb
|
|
54
|
+
- lib/ask/session.rb
|
|
55
|
+
- lib/ask/session/codec.rb
|
|
56
|
+
- lib/ask/session/event.rb
|
|
57
|
+
- lib/ask/session/host.rb
|
|
58
|
+
- lib/ask/session/provider_store.rb
|
|
59
|
+
- lib/ask/session/record.rb
|
|
60
|
+
- lib/ask/session/sink.rb
|
|
61
|
+
- lib/ask/session/state.rb
|
|
62
|
+
- lib/ask/session/store.rb
|
|
63
|
+
- lib/ask/session/subscription.rb
|
|
64
|
+
- lib/ask/session/version.rb
|
|
65
|
+
homepage: https://github.com/ask-rb/ask-session
|
|
66
|
+
licenses:
|
|
67
|
+
- MIT
|
|
68
|
+
metadata:
|
|
69
|
+
homepage_uri: https://github.com/ask-rb/ask-session
|
|
70
|
+
source_code_uri: https://github.com/ask-rb/ask-session
|
|
71
|
+
changelog_uri: https://github.com/ask-rb/ask-session/blob/master/CHANGELOG.md
|
|
72
|
+
rubygems_mfa_required: 'true'
|
|
73
|
+
rdoc_options: []
|
|
74
|
+
require_paths:
|
|
75
|
+
- lib
|
|
76
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '3.2'
|
|
81
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '0'
|
|
86
|
+
requirements: []
|
|
87
|
+
rubygems_version: 4.0.18
|
|
88
|
+
specification_version: 4
|
|
89
|
+
summary: Event-sourced session state for the ask-rb ecosystem.
|
|
90
|
+
test_files: []
|