tep 0.11.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/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- metadata +264 -0
data/lib/tep/presence.rb
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
# Tep::Presence -- topic-keyed who's-here registry.
|
|
2
|
+
#
|
|
3
|
+
# Battery 3 (Presence). Layers on req.identity (Battery 1) for
|
|
4
|
+
# the principal+delegate split and on the Broadcast pub-sub
|
|
5
|
+
# surface (Battery 2) for diff fan-out -- though in chunk 3.1
|
|
6
|
+
# diff broadcasting isn't wired yet; apps that want presence
|
|
7
|
+
# diffs build them on top of list() snapshots for now.
|
|
8
|
+
#
|
|
9
|
+
# Tracking model: one PresenceEntry per (principal, session,
|
|
10
|
+
# topic). `fd` doubles as the session-id surrogate, so a human
|
|
11
|
+
# with three browser tabs lands as three entries with kind=:human
|
|
12
|
+
# and three distinct fds. An agent acting on behalf of that
|
|
13
|
+
# human gets its own entry with kind=:agent_for and the agent_id
|
|
14
|
+
# populated -- four entries, one principal, one topic. This is
|
|
15
|
+
# the Phoenix.Presence shape extended with the agentic kind/
|
|
16
|
+
# agent_id pair.
|
|
17
|
+
#
|
|
18
|
+
# Local storage is per-process (Tep::APP) for the fast list /
|
|
19
|
+
# count read path. Cross-worker visibility goes through PG --
|
|
20
|
+
# Tep::Presence.enable_pg_mirror writes each track/untrack/
|
|
21
|
+
# set_status as an UPSERT/DELETE; list_global pulls the union.
|
|
22
|
+
# Apps that don't need cross-worker snapshots run single-worker
|
|
23
|
+
# or skip the mirror entirely.
|
|
24
|
+
#
|
|
25
|
+
# Status handling: every entry carries a Tep::PresenceStatus
|
|
26
|
+
# inline (status_state / status_note / status_until on the
|
|
27
|
+
# entry). track() initializes status to :available; apps update
|
|
28
|
+
# via set_status / clear_status. Expiry (status_until) isn't
|
|
29
|
+
# auto-swept in chunk 3.1; the next chunk's diff loop will
|
|
30
|
+
# handle reset-on-expire alongside emit.
|
|
31
|
+
module Tep
|
|
32
|
+
module Presence
|
|
33
|
+
# Track a presence entry. principal_id comes off req.identity;
|
|
34
|
+
# kind is :human or :agent_for depending on the identity's
|
|
35
|
+
# delegation state. fd is the underlying connection's socket
|
|
36
|
+
# (typically a WS-accepted fd). Returns 0 on success.
|
|
37
|
+
#
|
|
38
|
+
# Multiple track() calls for the same (principal, topic, fd)
|
|
39
|
+
# are deduped: the existing entry stays, no second row is
|
|
40
|
+
# created. Apps can call freely from before-filters /
|
|
41
|
+
# reconnect paths without growing the registry.
|
|
42
|
+
def self.track(req, topic, fd)
|
|
43
|
+
ident = req.identity
|
|
44
|
+
if Tep::Presence.find_entry(topic, fd) != nil
|
|
45
|
+
return 0
|
|
46
|
+
end
|
|
47
|
+
kind = :human
|
|
48
|
+
agent_id = ""
|
|
49
|
+
if ident.agent?
|
|
50
|
+
kind = :agent_for
|
|
51
|
+
agent_id = ident.acting_via.agent_id
|
|
52
|
+
end
|
|
53
|
+
entry = Tep::PresenceEntry.new(
|
|
54
|
+
topic, ident.principal_id, kind, agent_id, fd, Time.now.to_i)
|
|
55
|
+
Tep::APP.presence_entries.push(entry)
|
|
56
|
+
Tep::Presence.mirror_insert(entry)
|
|
57
|
+
Tep::Presence.publish_diff("join", entry)
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Drop the entry for (topic, fd). The fd is the unique key
|
|
62
|
+
# within a topic; principal_id isn't needed. Returns 1 if an
|
|
63
|
+
# entry was removed, 0 if none matched. Emits a "leave" diff
|
|
64
|
+
# on the topic's presence channel when removal happens.
|
|
65
|
+
def self.untrack(topic, fd)
|
|
66
|
+
entries = Tep::APP.presence_entries
|
|
67
|
+
i = 0
|
|
68
|
+
while i < entries.length
|
|
69
|
+
if entries[i].topic == topic && entries[i].fd == fd
|
|
70
|
+
e = entries[i]
|
|
71
|
+
entries.delete_at(i)
|
|
72
|
+
Tep::Presence.mirror_delete(topic, fd)
|
|
73
|
+
Tep::Presence.publish_diff("leave", e)
|
|
74
|
+
return 1
|
|
75
|
+
end
|
|
76
|
+
i += 1
|
|
77
|
+
end
|
|
78
|
+
0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Drop every entry associated with `fd` (across all topics).
|
|
82
|
+
# Used by the WS close hook to clean up everything a
|
|
83
|
+
# connection had tracked. Returns the count dropped. Emits
|
|
84
|
+
# one "leave" diff per dropped entry, on each entry's topic's
|
|
85
|
+
# presence channel.
|
|
86
|
+
def self.untrack_by_fd(fd)
|
|
87
|
+
entries = Tep::APP.presence_entries
|
|
88
|
+
dropped = 0
|
|
89
|
+
i = entries.length - 1
|
|
90
|
+
while i >= 0
|
|
91
|
+
if entries[i].fd == fd
|
|
92
|
+
e = entries[i]
|
|
93
|
+
entries.delete_at(i)
|
|
94
|
+
Tep::Presence.mirror_delete(e.topic, fd)
|
|
95
|
+
Tep::Presence.publish_diff("leave", e)
|
|
96
|
+
dropped += 1
|
|
97
|
+
end
|
|
98
|
+
i -= 1
|
|
99
|
+
end
|
|
100
|
+
dropped
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# All entries for `topic`. Caller groups by principal_id when
|
|
104
|
+
# they want the Phoenix.Presence-style {principal => [metas]}
|
|
105
|
+
# shape; tep doesn't pre-group because spinel's nested-hash
|
|
106
|
+
# lowering is awkward.
|
|
107
|
+
def self.list(topic)
|
|
108
|
+
result = [Tep::PresenceEntry.new("", "", :human, "", -1, 0)]
|
|
109
|
+
result.delete_at(0)
|
|
110
|
+
entries = Tep::APP.presence_entries
|
|
111
|
+
i = 0
|
|
112
|
+
while i < entries.length
|
|
113
|
+
if entries[i].topic == topic
|
|
114
|
+
result.push(entries[i])
|
|
115
|
+
end
|
|
116
|
+
i += 1
|
|
117
|
+
end
|
|
118
|
+
result
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Total entries for `topic` (across all kinds).
|
|
122
|
+
def self.count(topic)
|
|
123
|
+
Tep::Presence.count_filtered(topic, :both)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.count_humans(topic)
|
|
127
|
+
Tep::Presence.count_filtered(topic, :human)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def self.count_agents(topic)
|
|
131
|
+
Tep::Presence.count_filtered(topic, :agent_for)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Internal counting helper: `kind_filter` is :both for all
|
|
135
|
+
# entries, otherwise :human or :agent_for to filter.
|
|
136
|
+
def self.count_filtered(topic, kind_filter)
|
|
137
|
+
entries = Tep::APP.presence_entries
|
|
138
|
+
n = 0
|
|
139
|
+
i = 0
|
|
140
|
+
while i < entries.length
|
|
141
|
+
if entries[i].topic == topic
|
|
142
|
+
if kind_filter == :both
|
|
143
|
+
n += 1
|
|
144
|
+
elsif entries[i].kind == kind_filter
|
|
145
|
+
n += 1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
i += 1
|
|
149
|
+
end
|
|
150
|
+
n
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Set the structured status on an existing entry. `state` ∈
|
|
154
|
+
# {:available, :busy, :blocked}; `note` is free text (~140
|
|
155
|
+
# char soft hint); `until_ts` is unix epoch seconds (0 = no
|
|
156
|
+
# identity-level expiry). Returns 1 if the entry was found
|
|
157
|
+
# and updated, 0 otherwise. Emits a "status" diff on the
|
|
158
|
+
# topic's presence channel on update.
|
|
159
|
+
def self.set_status(topic, fd, state, note, until_ts)
|
|
160
|
+
entry = Tep::Presence.find_entry(topic, fd)
|
|
161
|
+
if entry == nil
|
|
162
|
+
return 0
|
|
163
|
+
end
|
|
164
|
+
entry.status_state = state
|
|
165
|
+
entry.status_note = note
|
|
166
|
+
entry.status_until = until_ts
|
|
167
|
+
Tep::Presence.mirror_status(topic, fd, state, note, until_ts)
|
|
168
|
+
Tep::Presence.publish_diff("status", entry)
|
|
169
|
+
1
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Reset an entry's status back to :available / "" / 0.
|
|
173
|
+
def self.clear_status(topic, fd)
|
|
174
|
+
Tep::Presence.set_status(topic, fd, :available, "", 0)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Internal: find the entry matching (topic, fd). Returns nil
|
|
178
|
+
# if no match.
|
|
179
|
+
def self.find_entry(topic, fd)
|
|
180
|
+
entries = Tep::APP.presence_entries
|
|
181
|
+
i = 0
|
|
182
|
+
while i < entries.length
|
|
183
|
+
if entries[i].topic == topic && entries[i].fd == fd
|
|
184
|
+
return entries[i]
|
|
185
|
+
end
|
|
186
|
+
i += 1
|
|
187
|
+
end
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Drop every entry. Used by tests between fixtures and
|
|
192
|
+
# available to apps for graceful-shutdown cleanup. Returns
|
|
193
|
+
# the count dropped. Does NOT emit leave diffs (it's a
|
|
194
|
+
# registry-management op, not a per-connection event).
|
|
195
|
+
def self.clear
|
|
196
|
+
entries = Tep::APP.presence_entries
|
|
197
|
+
n = entries.length
|
|
198
|
+
while entries.length > 0
|
|
199
|
+
entries.delete_at(0)
|
|
200
|
+
end
|
|
201
|
+
n
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ---- Diff broadcasting + auto-expiry ----
|
|
205
|
+
|
|
206
|
+
# Compose the Broadcast topic for diff fan-out on a presence
|
|
207
|
+
# topic. WS subscribers register via
|
|
208
|
+
# Tep::Broadcast.subscribe_ws(diff_topic("room:lobby"), ws_fd).
|
|
209
|
+
def self.diff_topic(topic)
|
|
210
|
+
"presence:" + topic
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Flat-JSON wire format for a diff event. `kind` is one of
|
|
214
|
+
# "join" / "leave" / "status". Tep::Json's flat-object
|
|
215
|
+
# extractors handle this on the client side (or any
|
|
216
|
+
# JSON-aware peer).
|
|
217
|
+
def self.encode_diff(kind, entry)
|
|
218
|
+
"{" +
|
|
219
|
+
Tep::Json.encode_pair_str("kind", kind) + "," +
|
|
220
|
+
Tep::Json.encode_pair_str("topic", entry.topic) + "," +
|
|
221
|
+
Tep::Json.encode_pair_str("principal", entry.principal_id) + "," +
|
|
222
|
+
Tep::Json.encode_pair_str("ekind", entry.kind.to_s) + "," +
|
|
223
|
+
Tep::Json.encode_pair_str("agent_id", entry.agent_id) + "," +
|
|
224
|
+
Tep::Json.encode_pair_int("fd", entry.fd) + "," +
|
|
225
|
+
Tep::Json.encode_pair_int("since", entry.since) + "," +
|
|
226
|
+
Tep::Json.encode_pair_str("state", entry.status_state.to_s) + "," +
|
|
227
|
+
Tep::Json.encode_pair_str("note", entry.status_note) + "," +
|
|
228
|
+
Tep::Json.encode_pair_int("until_ts", entry.status_until) +
|
|
229
|
+
"}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Publish a diff via Tep::Broadcast. Subscribers to
|
|
233
|
+
# diff_topic(entry.topic) -- typically WS connections via
|
|
234
|
+
# subscribe_ws -- receive the encoded JSON payload as their
|
|
235
|
+
# next message. Returns the local-match count from publish
|
|
236
|
+
# (cross-worker delivery counts aren't tracked here, same
|
|
237
|
+
# as Broadcast.publish's documented behavior).
|
|
238
|
+
def self.publish_diff(kind, entry)
|
|
239
|
+
payload = Tep::Presence.encode_diff(kind, entry)
|
|
240
|
+
Tep::Broadcast.publish(
|
|
241
|
+
Tep::Presence.diff_topic(entry.topic), payload)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Sweep entries whose status_until has passed: reset to
|
|
245
|
+
# :available / "" / 0 and emit a "status" diff for each.
|
|
246
|
+
# Apps call this periodically (e.g. once per HTTP request,
|
|
247
|
+
# or in a background fiber once Scheduled is reliable).
|
|
248
|
+
# Returns the count of entries reset.
|
|
249
|
+
def self.sweep_expired_status
|
|
250
|
+
entries = Tep::APP.presence_entries
|
|
251
|
+
now = Time.now.to_i
|
|
252
|
+
swept = 0
|
|
253
|
+
i = 0
|
|
254
|
+
while i < entries.length
|
|
255
|
+
e = entries[i]
|
|
256
|
+
if e.status_until > 0 && e.status_until <= now && e.status_state != :available
|
|
257
|
+
e.status_state = :available
|
|
258
|
+
e.status_note = ""
|
|
259
|
+
e.status_until = 0
|
|
260
|
+
Tep::Presence.publish_diff("status", e)
|
|
261
|
+
swept += 1
|
|
262
|
+
end
|
|
263
|
+
i += 1
|
|
264
|
+
end
|
|
265
|
+
swept
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# ---- PG mirror (cross-worker visibility) ----
|
|
269
|
+
#
|
|
270
|
+
# Opt-in mirror of the local presence registry to a shared PG
|
|
271
|
+
# table. Each worker's track/untrack/set_status writes also
|
|
272
|
+
# touch the table; list_global / count_global read across all
|
|
273
|
+
# workers. The local registry stays the fast read path for
|
|
274
|
+
# per-worker queries (list / count); list_global is for the
|
|
275
|
+
# "who's globally in this room" snapshot that's typically a
|
|
276
|
+
# one-shot UI render.
|
|
277
|
+
#
|
|
278
|
+
# Worker ID is PID + boot epoch second so a same-PID restart
|
|
279
|
+
# doesn't alias a prior worker's stale rows. On
|
|
280
|
+
# disable_pg_mirror (or clean shutdown), this worker's rows
|
|
281
|
+
# get DELETE'd. Crashed workers leave stale rows; the
|
|
282
|
+
# heartbeat + prune_stale_workers pair below handles the
|
|
283
|
+
# garbage-collection.
|
|
284
|
+
#
|
|
285
|
+
# Returns 0 on success, -1 on connect / schema failure.
|
|
286
|
+
def self.enable_pg_mirror(conninfo)
|
|
287
|
+
conn = PG::Connection.new(conninfo)
|
|
288
|
+
if conn.pgh < 0
|
|
289
|
+
return -1
|
|
290
|
+
end
|
|
291
|
+
# exec raises PG::Error on failure now; degrade gracefully
|
|
292
|
+
# (close + return -1) rather than letting it escape the worker.
|
|
293
|
+
begin
|
|
294
|
+
r = conn.exec(Tep::Presence.schema_sql)
|
|
295
|
+
r.clear
|
|
296
|
+
# Heartbeat table for the prune-stale-workers path (#47).
|
|
297
|
+
r = conn.exec(Tep::Presence.worker_schema_sql)
|
|
298
|
+
r.clear
|
|
299
|
+
rescue PG::Error
|
|
300
|
+
conn.finish
|
|
301
|
+
return -1
|
|
302
|
+
end
|
|
303
|
+
Tep::APP.set_presence_pg_conn(conn)
|
|
304
|
+
worker_id = Sock.sphttp_getpid.to_s + "-" + Time.now.to_i.to_s
|
|
305
|
+
Tep::APP.set_presence_pg_worker_id(worker_id)
|
|
306
|
+
Tep::APP.set_presence_pg_enabled(1)
|
|
307
|
+
# Drop any rows from a prior worker that managed to leave
|
|
308
|
+
# stale entries with this same worker_id (unlikely thanks
|
|
309
|
+
# to the boot-epoch suffix, but defensive). Best-effort.
|
|
310
|
+
Tep::Presence.mirror_exec(
|
|
311
|
+
"DELETE FROM tep_presence WHERE worker_id = $1",
|
|
312
|
+
[worker_id])
|
|
313
|
+
# Register this worker's heartbeat row immediately. Apps
|
|
314
|
+
# refresh it periodically via Tep::Presence.heartbeat;
|
|
315
|
+
# prune_stale_workers deletes rows whose heartbeat is stale.
|
|
316
|
+
Tep::Presence.heartbeat
|
|
317
|
+
0
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def self.disable_pg_mirror
|
|
321
|
+
if Tep::APP.presence_pg_enabled == 0
|
|
322
|
+
return 0
|
|
323
|
+
end
|
|
324
|
+
# Best-effort cleanup -- swallow PG errors (we're tearing the
|
|
325
|
+
# mirror down regardless) and still finish + disable below.
|
|
326
|
+
begin
|
|
327
|
+
r = Tep::APP.presence_pg_conn.exec_params(
|
|
328
|
+
"DELETE FROM tep_presence WHERE worker_id = $1",
|
|
329
|
+
[Tep::APP.presence_pg_worker_id])
|
|
330
|
+
r.clear
|
|
331
|
+
# Remove the heartbeat row so prune_stale_workers doesn't
|
|
332
|
+
# see this worker as live after we're gone.
|
|
333
|
+
r = Tep::APP.presence_pg_conn.exec_params(
|
|
334
|
+
"DELETE FROM tep_presence_worker WHERE worker_id = $1",
|
|
335
|
+
[Tep::APP.presence_pg_worker_id])
|
|
336
|
+
r.clear
|
|
337
|
+
rescue PG::Error
|
|
338
|
+
# swallow -- shutting the mirror down anyway
|
|
339
|
+
end
|
|
340
|
+
Tep::APP.presence_pg_conn.finish
|
|
341
|
+
Tep::APP.set_presence_pg_enabled(0)
|
|
342
|
+
0
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# CREATE TABLE statement, kept here so apps that want to
|
|
346
|
+
# provision the schema separately (migration runners, etc.)
|
|
347
|
+
# can grab the canonical DDL. Idempotent via IF NOT EXISTS.
|
|
348
|
+
def self.schema_sql
|
|
349
|
+
"CREATE TABLE IF NOT EXISTS tep_presence (" +
|
|
350
|
+
"worker_id TEXT NOT NULL, " +
|
|
351
|
+
"topic TEXT NOT NULL, " +
|
|
352
|
+
"fd INTEGER NOT NULL, " +
|
|
353
|
+
"principal_id TEXT NOT NULL, " +
|
|
354
|
+
"kind TEXT NOT NULL, " +
|
|
355
|
+
"agent_id TEXT NOT NULL, " +
|
|
356
|
+
"since_ts BIGINT NOT NULL, " +
|
|
357
|
+
"status_state TEXT NOT NULL, " +
|
|
358
|
+
"status_note TEXT NOT NULL, " +
|
|
359
|
+
"status_until BIGINT NOT NULL, " +
|
|
360
|
+
"PRIMARY KEY (worker_id, topic, fd)" +
|
|
361
|
+
")"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Heartbeat table -- one row per worker that's mirroring
|
|
365
|
+
# presence right now. Used by prune_stale_workers to identify
|
|
366
|
+
# crashed workers (no heartbeat updates in N seconds) and
|
|
367
|
+
# garbage-collect their orphan tep_presence rows.
|
|
368
|
+
def self.worker_schema_sql
|
|
369
|
+
"CREATE TABLE IF NOT EXISTS tep_presence_worker (" +
|
|
370
|
+
"worker_id TEXT PRIMARY KEY, " +
|
|
371
|
+
"last_seen_ts BIGINT NOT NULL" +
|
|
372
|
+
")"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Refresh this worker's heartbeat row to the current Unix
|
|
376
|
+
# timestamp. Apps call this periodically (typical: from a
|
|
377
|
+
# before-filter, a Tep::Job tick, or an explicit timer fiber)
|
|
378
|
+
# so prune_stale_workers can tell live workers from crashed
|
|
379
|
+
# ones. No-op when the PG mirror isn't enabled, or when the
|
|
380
|
+
# mirror was opened on a different process and we're the
|
|
381
|
+
# post-fork child (worker_id is empty until enable_pg_mirror
|
|
382
|
+
# runs locally).
|
|
383
|
+
#
|
|
384
|
+
# Returns 1 if the heartbeat row was upserted, 0 if the call
|
|
385
|
+
# short-circuited (mirror disabled or no worker_id).
|
|
386
|
+
def self.heartbeat
|
|
387
|
+
if Tep::APP.presence_pg_enabled == 0
|
|
388
|
+
return 0
|
|
389
|
+
end
|
|
390
|
+
wid = Tep::APP.presence_pg_worker_id
|
|
391
|
+
if wid.length == 0
|
|
392
|
+
return 0
|
|
393
|
+
end
|
|
394
|
+
begin
|
|
395
|
+
r = Tep::APP.presence_pg_conn.exec_params(
|
|
396
|
+
"INSERT INTO tep_presence_worker (worker_id, last_seen_ts) " +
|
|
397
|
+
"VALUES ($1, $2) " +
|
|
398
|
+
"ON CONFLICT (worker_id) DO UPDATE SET " +
|
|
399
|
+
" last_seen_ts = EXCLUDED.last_seen_ts",
|
|
400
|
+
[wid, Time.now.to_i.to_s])
|
|
401
|
+
r.clear
|
|
402
|
+
rescue PG::Error
|
|
403
|
+
return 0
|
|
404
|
+
end
|
|
405
|
+
1
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Prune crashed-worker rows. Deletes:
|
|
409
|
+
# 1. tep_presence_worker rows whose last_seen_ts is older than
|
|
410
|
+
# ttl_seconds (the worker's heartbeat is stale).
|
|
411
|
+
# 2. tep_presence rows whose worker_id has no surviving
|
|
412
|
+
# heartbeat (orphans left by the crashed worker).
|
|
413
|
+
#
|
|
414
|
+
# Apps call this periodically -- the canonical shape is a
|
|
415
|
+
# before-filter on a "/health" route that internal monitoring
|
|
416
|
+
# hits every 30s, or a Tep::Job that fires from a cron-like
|
|
417
|
+
# tick. Returns the number of tep_presence rows deleted.
|
|
418
|
+
#
|
|
419
|
+
# ttl_seconds should be at least 3x the app's typical
|
|
420
|
+
# heartbeat interval so a transient slow response doesn't
|
|
421
|
+
# evict a live worker. Default callers pass 90 (assumes 30s
|
|
422
|
+
# heartbeats).
|
|
423
|
+
def self.prune_stale_workers(ttl_seconds)
|
|
424
|
+
if Tep::APP.presence_pg_enabled == 0
|
|
425
|
+
return 0
|
|
426
|
+
end
|
|
427
|
+
cutoff = Time.now.to_i - ttl_seconds
|
|
428
|
+
conn = Tep::APP.presence_pg_conn
|
|
429
|
+
begin
|
|
430
|
+
# Drop dead heartbeats first; the second DELETE then walks
|
|
431
|
+
# the worker_id space that's still alive.
|
|
432
|
+
r1 = conn.exec_params(
|
|
433
|
+
"DELETE FROM tep_presence_worker WHERE last_seen_ts < $1",
|
|
434
|
+
[cutoff.to_s])
|
|
435
|
+
r1.clear
|
|
436
|
+
# Now drop presence rows whose worker_id isn't in the live
|
|
437
|
+
# heartbeat table. NOT IN handles both crashed-and-pruned
|
|
438
|
+
# workers and workers that never registered (legacy rows
|
|
439
|
+
# from before this prune feature shipped).
|
|
440
|
+
r2 = conn.exec(
|
|
441
|
+
"DELETE FROM tep_presence " +
|
|
442
|
+
"WHERE worker_id NOT IN (SELECT worker_id FROM tep_presence_worker)")
|
|
443
|
+
n = r2.cmd_tuples
|
|
444
|
+
r2.clear
|
|
445
|
+
rescue PG::Error
|
|
446
|
+
return 0
|
|
447
|
+
end
|
|
448
|
+
n
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Best-effort mirror write: run an exec_params on the mirror conn
|
|
452
|
+
# and swallow any PG::Error. The PG mirror is advisory -- local
|
|
453
|
+
# presence is authoritative -- so a transient mirror failure must
|
|
454
|
+
# never propagate into the caller's request now that exec raises
|
|
455
|
+
# (matz/spinel#627 + #1041). Always returns 0.
|
|
456
|
+
def self.mirror_exec(sql, params)
|
|
457
|
+
begin
|
|
458
|
+
r = Tep::APP.presence_pg_conn.exec_params(sql, params)
|
|
459
|
+
r.clear
|
|
460
|
+
rescue PG::Error
|
|
461
|
+
# swallow -- advisory mirror, local presence is authoritative
|
|
462
|
+
end
|
|
463
|
+
0
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Mirror a track to PG. Called from track() when the PG
|
|
467
|
+
# mirror is enabled.
|
|
468
|
+
def self.mirror_insert(entry)
|
|
469
|
+
if Tep::APP.presence_pg_enabled == 0
|
|
470
|
+
return 0
|
|
471
|
+
end
|
|
472
|
+
Tep::Presence.mirror_exec(
|
|
473
|
+
"INSERT INTO tep_presence " +
|
|
474
|
+
"(worker_id, topic, fd, principal_id, kind, agent_id, " +
|
|
475
|
+
" since_ts, status_state, status_note, status_until) " +
|
|
476
|
+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) " +
|
|
477
|
+
"ON CONFLICT (worker_id, topic, fd) DO UPDATE SET " +
|
|
478
|
+
" principal_id = EXCLUDED.principal_id, " +
|
|
479
|
+
" kind = EXCLUDED.kind, " +
|
|
480
|
+
" agent_id = EXCLUDED.agent_id, " +
|
|
481
|
+
" since_ts = EXCLUDED.since_ts, " +
|
|
482
|
+
" status_state = EXCLUDED.status_state, " +
|
|
483
|
+
" status_note = EXCLUDED.status_note, " +
|
|
484
|
+
" status_until = EXCLUDED.status_until",
|
|
485
|
+
[
|
|
486
|
+
Tep::APP.presence_pg_worker_id,
|
|
487
|
+
entry.topic,
|
|
488
|
+
entry.fd.to_s,
|
|
489
|
+
entry.principal_id,
|
|
490
|
+
entry.kind.to_s,
|
|
491
|
+
entry.agent_id,
|
|
492
|
+
entry.since.to_s,
|
|
493
|
+
entry.status_state.to_s,
|
|
494
|
+
entry.status_note,
|
|
495
|
+
entry.status_until.to_s
|
|
496
|
+
])
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Mirror an untrack to PG.
|
|
500
|
+
def self.mirror_delete(topic, fd)
|
|
501
|
+
if Tep::APP.presence_pg_enabled == 0
|
|
502
|
+
return 0
|
|
503
|
+
end
|
|
504
|
+
Tep::Presence.mirror_exec(
|
|
505
|
+
"DELETE FROM tep_presence " +
|
|
506
|
+
"WHERE worker_id = $1 AND topic = $2 AND fd = $3",
|
|
507
|
+
[Tep::APP.presence_pg_worker_id, topic, fd.to_s])
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Mirror a status update.
|
|
511
|
+
def self.mirror_status(topic, fd, state, note, until_ts)
|
|
512
|
+
if Tep::APP.presence_pg_enabled == 0
|
|
513
|
+
return 0
|
|
514
|
+
end
|
|
515
|
+
Tep::Presence.mirror_exec(
|
|
516
|
+
"UPDATE tep_presence " +
|
|
517
|
+
"SET status_state = $4, status_note = $5, status_until = $6 " +
|
|
518
|
+
"WHERE worker_id = $1 AND topic = $2 AND fd = $3",
|
|
519
|
+
[Tep::APP.presence_pg_worker_id, topic, fd.to_s,
|
|
520
|
+
state.to_s, note, until_ts.to_s])
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Cross-worker list: SELECT all entries on `topic` regardless
|
|
524
|
+
# of which worker tracked them. Returns Array[PresenceEntry]
|
|
525
|
+
# built from the PG rows. The returned entries are read-only
|
|
526
|
+
# snapshots -- mutating them doesn't write back to PG.
|
|
527
|
+
def self.list_global(topic)
|
|
528
|
+
result = [Tep::PresenceEntry.new("", "", :human, "", -1, 0)]
|
|
529
|
+
result.delete_at(0)
|
|
530
|
+
if Tep::APP.presence_pg_enabled == 0
|
|
531
|
+
return result
|
|
532
|
+
end
|
|
533
|
+
begin
|
|
534
|
+
r = Tep::APP.presence_pg_conn.exec_params(
|
|
535
|
+
"SELECT principal_id, kind, agent_id, fd, since_ts, " +
|
|
536
|
+
" status_state, status_note, status_until " +
|
|
537
|
+
"FROM tep_presence WHERE topic = $1 ORDER BY since_ts",
|
|
538
|
+
[topic])
|
|
539
|
+
rescue PG::Error
|
|
540
|
+
return result
|
|
541
|
+
end
|
|
542
|
+
i = 0
|
|
543
|
+
n = r.ntuples
|
|
544
|
+
while i < n
|
|
545
|
+
kind_sym = :human
|
|
546
|
+
if r.getvalue(i, 1) == "agent_for"
|
|
547
|
+
kind_sym = :agent_for
|
|
548
|
+
end
|
|
549
|
+
state_sym = :available
|
|
550
|
+
sstr = r.getvalue(i, 5)
|
|
551
|
+
if sstr == "busy"
|
|
552
|
+
state_sym = :busy
|
|
553
|
+
elsif sstr == "blocked"
|
|
554
|
+
state_sym = :blocked
|
|
555
|
+
end
|
|
556
|
+
e = Tep::PresenceEntry.new(
|
|
557
|
+
topic,
|
|
558
|
+
r.getvalue(i, 0),
|
|
559
|
+
kind_sym,
|
|
560
|
+
r.getvalue(i, 2),
|
|
561
|
+
r.getvalue(i, 3).to_i,
|
|
562
|
+
r.getvalue(i, 4).to_i)
|
|
563
|
+
e.status_state = state_sym
|
|
564
|
+
e.status_note = r.getvalue(i, 6)
|
|
565
|
+
e.status_until = r.getvalue(i, 7).to_i
|
|
566
|
+
result.push(e)
|
|
567
|
+
i += 1
|
|
568
|
+
end
|
|
569
|
+
r.clear
|
|
570
|
+
result
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def self.count_global(topic)
|
|
574
|
+
if Tep::APP.presence_pg_enabled == 0
|
|
575
|
+
return 0
|
|
576
|
+
end
|
|
577
|
+
begin
|
|
578
|
+
r = Tep::APP.presence_pg_conn.exec_params(
|
|
579
|
+
"SELECT count(*) FROM tep_presence WHERE topic = $1",
|
|
580
|
+
[topic])
|
|
581
|
+
rescue PG::Error
|
|
582
|
+
return 0
|
|
583
|
+
end
|
|
584
|
+
n = r.getvalue(0, 0).to_i
|
|
585
|
+
r.clear
|
|
586
|
+
n
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Tep::PresenceEntry -- one row in the Tep::Presence registry.
|
|
2
|
+
#
|
|
3
|
+
# Represents one (principal, session, topic) tracking, plus the
|
|
4
|
+
# optional structured-status + agent-delegation metadata that
|
|
5
|
+
# makes Presence agent-aware. Multiple entries with the same
|
|
6
|
+
# principal_id under one topic are normal: a human in three
|
|
7
|
+
# browser tabs (kind=:human, three different fds), plus their
|
|
8
|
+
# delegated summarizer-bot (kind=:agent_for, agent_id set,
|
|
9
|
+
# separate fd) -- five entries, one principal.
|
|
10
|
+
#
|
|
11
|
+
# fd is the underlying socket file descriptor (typically the
|
|
12
|
+
# accepted WS socket). It's the session-id surrogate: each WS
|
|
13
|
+
# connection has its own fd, so fd uniquely identifies a session
|
|
14
|
+
# within a worker. The framework's WS close hook calls
|
|
15
|
+
# Tep::Presence.untrack_by_fd(fd) to clear all entries when the
|
|
16
|
+
# connection closes.
|
|
17
|
+
#
|
|
18
|
+
# `since` is unix epoch seconds at track time. Useful for "online
|
|
19
|
+
# for N minutes" UI labels.
|
|
20
|
+
#
|
|
21
|
+
# Status fields encode Tep::PresenceStatus inline (a separate
|
|
22
|
+
# wrapper class would force a nested struct and complicate
|
|
23
|
+
# spinel's PtrArray<PresenceEntry> dispatch). Status defaults to
|
|
24
|
+
# (:available, "", 0) at track time; apps update via
|
|
25
|
+
# Tep::Presence.set_status.
|
|
26
|
+
module Tep
|
|
27
|
+
class PresenceEntry
|
|
28
|
+
attr_reader :topic # String
|
|
29
|
+
attr_reader :principal_id # String, opaque
|
|
30
|
+
attr_reader :kind # Symbol: :human | :agent_for
|
|
31
|
+
attr_reader :agent_id # String, empty when kind == :human
|
|
32
|
+
attr_reader :fd # Integer, session-id surrogate
|
|
33
|
+
attr_reader :since # Integer unix epoch seconds
|
|
34
|
+
# Structured-status fields (see docs/BATTERIES-DESIGN.md +
|
|
35
|
+
# memory presence_status). state ∈ :available | :busy | :blocked.
|
|
36
|
+
attr_accessor :status_state
|
|
37
|
+
attr_accessor :status_note
|
|
38
|
+
attr_accessor :status_until
|
|
39
|
+
|
|
40
|
+
def initialize(topic, principal_id, kind, agent_id, fd, since)
|
|
41
|
+
@topic = topic
|
|
42
|
+
@principal_id = principal_id
|
|
43
|
+
@kind = kind
|
|
44
|
+
@agent_id = agent_id
|
|
45
|
+
@fd = fd
|
|
46
|
+
@since = since
|
|
47
|
+
@status_state = :available
|
|
48
|
+
@status_note = ""
|
|
49
|
+
@status_until = 0
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|