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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
data/lib/tep/pg.rb ADDED
@@ -0,0 +1,1128 @@
1
+ # Tep ships the PG battery -- a libpq wrapper that mirrors the
2
+ # ruby-pg gem's public surface (PG::Connection / PG::Result /
3
+ # PG::Error and SQLSTATE-keyed subclasses) so an eventual
4
+ # ActiveRecord-on-spinel port reuses the existing AR pg adapter
5
+ # with minimal divergence.
6
+ #
7
+ # Implementation:
8
+ # - lib/tep/tep_pg.c -- the libpq C shim (integer-handle slot
9
+ # tables, rotating return-string buffer, param accumulator).
10
+ # - this file -- the Ruby surface.
11
+ #
12
+ # Why not the `pg` gem? It's a CRuby native extension against MRI's
13
+ # ABI; spinel produces a static binary with no MRI runtime. The
14
+ # C-shim model (same pattern as Tep::SQLite) replaces "load a gem"
15
+ # with "link a .o at compile time."
16
+ #
17
+ # Namespace note: PG lives at the top level (matching `require 'pg'`
18
+ # from gem-shaped code), not under Tep::. This is the one battery
19
+ # that bends the Tep::Foo convention to keep AR-portability free.
20
+ #
21
+ # See docs/PG-BATTERY.md for the full design + per-method
22
+ # compatibility table.
23
+
24
+ module Pg
25
+ ffi_cflags "@TEP_PG_O@"
26
+ ffi_cflags "@TEP_PG_CFLAGS@"
27
+
28
+ # Result-status constants (collapsed from libpq's 8-value enum
29
+ # into the 4 callers care about; see tep_pg.c). Stable across
30
+ # libpq versions.
31
+ ffi_const :RES_TUPLES, 0
32
+ ffi_const :RES_COMMAND, 1
33
+ ffi_const :RES_EMPTY, 2
34
+ ffi_const :RES_ERROR, 3
35
+
36
+ # Connection lifecycle
37
+ ffi_func :tep_pg_connect, [:str], :int
38
+ ffi_func :tep_pg_connect_kv, [:str, :str, :int], :int
39
+ ffi_func :tep_pg_finish, [:int], :int
40
+ ffi_func :tep_pg_reset, [:int], :int
41
+ ffi_func :tep_pg_status, [:int], :int
42
+ ffi_func :tep_pg_transaction_status, [:int], :int
43
+ ffi_func :tep_pg_error_message, [:int], :str
44
+ ffi_func :tep_pg_server_version, [:int], :int
45
+ ffi_func :tep_pg_set_client_encoding, [:int, :str], :int
46
+
47
+ # Sync exec + param accumulator
48
+ ffi_func :tep_pg_exec, [:int, :str], :int
49
+ ffi_func :tep_pg_param_clear, [], :int
50
+ ffi_func :tep_pg_param_push_str, [:str], :int
51
+ ffi_func :tep_pg_param_push_null, [], :int
52
+ ffi_func :tep_pg_exec_params, [:int, :str], :int
53
+
54
+ # Result inspection
55
+ ffi_func :tep_pg_clear, [:int], :int
56
+ ffi_func :tep_pg_result_status, [:int], :int
57
+ ffi_func :tep_pg_result_error_message, [:int], :str
58
+ ffi_func :tep_pg_result_error_field, [:int, :int], :str
59
+ ffi_func :tep_pg_cmd_status, [:int], :str
60
+ ffi_func :tep_pg_cmd_tuples, [:int], :int
61
+
62
+ ffi_func :tep_pg_ntuples, [:int], :int
63
+ ffi_func :tep_pg_nfields, [:int], :int
64
+ ffi_func :tep_pg_fname, [:int, :int], :str
65
+ ffi_func :tep_pg_fnumber, [:int, :str], :int
66
+ ffi_func :tep_pg_ftype, [:int, :int], :int
67
+ ffi_func :tep_pg_fformat, [:int, :int], :int
68
+ ffi_func :tep_pg_fmod, [:int, :int], :int
69
+ ffi_func :tep_pg_getvalue, [:int, :int, :int], :str
70
+ ffi_func :tep_pg_getisnull, [:int, :int, :int], :int
71
+ ffi_func :tep_pg_getlength, [:int, :int, :int], :int
72
+
73
+ # Escape
74
+ ffi_func :tep_pg_escape_string, [:int, :str], :str
75
+ ffi_func :tep_pg_escape_literal, [:int, :str], :str
76
+ ffi_func :tep_pg_escape_identifier, [:int, :str], :str
77
+
78
+ # Async connect (libpq PQconnectStart + PQconnectPoll). Used by
79
+ # Connection#initialize when called inside a scheduled fiber, so
80
+ # the connect's TCP handshake + auth round-trip parks via
81
+ # Tep::Scheduler.io_wait instead of blocking the worker fiber.
82
+ # PG::Pool's eager open at construction benefits when N
83
+ # connections warm up in parallel under Scheduled.
84
+ ffi_func :tep_pg_connect_start, [:str], :int
85
+ ffi_func :tep_pg_connect_poll, [:int], :int
86
+
87
+ # Async exec (libpq non-blocking surface). Used by
88
+ # Connection#async_exec to park the fiber on Tep::Scheduler.io_wait
89
+ # between PG round-trips under Tep::Server::Scheduled, so other
90
+ # fibers in the same worker can run while the query is in flight.
91
+ ffi_func :tep_pg_socket, [:int], :int
92
+ ffi_func :tep_pg_set_nonblocking, [:int, :int], :int
93
+ ffi_func :tep_pg_send_query, [:int, :str], :int
94
+ ffi_func :tep_pg_send_query_params, [:int, :str], :int
95
+ ffi_func :tep_pg_flush, [:int], :int
96
+ ffi_func :tep_pg_consume_input, [:int], :int
97
+ ffi_func :tep_pg_is_busy, [:int], :int
98
+ ffi_func :tep_pg_get_result, [:int], :int
99
+
100
+ # LISTEN / NOTIFY. Used by Tep::Broadcast's PG backend
101
+ # (Battery 2 chunk 2.2) for cross-worker pub/sub. Channel names
102
+ # are SQL identifiers (caller's responsibility to keep safe);
103
+ # payloads are escaped server-side via PQescapeLiteral.
104
+ ffi_func :tep_pg_listen, [:int, :str], :int
105
+ ffi_func :tep_pg_unlisten, [:int, :str], :int
106
+ ffi_func :tep_pg_notify, [:int, :str, :str], :int
107
+ ffi_func :tep_pg_poll_notification, [:int, :int], :int
108
+ ffi_func :tep_pg_notify_channel, [], :str
109
+ ffi_func :tep_pg_notify_payload, [], :str
110
+
111
+ # Version
112
+ ffi_func :tep_pg_libpq_version, [], :str
113
+ end
114
+
115
+ # Public-facing PG module -- mirrors the ruby-pg gem's class
116
+ # layout. Callers write `PG.connect(...)`, `PG::Connection`,
117
+ # `PG::Result#each`, `rescue PG::UniqueViolation => e`, ...
118
+ module PG
119
+ # Connection status constants (libpq's ConnStatusType collapsed
120
+ # to the two values tep cares about).
121
+ CONNECTION_OK = 0
122
+ CONNECTION_BAD = 1
123
+
124
+ # Transaction status (libpq's PGTransactionStatusType).
125
+ PQTRANS_IDLE = 0
126
+ PQTRANS_ACTIVE = 1
127
+ PQTRANS_INTRANS = 2
128
+ PQTRANS_INERROR = 3
129
+ PQTRANS_UNKNOWN = 4
130
+
131
+ # Diagnostic field codes for Result#error_field. libpq uses single
132
+ # ASCII chars internally (PG_DIAG_SQLSTATE = 'C' = 67); expose
133
+ # them as integer constants here so callers can write
134
+ # `r.error_field(PG::DIAG_SQLSTATE)` without magic numbers.
135
+ DIAG_SEVERITY = 83 # 'S'
136
+ DIAG_SQLSTATE = 67 # 'C'
137
+ DIAG_MESSAGE_PRIMARY = 77 # 'M'
138
+ DIAG_MESSAGE_DETAIL = 68 # 'D'
139
+ DIAG_MESSAGE_HINT = 72 # 'H'
140
+ DIAG_STATEMENT_POSITION = 80 # 'P'
141
+ DIAG_CONTEXT = 87 # 'W'
142
+ DIAG_SCHEMA_NAME = 115 # 's'
143
+ DIAG_TABLE_NAME = 116 # 't'
144
+ DIAG_COLUMN_NAME = 99 # 'c'
145
+ DIAG_DATATYPE_NAME = 100 # 'd'
146
+ DIAG_CONSTRAINT_NAME = 110 # 'n'
147
+
148
+ # Convenience constructor matching ruby-pg's PG.connect entry.
149
+ # opts is either a libpq conninfo String ("postgresql://...") or
150
+ # a String=>String Hash of libpq keys (host, port, dbname, user,
151
+ # password, sslmode, ...).
152
+ #
153
+ # Unlike ruby-pg (which raises PG::ConnectionBad), connect does NOT
154
+ # raise on failure: it returns a connection-failed Connection
155
+ # (`connected?` false, `last_error_message` set). This is deliberate
156
+ # -- PG::Pool type-seeds its free list with `PG::Connection.new("")`
157
+ # at module load, before any server is reachable, so the constructor
158
+ # has to be non-raising. Check `conn.connected?` before use. (Query
159
+ # methods #exec / #exec_params DO raise; see Connection#exec.)
160
+ def self.connect(opts)
161
+ Connection.new(opts)
162
+ end
163
+
164
+ class Connection
165
+ # `:pgh` rather than `:handle` -- same poly-dispatch widening
166
+ # concern as Tep::SQLite#dbh (sharing a method name with
167
+ # Tep::Handler#handle confuses spinel's same-named-imeth-across-
168
+ # classes unifier).
169
+ attr_accessor :pgh
170
+ # Error context for the most recent exception raised by this
171
+ # connection. Spinel's `raise X.new(msg, ...)` lowering doesn't
172
+ # handle custom initializers (#622), so the SQLSTATE / message /
173
+ # owning-result-handle live here instead. Read after
174
+ # `rescue PG::Error => e`:
175
+ #
176
+ # begin; conn.exec_params(sql, params)
177
+ # rescue PG::Error => e
178
+ # sqlstate = conn.last_sqlstate
179
+ # full_msg = conn.last_error_message
180
+ # end
181
+ #
182
+ # AR's `translate_exception_class(message, sql, binds)` uses
183
+ # `e.is_a?(PG::UniqueViolation)` etc., which still works -- the
184
+ # class hierarchy is intact; only the per-exception accessors
185
+ # move to the connection.
186
+ attr_accessor :last_sqlstate, :last_error_message, :last_result_rh
187
+
188
+ def initialize(opts)
189
+ @pgh = -1
190
+ @last_sqlstate = ""
191
+ @last_error_message = ""
192
+ @last_result_rh = -1
193
+ if opts.is_a?(String)
194
+ if Tep::Scheduler.scheduled_context?
195
+ h = Connection.async_connect(opts)
196
+ else
197
+ h = Pg.tep_pg_connect(opts)
198
+ end
199
+ else
200
+ # Hash form. Pack keys and values into parallel \0-delimited
201
+ # buffers; the shim splits them apart and calls
202
+ # PQconnectdbParams. (No async-connect path for the Hash
203
+ # form yet -- AR uses the String form for connect, so the
204
+ # Scheduled-context shortcut points only at conninfo.)
205
+ keys = ""
206
+ vals = ""
207
+ n = 0
208
+ opts.each do |k, v|
209
+ keys = keys + k + "\0"
210
+ vals = vals + v + "\0"
211
+ n += 1
212
+ end
213
+ h = Pg.tep_pg_connect_kv(keys, vals, n)
214
+ end
215
+ if h < 0
216
+ # Slot 0 holds the most recent connect-failure error message
217
+ # (PQstatus on a failed PQconnectdb still gives a readable
218
+ # error, but the conn itself is closed by the time we get
219
+ # here -- the shim stashes the message before PQfinish).
220
+ @last_error_message = Pg.tep_pg_error_message(0)
221
+ @last_sqlstate = ""
222
+ # Connection-failure surfaces via `c.last_error_message` +
223
+ # `c.connected?` after the constructor returns -- the
224
+ # constructor stays non-raising on purpose (PG::Pool seeds its
225
+ # free list with `PG::Connection.new("")` before a server is
226
+ # reachable; a raising constructor would blow up at module
227
+ # load). Callers must check `c.connected?` before exec. NB:
228
+ # this is the lone non-raising path -- query methods raise
229
+ # PG::Error subclasses now that spinel supports namespaced
230
+ # raise + rescue (matz/spinel#627 + #1041).
231
+ end
232
+ @pgh = h
233
+ end
234
+
235
+ def connected?
236
+ @pgh >= 0
237
+ end
238
+
239
+ def close
240
+ if @pgh >= 0
241
+ Pg.tep_pg_finish(@pgh)
242
+ @pgh = -1
243
+ end
244
+ 0
245
+ end
246
+
247
+ def finish
248
+ close
249
+ end
250
+
251
+ def reset
252
+ if @pgh >= 0
253
+ Pg.tep_pg_reset(@pgh)
254
+ end
255
+ self
256
+ end
257
+
258
+ def status
259
+ @pgh < 0 ? PG::CONNECTION_BAD : Pg.tep_pg_status(@pgh)
260
+ end
261
+
262
+ def transaction_status
263
+ @pgh < 0 ? PG::PQTRANS_UNKNOWN : Pg.tep_pg_transaction_status(@pgh)
264
+ end
265
+
266
+ def server_version
267
+ @pgh < 0 ? 0 : Pg.tep_pg_server_version(@pgh)
268
+ end
269
+
270
+ def error_message
271
+ @pgh < 0 ? "" : Pg.tep_pg_error_message(@pgh)
272
+ end
273
+
274
+ # LISTEN / NOTIFY (Battery 2 chunk 2.2). Used by
275
+ # Tep::Broadcast's PG backend for cross-worker pub/sub.
276
+ # Channel names must be safe SQL identifiers (no caller-
277
+ # controlled interpolation -- use a hard-coded constant).
278
+ # Payload max size is 8000 bytes per PG default.
279
+ def listen(channel)
280
+ return -1 if @pgh < 0
281
+ Pg.tep_pg_listen(@pgh, channel)
282
+ end
283
+
284
+ def unlisten(channel)
285
+ return -1 if @pgh < 0
286
+ Pg.tep_pg_unlisten(@pgh, channel)
287
+ end
288
+
289
+ def notify(channel, payload)
290
+ return -1 if @pgh < 0
291
+ Pg.tep_pg_notify(@pgh, channel, payload)
292
+ end
293
+
294
+ # Block up to `timeout_ms` waiting for one notification on the
295
+ # connection. Returns 1 on receipt (caller then reads
296
+ # #last_notify_channel + #last_notify_payload), 0 on timeout,
297
+ # -1 on connection error. Connection must already be in LISTEN
298
+ # mode for the channel of interest.
299
+ def poll_notification(timeout_ms)
300
+ return -1 if @pgh < 0
301
+ Pg.tep_pg_poll_notification(@pgh, timeout_ms)
302
+ end
303
+
304
+ def last_notify_channel
305
+ Pg.tep_pg_notify_channel
306
+ end
307
+
308
+ def last_notify_payload
309
+ Pg.tep_pg_notify_payload
310
+ end
311
+
312
+ # Run a no-params query. Returns a PG::Result on success.
313
+ #
314
+ # ON ERROR IT RAISES the SQLSTATE-mapped PG::Error subclass
315
+ # (PG::UniqueViolation, PG::UndefinedTable, ... -> PG::ServerError
316
+ # for unmapped states) -- the ruby-pg / AR shape. The failed
317
+ # PGresult is freed before the raise; the SQLSTATE / message stay
318
+ # readable on `conn.last_sqlstate` / `#last_error_message` for
319
+ # post-rescue inspection:
320
+ #
321
+ # begin
322
+ # c.exec(sql)
323
+ # rescue PG::UniqueViolation => e
324
+ # ... # e.message + c.last_sqlstate
325
+ # rescue PG::Error => e # base catches any server error
326
+ # ...
327
+ # end
328
+ #
329
+ # Raising (instead of the old Result-on-error sentinel) became
330
+ # viable once spinel learned namespaced raise + hierarchy-walking
331
+ # rescue (matz/spinel#627 + #1041). NB: PG.connect is the one path
332
+ # that still does NOT raise -- it returns a connection-failed
333
+ # instance so PG::Pool can type-seed without a live server (check
334
+ # `conn.connected?`).
335
+ #
336
+ # Under `Tep::Server::Scheduled` this routes through the libpq
337
+ # async surface (PQsendQuery + PQflush + PQconsumeInput parked
338
+ # on Tep::Scheduler.io_wait), so other fibers in the same
339
+ # worker can run while the query is in flight. Under prefork
340
+ # it routes through the blocking PQexec. Both raise identically.
341
+ def exec(sql)
342
+ if Tep::Scheduler.scheduled_context?
343
+ return async_exec(sql)
344
+ end
345
+ rh = Pg.tep_pg_exec(@pgh, sql)
346
+ r = PG::Result.new(rh)
347
+ Connection.record_error_if_any(self, r)
348
+ r
349
+ end
350
+
351
+ # Parameterised query with positional binds ($1, $2, ...).
352
+ # `params` is an Array of String / Integer / nil. Same
353
+ # raise-on-error contract + auto-routing as `exec`.
354
+ def exec_params(sql, params)
355
+ Pg.tep_pg_param_clear
356
+ i = 0
357
+ n = params.length
358
+ while i < n
359
+ p = params[i]
360
+ if p == nil
361
+ Pg.tep_pg_param_push_null
362
+ else
363
+ Pg.tep_pg_param_push_str(p.to_s)
364
+ end
365
+ i += 1
366
+ end
367
+ if Tep::Scheduler.scheduled_context?
368
+ return async_exec_params_after_clear(sql)
369
+ end
370
+ rh = Pg.tep_pg_exec_params(@pgh, sql)
371
+ r = PG::Result.new(rh)
372
+ Connection.record_error_if_any(self, r)
373
+ r
374
+ end
375
+
376
+ # Explicit async exec. Same shape as `exec` but doesn't
377
+ # context-detect -- always uses the libpq async surface. If
378
+ # called outside Tep::Server::Scheduled, Tep::Scheduler.io_wait
379
+ # falls back to a single-shot poll(2), so this still works
380
+ # under prefork (just without the cross-fiber concurrency
381
+ # win).
382
+ def async_exec(sql)
383
+ Pg.tep_pg_set_nonblocking(@pgh, 1)
384
+ ok = Pg.tep_pg_send_query(@pgh, sql)
385
+ if ok != 1
386
+ Connection.raise_send_failure(self)
387
+ end
388
+ Connection.drain_send(@pgh)
389
+ Connection.wait_for_result_ready(@pgh)
390
+ rh = Pg.tep_pg_get_result(@pgh)
391
+ r = PG::Result.new(rh)
392
+ # Drain trailing NULL terminator (libpq requires reading
393
+ # until PQgetResult returns NULL to mark the conn ready for
394
+ # the next send_query).
395
+ Connection.drain_remaining_results(@pgh)
396
+ Connection.record_error_if_any(self, r)
397
+ r
398
+ end
399
+
400
+ # Parameterised async exec. `params` is an Array of
401
+ # String / Integer / nil; same conversion as exec_params.
402
+ def async_exec_params(sql, params)
403
+ Pg.tep_pg_param_clear
404
+ i = 0
405
+ n = params.length
406
+ while i < n
407
+ p = params[i]
408
+ if p == nil
409
+ Pg.tep_pg_param_push_null
410
+ else
411
+ Pg.tep_pg_param_push_str(p.to_s)
412
+ end
413
+ i += 1
414
+ end
415
+ async_exec_params_after_clear(sql)
416
+ end
417
+
418
+ # Internal: param accumulator has already been populated by
419
+ # the caller (either exec_params routing here on context
420
+ # detect, or async_exec_params after its own push loop).
421
+ def async_exec_params_after_clear(sql)
422
+ Pg.tep_pg_set_nonblocking(@pgh, 1)
423
+ ok = Pg.tep_pg_send_query_params(@pgh, sql)
424
+ if ok != 1
425
+ Connection.raise_send_failure(self)
426
+ end
427
+ Connection.drain_send(@pgh)
428
+ Connection.wait_for_result_ready(@pgh)
429
+ rh = Pg.tep_pg_get_result(@pgh)
430
+ r = PG::Result.new(rh)
431
+ Connection.drain_remaining_results(@pgh)
432
+ Connection.record_error_if_any(self, r)
433
+ r
434
+ end
435
+
436
+ # --- Async connect helper ---
437
+
438
+ # Drive PQconnectStart + PQconnectPoll, parking on io_wait
439
+ # between poll calls. Returns the conn slot (>=1) on success
440
+ # or -1 on failure. The C shim's tep_pg_connect_poll stashes
441
+ # the libpq error message on a FAILED return so
442
+ # `Pg.tep_pg_error_message(0)` still surfaces the diagnostic
443
+ # for the Connection.new "connect failed" branch.
444
+ #
445
+ # libpq's PostgresPollingStatusType:
446
+ # 0 = PGRES_POLLING_FAILED
447
+ # 1 = PGRES_POLLING_READING (wait for fd READ-ready)
448
+ # 2 = PGRES_POLLING_WRITING (wait for fd WRITE-ready)
449
+ # 3 = PGRES_POLLING_OK (connected; stop polling)
450
+ def self.async_connect(conninfo)
451
+ h = Pg.tep_pg_connect_start(conninfo)
452
+ if h < 0
453
+ return -1
454
+ end
455
+ fd = Pg.tep_pg_socket(h)
456
+ while true
457
+ state = Pg.tep_pg_connect_poll(h)
458
+ if state == 3
459
+ # PGRES_POLLING_OK
460
+ Pg.tep_pg_set_client_encoding(h, "UTF8")
461
+ return h
462
+ end
463
+ if state == 0
464
+ # PGRES_POLLING_FAILED. The shim has already stashed the
465
+ # error message; we PQfinish the slot.
466
+ Pg.tep_pg_finish(h)
467
+ return -1
468
+ end
469
+ mode = state == 1 ? Tep::Scheduler::READ : Tep::Scheduler::WRITE
470
+ Tep::Scheduler.io_wait(fd, mode, 10)
471
+ end
472
+ -1
473
+ end
474
+
475
+ # --- Internal helpers for the async loop ---
476
+
477
+ # PQsendQuery returned 0 (immediate failure -- conn already
478
+ # closed, send buffer error, etc.). Mirror the error onto the
479
+ # conn's last_* and raise, matching the exec error path (ruby-pg
480
+ # surfaces a send failure as PG::UnableToSend < PG::Error). No
481
+ # SQLSTATE is available pre-result, so this maps to the transport
482
+ # leaf rather than going through raise_for_sqlstate.
483
+ def self.raise_send_failure(conn)
484
+ conn.last_sqlstate = ""
485
+ conn.last_error_message = conn.error_message
486
+ conn.last_result_rh = -1
487
+ raise PG::UnableToSend, conn.error_message
488
+ end
489
+
490
+ # Drain libpq's send buffer. PQflush returns 0 when done; 1
491
+ # when the kernel send-buffer is full and we should park on
492
+ # WRITE-ready; -1 on error. Timeout is generous (10s); a
493
+ # genuinely-stuck PG is the rare case worth bailing on.
494
+ def self.drain_send(pgh)
495
+ fd = Pg.tep_pg_socket(pgh)
496
+ while true
497
+ rc = Pg.tep_pg_flush(pgh)
498
+ if rc == 0
499
+ return 0
500
+ end
501
+ if rc < 0
502
+ return -1
503
+ end
504
+ # rc == 1: send buffer full, park on writability.
505
+ Tep::Scheduler.io_wait(fd, Tep::Scheduler::WRITE, 10)
506
+ end
507
+ 0
508
+ end
509
+
510
+ # Wait until PQisBusy returns 0 (PQgetResult won't block).
511
+ # Pumps PQconsumeInput in between io_wait calls so the
512
+ # libpq state machine advances. Timeout is generous (30s)
513
+ # since the query itself can take that long; the io_wait
514
+ # timeout is per-iteration, not cumulative.
515
+ def self.wait_for_result_ready(pgh)
516
+ fd = Pg.tep_pg_socket(pgh)
517
+ while true
518
+ if Pg.tep_pg_consume_input(pgh) != 1
519
+ return -1
520
+ end
521
+ if Pg.tep_pg_is_busy(pgh) == 0
522
+ return 0
523
+ end
524
+ Tep::Scheduler.io_wait(fd, Tep::Scheduler::READ, 30)
525
+ end
526
+ 0
527
+ end
528
+
529
+ # After the first PQgetResult returned a real Result, libpq
530
+ # requires the conn be drained via additional PQgetResult
531
+ # calls until NULL is returned. This is a fast in-memory drain
532
+ # (no network), but it has to happen between async_exec calls
533
+ # or the next send_query will fail. Each tep_pg_get_result
534
+ # call that produces a non-NULL result stashes it in the slot
535
+ # table; we PQclear those immediately since they're trailing
536
+ # status results we don't expose.
537
+ def self.drain_remaining_results(pgh)
538
+ while true
539
+ rh = Pg.tep_pg_get_result(pgh)
540
+ if rh < 0
541
+ return 0
542
+ end
543
+ # A trailing result -- shouldn't normally happen for
544
+ # single-statement queries, but defensively free.
545
+ Pg.tep_pg_clear(rh)
546
+ end
547
+ 0
548
+ end
549
+
550
+ def escape_string(s)
551
+ Pg.tep_pg_escape_string(@pgh, s)
552
+ end
553
+
554
+ def escape_identifier(s)
555
+ Pg.tep_pg_escape_identifier(@pgh, s)
556
+ end
557
+
558
+ def escape_literal(s)
559
+ Pg.tep_pg_escape_literal(@pgh, s)
560
+ end
561
+
562
+ # Class-method form -- ruby-pg allows escape_string and
563
+ # quote_ident without a live conn. We route through slot 0
564
+ # which the shim treats as "no conn, fall back to standalone
565
+ # PQescapeString". Use the instance method when a conn is
566
+ # available -- it goes through PQescapeStringConn which is
567
+ # the standards-compliant path.
568
+ def self.escape_string(s)
569
+ Pg.tep_pg_escape_string(0, s)
570
+ end
571
+
572
+ def self.quote_ident(s)
573
+ # PQescapeIdentifier requires a conn; without one we fall
574
+ # through to "" which is wrong but rare. Apps with a live
575
+ # PG::Connection should use the instance method.
576
+ Pg.tep_pg_escape_identifier(0, s)
577
+ end
578
+
579
+ # If the Result is in an error state, mirror SQLSTATE +
580
+ # message + result-handle onto the conn so post-rescue (or
581
+ # post-`if !r.ok?`) callers can read them via `conn.last_*`.
582
+ # No raise here -- see the docstring on `exec` for why.
583
+ def self.record_error_if_any(conn, r)
584
+ st = r.status
585
+ if st == Pg::RES_TUPLES || st == Pg::RES_COMMAND || st == Pg::RES_EMPTY
586
+ return 0
587
+ end
588
+ sqlstate = r.error_field(PG::DIAG_SQLSTATE)
589
+ msg = r.error_message
590
+ if msg.length == 0
591
+ msg = conn.error_message
592
+ end
593
+ conn.last_sqlstate = sqlstate
594
+ conn.last_error_message = msg
595
+ # Free the failed PGresult NOW: once we raise out of
596
+ # exec/exec_params the caller's `r.clear` never runs, so this is
597
+ # the only chance to release it. The SQLSTATE / message are
598
+ # already copied onto conn.last_* (Strings) for post-rescue
599
+ # inspection, so dropping the handle loses nothing callers need.
600
+ conn.last_result_rh = -1
601
+ r.clear
602
+ # ruby-pg / AR parity: raise the SQLSTATE-mapped PG::Error
603
+ # subclass (live since matz/spinel#627 + #1041 -- namespaced
604
+ # raise + hierarchy-walking rescue). Callers `rescue
605
+ # PG::UniqueViolation` (leaf) or `rescue PG::Error` (base).
606
+ PG.raise_for_sqlstate(sqlstate, msg)
607
+ 0
608
+ end
609
+ end
610
+
611
+ class Result
612
+ attr_accessor :rh
613
+
614
+ def initialize(rh)
615
+ @rh = rh
616
+ end
617
+
618
+ def status
619
+ @rh < 0 ? Pg::RES_ERROR : Pg.tep_pg_result_status(@rh)
620
+ end
621
+
622
+ # True when the query reached the server and produced a
623
+ # non-error result (rows, command success, or empty query).
624
+ # Inspect `error_message` / `error_field(5)` on a non-ok result.
625
+ def ok?
626
+ st = status
627
+ st == Pg::RES_TUPLES || st == Pg::RES_COMMAND || st == Pg::RES_EMPTY
628
+ end
629
+
630
+ def error_message
631
+ @rh < 0 ? "" : Pg.tep_pg_result_error_message(@rh)
632
+ end
633
+
634
+ def error_field(code)
635
+ @rh < 0 ? "" : Pg.tep_pg_result_error_field(@rh, code)
636
+ end
637
+
638
+ def cmd_status
639
+ @rh < 0 ? "" : Pg.tep_pg_cmd_status(@rh)
640
+ end
641
+
642
+ # ruby-pg's PG::Result#error_field shortcut: 5-char SQLSTATE
643
+ # string. Empty when the result isn't an error.
644
+ def sql_state
645
+ error_field(PG::DIAG_SQLSTATE)
646
+ end
647
+
648
+ def cmd_tuples
649
+ @rh < 0 ? 0 : Pg.tep_pg_cmd_tuples(@rh)
650
+ end
651
+
652
+ def ntuples
653
+ @rh < 0 ? 0 : Pg.tep_pg_ntuples(@rh)
654
+ end
655
+
656
+ def nfields
657
+ @rh < 0 ? 0 : Pg.tep_pg_nfields(@rh)
658
+ end
659
+
660
+ # ruby-pg aliases for ntuples / nfields.
661
+ def num_tuples; ntuples; end
662
+ def num_fields; nfields; end
663
+
664
+ def fname(col)
665
+ @rh < 0 ? "" : Pg.tep_pg_fname(@rh, col)
666
+ end
667
+
668
+ def fnumber(name)
669
+ @rh < 0 ? -1 : Pg.tep_pg_fnumber(@rh, name)
670
+ end
671
+
672
+ def ftype(col)
673
+ @rh < 0 ? 0 : Pg.tep_pg_ftype(@rh, col)
674
+ end
675
+
676
+ def fformat(col)
677
+ @rh < 0 ? 0 : Pg.tep_pg_fformat(@rh, col)
678
+ end
679
+
680
+ def fmod(col)
681
+ @rh < 0 ? -1 : Pg.tep_pg_fmod(@rh, col)
682
+ end
683
+
684
+ def getvalue(row, col)
685
+ @rh < 0 ? "" : Pg.tep_pg_getvalue(@rh, row, col)
686
+ end
687
+
688
+ def getisnull(row, col)
689
+ @rh < 0 ? true : Pg.tep_pg_getisnull(@rh, row, col) == 1
690
+ end
691
+
692
+ def getlength(row, col)
693
+ @rh < 0 ? 0 : Pg.tep_pg_getlength(@rh, row, col)
694
+ end
695
+
696
+ # ruby-pg's #value is an alias for #getvalue.
697
+ def value(row, col)
698
+ getvalue(row, col)
699
+ end
700
+
701
+ def fields
702
+ out = [""]
703
+ out.delete_at(0)
704
+ w = nfields
705
+ j = 0
706
+ while j < w
707
+ out.push(fname(j))
708
+ j += 1
709
+ end
710
+ out
711
+ end
712
+
713
+ def values
714
+ rows = [[""]]
715
+ rows.delete_at(0)
716
+ n = ntuples
717
+ w = nfields
718
+ i = 0
719
+ while i < n
720
+ row = [""]
721
+ row.delete_at(0)
722
+ j = 0
723
+ while j < w
724
+ row.push(getvalue(i, j))
725
+ j += 1
726
+ end
727
+ rows.push(row)
728
+ i += 1
729
+ end
730
+ rows
731
+ end
732
+
733
+ def column_values(col)
734
+ out = [""]
735
+ out.delete_at(0)
736
+ n = ntuples
737
+ i = 0
738
+ while i < n
739
+ out.push(getvalue(i, col))
740
+ i += 1
741
+ end
742
+ out
743
+ end
744
+
745
+ # Array-yielding iteration. Cleaner shape than #each for hot
746
+ # paths -- no Hash allocation per row.
747
+ def each_row
748
+ n = ntuples
749
+ w = nfields
750
+ i = 0
751
+ while i < n
752
+ row = [""]
753
+ row.delete_at(0)
754
+ j = 0
755
+ while j < w
756
+ row.push(getvalue(i, j))
757
+ j += 1
758
+ end
759
+ yield row
760
+ i += 1
761
+ end
762
+ self
763
+ end
764
+
765
+ # Hash-yielding iteration -- matches ruby-pg's #each. Pre-builds
766
+ # the field-name array to skip a per-row fname call. The Hash
767
+ # shape is pinned to str_str_hash via a seed in lib/tep.rb;
768
+ # without that seed spinel widens to poly_poly_hash on first
769
+ # use.
770
+ def each
771
+ flds = fields
772
+ n = ntuples
773
+ w = flds.length
774
+ i = 0
775
+ while i < n
776
+ row = Tep.str_hash
777
+ j = 0
778
+ while j < w
779
+ row[flds[j]] = getvalue(i, j)
780
+ j += 1
781
+ end
782
+ yield row
783
+ i += 1
784
+ end
785
+ self
786
+ end
787
+
788
+ def clear
789
+ if @rh >= 0
790
+ Pg.tep_pg_clear(@rh)
791
+ @rh = -1
792
+ end
793
+ 0
794
+ end
795
+ end
796
+
797
+ # libpq version string ("16.2.0" etc.). Diagnostic / banner use.
798
+ def self.libpq_version
799
+ Pg.tep_pg_libpq_version
800
+ end
801
+
802
+ # -------- Exception hierarchy --------
803
+ #
804
+ # Mirrors ruby-pg's PG::Error tree. ActiveRecord's adapter
805
+ # pattern-matches with `e.is_a?(PG::UniqueViolation)` etc.; the
806
+ # leaf classes are what makes that pattern work without a SQLSTATE
807
+ # parse at every callsite.
808
+ #
809
+ # v1 ships base + ConnectionBad + UnableToSend + ServerError; the
810
+ # SQLSTATE-keyed leaves below are the v1.5 surface (AR-coverage
811
+ # subset). Adding a leaf is one class definition + one line in
812
+ # error_class_for_sqlstate.
813
+
814
+ # PG::Error hierarchy -- ruby-pg-shape, SQLSTATE-keyed. AR's
815
+ # pg adapter does `e.is_a?(PG::UniqueViolation)` to translate
816
+ # libpq errors; the class identity has to match. Live since
817
+ # matz/spinel#627 (rescue ParentClass + is_a?(ParentClass) walk
818
+ # the class hierarchy).
819
+ #
820
+ # Raised by Connection#exec / #exec_params via the two-arg
821
+ # `raise PG::Klass, msg` form (spinel can't lower `raise
822
+ # X.new(msg, ...)` for custom Exception initializers --
823
+ # matz/spinel#622). SQLSTATE / result-handle context lives on
824
+ # `conn.last_sqlstate` / `#last_error_message` / `#last_result_rh`
825
+ # for callers who need them post-rescue.
826
+ class Error < StandardError; end
827
+
828
+ class ConnectionBad < Error; end
829
+ class UnableToSend < Error; end
830
+ class ServerError < Error; end
831
+
832
+ # SQLSTATE class 23 -- integrity constraint violation
833
+ class IntegrityConstraintViolation < ServerError; end
834
+ class NotNullViolation < IntegrityConstraintViolation; end # 23502
835
+ class ForeignKeyViolation < IntegrityConstraintViolation; end # 23503
836
+ class UniqueViolation < IntegrityConstraintViolation; end # 23505
837
+ class CheckViolation < IntegrityConstraintViolation; end # 23514
838
+ class ExclusionViolation < IntegrityConstraintViolation; end # 23P01
839
+
840
+ # SQLSTATE class 25 -- invalid transaction state
841
+ class InFailedSqlTransaction < ServerError; end # 25P02
842
+ class ReadOnlySqlTransaction < ServerError; end # 25006
843
+
844
+ # SQLSTATE class 40 -- transaction rollback
845
+ class SerializationFailure < ServerError; end # 40001
846
+ class DeadlockDetected < ServerError; end # 40P01
847
+
848
+ # SQLSTATE class 42 -- syntax / access rule violation
849
+ class SyntaxError < ServerError; end # 42601
850
+ class UndefinedColumn < ServerError; end # 42703
851
+ class UndefinedFunction < ServerError; end # 42883
852
+ class UndefinedTable < ServerError; end # 42P01
853
+ class DuplicateColumn < ServerError; end # 42701
854
+ class DuplicateTable < ServerError; end # 42P07
855
+ class InsufficientPrivilege < ServerError; end # 42501
856
+
857
+ # SQLSTATE class 57 -- operator intervention
858
+ class QueryCanceled < ServerError; end # 57014
859
+ class AdminShutdown < ServerError; end # 57P01
860
+
861
+ # SQLSTATE class 08 -- connection exception
862
+ class ConnectionException < ServerError; end # 08000
863
+ class ConnectionDoesNotExist < ServerError; end # 08003
864
+
865
+ # Pool-side error (no SQLSTATE): raised by PG::Pool#checkout when
866
+ # the pool stays empty past the checkout timeout. Subclasses Error
867
+ # so callers can `rescue PG::PoolExhausted` or the broader
868
+ # `rescue PG::Error`. (Raising namespaced errors from instance
869
+ # methods became viable with matz/spinel#1041; before that, checkout
870
+ # surfaced exhaustion as a sentinel nil-equivalent Connection.)
871
+ class PoolExhausted < Error; end
872
+
873
+ # Raise the PG::Error subclass mapped from a 5-char SQLSTATE.
874
+ # Connection#exec / #exec_params call this (via record_error_if_any)
875
+ # so a failed query surfaces as a typed exception -- the ruby-pg / AR
876
+ # shape, where the adapter does `rescue PG::UniqueViolation` /
877
+ # `e.is_a?(PG::UndefinedTable)`. An unmapped SQLSTATE falls through to
878
+ # PG::ServerError, so `rescue PG::Error` still catches every server
879
+ # error. The mapping is the SQLSTATE-keyed subset the leaf classes
880
+ # cover (AR-coverage); add a leaf + an arm here together.
881
+ #
882
+ # Literal-class dispatch (one `raise PG::Klass` per arm) rather than
883
+ # `raise klass_var` -- raising a Class held in a local doesn't lower
884
+ # under spinel; the constant-path raise is what matz/spinel#1041 made
885
+ # work.
886
+ def self.raise_for_sqlstate(state, msg)
887
+ # 23 -- integrity constraint violation
888
+ if state == "23502"
889
+ raise PG::NotNullViolation, msg
890
+ elsif state == "23503"
891
+ raise PG::ForeignKeyViolation, msg
892
+ elsif state == "23505"
893
+ raise PG::UniqueViolation, msg
894
+ elsif state == "23514"
895
+ raise PG::CheckViolation, msg
896
+ elsif state == "23P01"
897
+ raise PG::ExclusionViolation, msg
898
+ # 25 -- invalid transaction state
899
+ elsif state == "25P02"
900
+ raise PG::InFailedSqlTransaction, msg
901
+ elsif state == "25006"
902
+ raise PG::ReadOnlySqlTransaction, msg
903
+ # 40 -- transaction rollback
904
+ elsif state == "40001"
905
+ raise PG::SerializationFailure, msg
906
+ elsif state == "40P01"
907
+ raise PG::DeadlockDetected, msg
908
+ # 42 -- syntax / access rule violation
909
+ elsif state == "42601"
910
+ raise PG::SyntaxError, msg
911
+ elsif state == "42703"
912
+ raise PG::UndefinedColumn, msg
913
+ elsif state == "42883"
914
+ raise PG::UndefinedFunction, msg
915
+ elsif state == "42P01"
916
+ raise PG::UndefinedTable, msg
917
+ elsif state == "42701"
918
+ raise PG::DuplicateColumn, msg
919
+ elsif state == "42P07"
920
+ raise PG::DuplicateTable, msg
921
+ elsif state == "42501"
922
+ raise PG::InsufficientPrivilege, msg
923
+ # 57 -- operator intervention
924
+ elsif state == "57014"
925
+ raise PG::QueryCanceled, msg
926
+ elsif state == "57P01"
927
+ raise PG::AdminShutdown, msg
928
+ # 08 -- connection exception
929
+ elsif state == "08000"
930
+ raise PG::ConnectionException, msg
931
+ elsif state == "08003"
932
+ raise PG::ConnectionDoesNotExist, msg
933
+ else
934
+ raise PG::ServerError, msg
935
+ end
936
+ end
937
+
938
+ # -------- Connection pool --------
939
+ #
940
+ # PG::Pool -- a fixed-size connection pool for PG::Connection
941
+ # instances. Mirrors ruby-pg's `PG::Pool` shape from the
942
+ # external pg_pool gem (and the same idea as AR's
943
+ # ConnectionPool): hold N pre-opened connections, hand them out
944
+ # via `checkout` / take them back via `checkin`, park
945
+ # cooperatively under `Tep::Server::Scheduled` when the free
946
+ # list is empty.
947
+ #
948
+ # Typical use:
949
+ #
950
+ # POOL = PG::Pool.new(ENV["DATABASE_URL"], 8)
951
+ #
952
+ # get '/users/:id' do
953
+ # c = POOL.checkout
954
+ # r = c.exec_params("SELECT name FROM users WHERE id = $1",
955
+ # [params[:id]])
956
+ # name = r.getvalue(0, 0)
957
+ # r.clear
958
+ # POOL.checkin(c)
959
+ # name
960
+ # end
961
+ #
962
+ # The block-form `with { |c| ... }` is deferred until spinel
963
+ # lights up instance-method typed yields (matz/spinel#628 covers
964
+ # the top-level def case but not instance methods); manual
965
+ # checkout/checkin is the v1 shape.
966
+ #
967
+ # Concurrency model:
968
+ #
969
+ # - Under prefork (Tep::Server, the default): one Pool per
970
+ # worker process; eagerly opens its N conns at boot. N tunes
971
+ # the per-worker in-flight query count.
972
+ # - Under Tep::Server::Scheduled: one Pool for the whole
973
+ # worker; checkouts that find the free list empty park via
974
+ # `Tep::Scheduler.pause(0.001)` until a checkin happens.
975
+ # Other fibers run in the meantime; eventually a checkin
976
+ # refills the free list and the parked fiber retries.
977
+ #
978
+ # On exhaustion (non-scheduled callers only), checkout raises
979
+ # PG::PoolExhausted once it has waited past @checkout_timeout_ms.
980
+ # This used to be a sentinel nil-equivalent return because spinel
981
+ # couldn't rescue module-namespaced exception classes; matz/spinel#1041
982
+ # fixed that, so `rescue PG::PoolExhausted` / `rescue PG::Error` now
983
+ # work. The scheduled path parks indefinitely (waking on checkin) and
984
+ # so has no exhaustion timeout -- only the spin fallback does.
985
+ class Pool
986
+ attr_accessor :url, :size, :free, :waiter_idxs, :checkout_timeout_ms
987
+
988
+ def initialize(url, size)
989
+ @url = url
990
+ @size = size
991
+ @checkout_timeout_ms = 5000 # 5s default; bump for slow upstreams
992
+ # Type-seed @free as PtrArray<PG::Connection>. PG::Connection.new
993
+ # with an empty conninfo returns a connection-failed instance
994
+ # (@pgh=-1, populated @last_error_message) rather than raising,
995
+ # so this is safe to run at module load even when PG isn't
996
+ # reachable.
997
+ @free = [PG::Connection.new("")]
998
+ @free.delete_at(0)
999
+ # Waiter queue: IntArray of fiber indices into Tep::APP.sched_fibers.
1000
+ # `checkout` parks the current fiber here when @free is empty
1001
+ # (under Scheduled); `checkin` resumes the oldest waiter by
1002
+ # setting its wake_at = -1. Type-seed with an int + delete.
1003
+ @waiter_idxs = [0]
1004
+ @waiter_idxs.delete_at(0)
1005
+ # Eager open of N real conns. If the URL isn't reachable, each
1006
+ # Connection will have @pgh=-1; the caller can check
1007
+ # `pool.healthy?` after construction.
1008
+ i = 0
1009
+ while i < size
1010
+ c = PG::Connection.new(url)
1011
+ @free.push(c)
1012
+ i += 1
1013
+ end
1014
+ end
1015
+
1016
+ # True iff every pooled connection opened successfully. Use
1017
+ # after construction to fail loud rather than handing out
1018
+ # broken conns:
1019
+ #
1020
+ # POOL = PG::Pool.new(url, 8)
1021
+ # raise "PG unreachable" unless POOL.healthy?
1022
+ def healthy?
1023
+ i = 0
1024
+ while i < @free.length
1025
+ if !@free[i].connected?
1026
+ return false
1027
+ end
1028
+ i += 1
1029
+ end
1030
+ @free.length == @size
1031
+ end
1032
+
1033
+ def set_checkout_timeout_ms(ms)
1034
+ @checkout_timeout_ms = ms
1035
+ end
1036
+
1037
+ # Acquire a connection. Returns a PG::Connection on success.
1038
+ #
1039
+ # Two paths:
1040
+ #
1041
+ # - Under Tep::Server::Scheduled: park the current fiber in
1042
+ # the pool's waiter queue (via Fiber.yield with a far-future
1043
+ # wake_at sentinel). `checkin` wakes the oldest waiter by
1044
+ # setting its wake_at=-1, which marks it as due on the next
1045
+ # scheduler tick. No busy-spin -- the scheduler runs other
1046
+ # fibers (handlers, accept loop, async-exec parkers) until
1047
+ # a checkin happens.
1048
+ #
1049
+ # - Outside scheduled context (prefork-blocking or top-level
1050
+ # code): fall back to a small-step pause-and-retry. Each
1051
+ # worker is single-threaded in prefork, so a busy
1052
+ # checkout-on-empty only happens if user code holds two
1053
+ # checkouts inside one handler. Document; rarely matters.
1054
+ def checkout
1055
+ if @free.length > 0
1056
+ return @free.delete_at(0)
1057
+ end
1058
+ if !Tep::Scheduler.scheduled_context?
1059
+ return checkout_spin_fallback
1060
+ end
1061
+ # Cooperative wait. Stash our fiber index, park, wait for
1062
+ # checkin to set wake_at=-1.
1063
+ idx = Tep::APP.sched_current
1064
+ @waiter_idxs.push(idx)
1065
+ # Far-future sentinel: the scheduler won't pick us as
1066
+ # time-due until checkin lowers our wake_at. Tep::Scheduler's
1067
+ # int-second resolution means "not soon enough to matter"
1068
+ # = a few hours.
1069
+ Tep::APP.sched_wake_at[idx] = Time.now.to_i + 86400
1070
+ Fiber.yield
1071
+ # When we resume, checkin pushed a conn to @free + woke us.
1072
+ # Pop it.
1073
+ @free.delete_at(0)
1074
+ end
1075
+
1076
+ # Return a connection to the pool. If there's a parked waiter,
1077
+ # wake it (push to @free + set wake_at=-1 on the waiter's
1078
+ # fiber index). Otherwise just push to @free.
1079
+ def checkin(c)
1080
+ @free.push(c)
1081
+ if @waiter_idxs.length > 0
1082
+ widx = @waiter_idxs.delete_at(0)
1083
+ # wake_at = -1 makes the fiber the "earliest due" in the
1084
+ # next tick's pick (the tick comparator chooses the lowest
1085
+ # wake_at among the time-due set, so -1 always wins).
1086
+ Tep::APP.sched_wake_at[widx] = -1
1087
+ end
1088
+ 0
1089
+ end
1090
+
1091
+ # Pause-and-retry fallback for non-scheduled callers. Used by
1092
+ # checkout when called outside a fiber. Since pause's seconds
1093
+ # arg is stored as an mrb_int (rounds sub-second values to 0),
1094
+ # this actually busy-spins under the scheduler -- but the
1095
+ # branch is only taken outside scheduled context, so there's
1096
+ # no fiber starvation concern: the worker is single-threaded
1097
+ # and either has a free conn or doesn't.
1098
+ def checkout_spin_fallback
1099
+ waited_ms = 0
1100
+ while @free.length == 0
1101
+ Tep::Scheduler.pause(1) # full-second pause; non-scheduled fallback
1102
+ waited_ms += 1000
1103
+ if waited_ms >= @checkout_timeout_ms
1104
+ raise PG::PoolExhausted,
1105
+ "PG::Pool#checkout timed out after " +
1106
+ @checkout_timeout_ms.to_s + "ms; all " +
1107
+ @size.to_s + " connections in use"
1108
+ end
1109
+ end
1110
+ @free.delete_at(0)
1111
+ end
1112
+
1113
+ # Diagnostic: how many connections are currently available.
1114
+ def available
1115
+ @free.length
1116
+ end
1117
+
1118
+ # Close every connection. Call at app shutdown if needed; the
1119
+ # OS recovers them on process exit anyway.
1120
+ def close_all
1121
+ while @free.length > 0
1122
+ c = @free.delete_at(0)
1123
+ c.close
1124
+ end
1125
+ 0
1126
+ end
1127
+ end
1128
+ end