ndb 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/LICENSE +21 -0
- data/README.md +296 -0
- data/lib/ndb/client.rb +245 -0
- data/lib/ndb/conditions.rb +184 -0
- data/lib/ndb/connection.rb +284 -0
- data/lib/ndb/errors.rb +67 -0
- data/lib/ndb/version.rb +5 -0
- data/lib/ndb.rb +7 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d05135b99146700c912046914e228098dc99c4bd67181074f39ea399c1d2e375
|
|
4
|
+
data.tar.gz: 76be9b065c8505b1a7c59ce69bf66bce0aa179bb6e4368f73facaea10e30c848
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b3e6fa3bded8d9ee8824f7c0038e2fe74c462cdd95561b20f0a1548c59b40ff825bb1cd41348c9a2b919166fba3ba2ded2c5b58f61133cd729adf2f915e7c0b9
|
|
7
|
+
data.tar.gz: 5ace970046bad037e2977795771133017dc594ef83f64ee914799d732fb4b71baca7446e2788464644d9e56e75320d0c7987f87305801730a36a1f043d4a6ceb
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thadeu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# ndb
|
|
2
|
+
|
|
3
|
+
<img src="https://raw.githubusercontent.com/thadeu/ndb/main/brand/png/lockup-on-light.png" width="220">
|
|
4
|
+
|
|
5
|
+
Plain Ruby client for [ndb](https://github.com/thadeu/ndb), an NDJSON query daemon. It talks HTTP over a unix socket or TCP, with a connection pool and keep-alive. It uses the Ruby standard library only: **no runtime dependencies**. The gem only builds JSON. The daemon decides what every query means.
|
|
6
|
+
|
|
7
|
+
Building a Rails app with models? Use [`ndb-rails`](https://github.com/thadeu/ndb/tree/main/sdk/rails) on top of this gem.
|
|
8
|
+
|
|
9
|
+
Docs: [client guide](https://github.com/thadeu/ndb/blob/main/docs/dev/clients.md), [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md), [HTTP API](https://github.com/thadeu/ndb/blob/main/docs/dev/http-api.md).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Needs Ruby 3.2 or newer and a running ndb daemon.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
gem "ndb"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle add ndb
|
|
22
|
+
# or: gem install ndb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require "ndb"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Connect
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
ndb = Ndb::Client.new(socket: "/run/ndb/ndb.sock") # unix socket
|
|
33
|
+
ndb = Ndb::Client.new(url: "unix:///run/ndb/ndb.sock") # same, as a URL
|
|
34
|
+
ndb = Ndb::Client.new(url: "tcp://10.0.0.5:7070", token: "secret") # TCP with a bearer token
|
|
35
|
+
ndb = Ndb::Client.new # reads NDB_URL and NDB_TOKEN
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
All options:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
Ndb::Client.new(
|
|
42
|
+
url: nil, # "unix:///path", "tcp://host:port", or a bare path that starts with /
|
|
43
|
+
socket: nil, # a unix socket path
|
|
44
|
+
token: nil, # bearer token
|
|
45
|
+
timeout: 30, # seconds, per request
|
|
46
|
+
pool_size: 16, # idle connections kept
|
|
47
|
+
ready_timeout: 10 # seconds to retry while the daemon starts; 0 turns it off
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- The address is, in order: `url:`, `socket:`, env `NDB_URL`, then the default socket `/var/run/ndb/ndb.sock`.
|
|
52
|
+
- The token is `token:`, or env `NDB_TOKEN`. It goes only in the `Authorization` header. A unix socket does not need it.
|
|
53
|
+
- A bad address raises `ArgumentError` at once.
|
|
54
|
+
- `ready_timeout` is how long a call retries while the daemon is not ready (`not_ready`, or the socket is missing or refuses). It uses exponential backoff (50 ms up to 1 s) with jitter. A write is retried only when the daemon certainly did not process it. A read is also retried after a dropped connection. A timeout is never retried. When the deadline passes, the last error is raised.
|
|
55
|
+
- A reused keep-alive connection that the server closed is sent again only for a read. A write is never sent twice.
|
|
56
|
+
- The pool is thread-safe and drops its connections after a `fork`.
|
|
57
|
+
|
|
58
|
+
## Collections
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
ndb.ensure_collection("calls",
|
|
62
|
+
id: "call_id",
|
|
63
|
+
indexes: ["status"],
|
|
64
|
+
unique: ["caller"],
|
|
65
|
+
time: "at") # also: expires_field:, ttl:
|
|
66
|
+
|
|
67
|
+
ndb.collections # => [{"name" => "calls", ...}]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`ensure_collection` is idempotent. A different definition for an existing collection raises `Ndb::CatalogConflict`.
|
|
71
|
+
|
|
72
|
+
## Insert, update, delete
|
|
73
|
+
|
|
74
|
+
Every document has a version. A new document has version 1, and each change adds 1.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
results = ndb.insert("calls", [
|
|
78
|
+
{ call_id: "c1", status: "answered", caller: "a", duration: 42 },
|
|
79
|
+
{ call_id: "c2", status: "failed", caller: "b", duration: 5 }
|
|
80
|
+
])
|
|
81
|
+
results # => [{"id" => "c1", "v" => 1}, {"id" => "c2", "v" => 1}]
|
|
82
|
+
|
|
83
|
+
ndb.update("calls", "c1", set: { status: "done" }, unset: ["caller"]) # => {"id" => "c1", "v" => 2}
|
|
84
|
+
ndb.update("calls", "c1", set: { duration: 50 }, if_version: 2) # only if the version is still 2
|
|
85
|
+
|
|
86
|
+
ndb.delete("calls", "c2")
|
|
87
|
+
ndb.delete("calls", "c1", if_version: 3)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- `if_version:` is optimistic locking. If another writer changed the document first, you get `Ndb::VersionConflict` (code `version_conflict`). Reload and try again.
|
|
91
|
+
- A duplicate id raises `Ndb::UniqueViolation` (code `duplicate_id`). A broken unique field raises it too (code `unique_violation`).
|
|
92
|
+
- A missing document raises `Ndb::NotFound`.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
begin
|
|
96
|
+
ndb.update("calls", "c1", set: { status: "done" }, if_version: version)
|
|
97
|
+
rescue Ndb::VersionConflict
|
|
98
|
+
version = ndb.find("calls", where: { call_id: "c1" }, versions: true).rows.first["_v"]
|
|
99
|
+
retry
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Find
|
|
104
|
+
|
|
105
|
+
`find` returns a `Ndb::Page` with `rows` (an array of Hash), `next_cursor`, `plan`, `examined` and `total`.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
page = ndb.find("calls",
|
|
109
|
+
where: { status: "answered", duration: { "$gte" => 30, "$lte" => 60 } },
|
|
110
|
+
sort: { at: -1 },
|
|
111
|
+
fields: %w[call_id status],
|
|
112
|
+
limit: 50,
|
|
113
|
+
skip: 0)
|
|
114
|
+
|
|
115
|
+
page.rows # => [{"call_id" => "c1", "status" => "answered"}, ...]
|
|
116
|
+
page.plan # "id", "index:status", "scan" or "snapshot"
|
|
117
|
+
page.examined # rows the daemon looked at
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The body of `find` is the JSON of the [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md): `where`, `sort`, `fields`, `limit`, `skip`, `after`, `count`, `explain`, `versions`. Symbol keys are fine.
|
|
121
|
+
|
|
122
|
+
In `find`, `where` is sent as it is: write the daemon operators yourself. (Only `watch` turns Ruby values such as Range and Array into operators, see below.)
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
ndb.find("calls", where: { status: { "$in" => %w[answered failed] } })
|
|
126
|
+
ndb.find("calls", where: { duration: { "$gte" => 30, "$lte" => 60 } })
|
|
127
|
+
ndb.find("calls", where: { "$or" => [{ status: "failed" }, { duration: { "$gt" => 50 } }] })
|
|
128
|
+
ndb.find("calls", where: { "agent.id" => 7 }) # dot path
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- `sort: { at: -1, call_id: 1 }`: `1` ascending, `-1` descending. The key order is the sort order. The id is always the last key.
|
|
132
|
+
- `versions: true` adds the field `_v` (the document version) to each row.
|
|
133
|
+
|
|
134
|
+
### Regular expressions
|
|
135
|
+
|
|
136
|
+
A Ruby `Regexp` is accepted anywhere a value is: a field value, `"$regex" => /x/`, inside `$not`, `$or`, `$and`, in `find`, `aggregate` (`$match`) and in the `where:` of `watch`.
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
ndb.find("calls", where: { name: /^alpha/i }) # i: ignore case
|
|
140
|
+
ndb.find("calls", where: { name: { "$regex" => /bet$/ } })
|
|
141
|
+
ndb.find("calls", where: { "$or" => [{ name: /^a/ }, { name: /^b/ }] })
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The daemon uses RE2. The mapping to `$options` is:
|
|
145
|
+
|
|
146
|
+
| Ruby | `$options` |
|
|
147
|
+
|---|---|
|
|
148
|
+
| always | `m` (Ruby `^` and `$` match at each line, so the daemon must do the same) |
|
|
149
|
+
| `/i` (IGNORECASE) | `i` |
|
|
150
|
+
| `/m` (MULTILINE: `.` matches a newline) | `s` |
|
|
151
|
+
| `/x` (EXTENDED) | `x` |
|
|
152
|
+
|
|
153
|
+
Ruby features that RE2 does not have raise `Ndb::InvalidQuery` (code `invalid_query`) **before** any request: look-around (`(?=`, `(?!`, `(?<=`, `(?<!`), backreferences (`\1` to `\9`, `\k<name>`), atomic groups (`(?>`), possessive quantifiers (`a++`), `\h`, `\G`, `\R` and `\X`.
|
|
154
|
+
|
|
155
|
+
A String pattern is sent unchanged and has no `m`: `where: { name: { "$regex" => "^abc" } }`. A pattern anchored with `^` and a literal start, without `i` or `m`, uses the index on the field (`page.plan` is `index:name`). A Regexp always sends `m`, so it is a scan filter, not an index range. For a prefix search that uses the index, send the String form.
|
|
156
|
+
|
|
157
|
+
### Count
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
page = ndb.find("calls", where: { status: "answered" }, count: true, limit: 1)
|
|
161
|
+
page.total # how many documents match, whatever the limit
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Cursor pagination
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
cursor = nil
|
|
168
|
+
|
|
169
|
+
loop do
|
|
170
|
+
page = ndb.find("calls", where: { status: "answered" }, sort: { call_id: 1 }, limit: 100, after: cursor)
|
|
171
|
+
|
|
172
|
+
page.rows.each { |row| puts row["call_id"] }
|
|
173
|
+
|
|
174
|
+
cursor = page.next_cursor
|
|
175
|
+
|
|
176
|
+
break if cursor.nil? # nil on the last page
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
A cursor works only with the same `where` and `sort`. Another query shape raises `Ndb::InvalidCursor`.
|
|
181
|
+
|
|
182
|
+
## Aggregate
|
|
183
|
+
|
|
184
|
+
The pipeline needs exactly one `$group`. Accumulators: `$count`, `$sum`, `$avg`, `$min`, `$max`, `$first`, `$last`.
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
page = ndb.aggregate("calls",
|
|
188
|
+
pipeline: [
|
|
189
|
+
{ "$match" => { "duration" => { "$gte" => 1 } } },
|
|
190
|
+
{ "$group" => {
|
|
191
|
+
"_id" => "$status",
|
|
192
|
+
"n" => { "$count" => {} },
|
|
193
|
+
"avg" => { "$avg" => "$duration" }
|
|
194
|
+
} },
|
|
195
|
+
{ "$sort" => { "n" => -1 } }
|
|
196
|
+
],
|
|
197
|
+
limit: 100,
|
|
198
|
+
count: true)
|
|
199
|
+
|
|
200
|
+
page.rows # => [{"_id" => "answered", "n" => 2, "avg" => 35.5}, ...]
|
|
201
|
+
page.total # with count: true
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`$dateTrunc` units are `minute`, `hour`, `day`, `week` and `month`.
|
|
205
|
+
|
|
206
|
+
## Watch
|
|
207
|
+
|
|
208
|
+
A watch streams the changes of a collection to a named consumer: first the events after its last ack, then new events as they happen. Use it for outbox-style work.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
begin
|
|
212
|
+
ndb.watch("calls", "relay", where: { status: "failed" }) do |event|
|
|
213
|
+
# event has "pos", "op" ("put" or "del"), "id", "v" and "doc"
|
|
214
|
+
handle(event)
|
|
215
|
+
|
|
216
|
+
ndb.ack("calls", "relay", event["pos"])
|
|
217
|
+
end
|
|
218
|
+
rescue Ndb::WatchLagged
|
|
219
|
+
retry # the consumer was too slow. watch continues after the last ack.
|
|
220
|
+
rescue Ndb::HistoryTruncated
|
|
221
|
+
raise # the saved position is too old. Start over.
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- `where:` takes the same conditions as the models of `ndb-rails`: equality, an Array (`$in`), a Range, a Regexp (see Regular expressions), `nil` (`$exists: false`) and `$` operators as keys. Only matching events come. A delete event has no `"doc"`; it is sent only if this stream saw an earlier matching put of that id.
|
|
226
|
+
- `from: "now"` starts a new consumer at the end. `limit: n` ends the stream after `n` events.
|
|
227
|
+
- Without a block, `watch` returns an Enumerator: `ndb.watch("calls", "relay", limit: 10).to_a`.
|
|
228
|
+
- Delivery is at least once. After a restart an event can come again, so make the work safe to repeat. `ack` stores the position of an event: the next `watch` with that consumer name starts after it.
|
|
229
|
+
- `HistoryTruncated` means the WAL files for the position were deleted (`wal_retention`).
|
|
230
|
+
- A watch holds one connection until it ends. It is retried only for the initial open, never in the middle of a stream.
|
|
231
|
+
|
|
232
|
+
## Blobs
|
|
233
|
+
|
|
234
|
+
Large values live in the blob store. A document keeps only a reference.
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
stored = ndb.put_blob(File.open("call.wav"), "audio/wav") # an IO, streamed in chunks
|
|
238
|
+
stored # => {"$blob" => "sha256:...", "size" => 123456, "type" => "audio/wav"}
|
|
239
|
+
|
|
240
|
+
ndb.insert("calls", [{ call_id: "c9", recording: stored }])
|
|
241
|
+
|
|
242
|
+
ndb.get_blob(stored["$blob"]) # all the bytes
|
|
243
|
+
ndb.get_blob(stored["$blob"], from: 0, to: 1023) # bytes 0..1023 (inclusive)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The same content always gives the same reference. An IO upload is never retried.
|
|
247
|
+
|
|
248
|
+
## Errors
|
|
249
|
+
|
|
250
|
+
All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `status` (the HTTP status).
|
|
251
|
+
|
|
252
|
+
| Class | Code |
|
|
253
|
+
|---|---|
|
|
254
|
+
| `Unauthorized` | `unauthorized` |
|
|
255
|
+
| `NotReady` | `not_ready` |
|
|
256
|
+
| `NotFound` | `not_found` |
|
|
257
|
+
| `UnknownCollection` | `unknown_collection` |
|
|
258
|
+
| `VersionConflict` | `version_conflict` |
|
|
259
|
+
| `UniqueViolation` | `unique_violation`, `duplicate_id` |
|
|
260
|
+
| `CatalogConflict` | `catalog_conflict` |
|
|
261
|
+
| `CatalogInvalid` | `catalog_invalid` |
|
|
262
|
+
| `ReadOnlyCollection` | `direct_collection` |
|
|
263
|
+
| `InvalidQuery` | `invalid_query`, `invalid_request` |
|
|
264
|
+
| `InvalidCursor` | `cursor_invalid`, `cursor_mismatch`, `cursor_expired` |
|
|
265
|
+
| `WatchLagged` | `watch_lagged` |
|
|
266
|
+
| `HistoryTruncated` | `history_truncated` |
|
|
267
|
+
| `ConnectionError` | cannot reach the daemon. Subclasses: `ConnectError` (nothing was sent), `TimeoutError` |
|
|
268
|
+
|
|
269
|
+
Any other code is a plain `Ndb::Error`. A response that is not a daemon error has the code `http_status`.
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
begin
|
|
273
|
+
ndb.insert("calls", [{ call_id: "c1" }])
|
|
274
|
+
rescue Ndb::UniqueViolation => e
|
|
275
|
+
e.code # "duplicate_id"
|
|
276
|
+
e.status # 409
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Testing
|
|
281
|
+
|
|
282
|
+
The tests of this gem build the daemon from the repository (`go build ./cmd/ndb`), start it on a short temp unix socket (macOS allows 104 bytes), wait for `GET /health` to return 200, and stop it at exit. To do the same by hand:
|
|
283
|
+
|
|
284
|
+
```sh
|
|
285
|
+
ndb -config /nonexistent.yml -data-dir "$(mktemp -d)" -unix /tmp/ndb-test.sock
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
ndb = Ndb::Client.new(socket: "/tmp/ndb-test.sock")
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Use a new collection name per test. Run the suite with `bundle exec rake test` in `sdk/ruby`.
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT.
|
data/lib/ndb/client.rb
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Ndb
|
|
7
|
+
Page = Struct.new(:rows, :next_cursor, :plan, :examined, :total)
|
|
8
|
+
|
|
9
|
+
# The HTTP API of the daemon (plan §12.1). It builds JSON and reads
|
|
10
|
+
# NDJSON; it has no query semantics.
|
|
11
|
+
class Client
|
|
12
|
+
DEFAULT_SOCKET = "/var/run/ndb/ndb.sock"
|
|
13
|
+
|
|
14
|
+
attr_reader :pool, :endpoint
|
|
15
|
+
|
|
16
|
+
RETRY_BASE = 0.05
|
|
17
|
+
RETRY_CAP = 1.0
|
|
18
|
+
|
|
19
|
+
# The address is, in order: url:, socket:, env NDB_URL, the default
|
|
20
|
+
# socket. The token is token:, or env NDB_TOKEN. It goes only in the
|
|
21
|
+
# Authorization header, and the unix socket does not need it.
|
|
22
|
+
#
|
|
23
|
+
# ready_timeout is how many seconds a call retries while the daemon is
|
|
24
|
+
# starting or recovering (not_ready, socket missing or refusing), with
|
|
25
|
+
# exponential backoff and jitter. 0 turns it off.
|
|
26
|
+
def initialize(url: nil, socket: nil, token: nil, timeout: 30, pool_size: 16, ready_timeout: 10)
|
|
27
|
+
@endpoint = Endpoint.parse(url || socket || presence(ENV["NDB_URL"]) || DEFAULT_SOCKET)
|
|
28
|
+
@token = presence(token) || presence(ENV["NDB_TOKEN"])
|
|
29
|
+
@pool = Pool.new(@endpoint, timeout: timeout, size: pool_size)
|
|
30
|
+
@ready_timeout = ready_timeout.to_f
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def socket = @endpoint.path
|
|
34
|
+
|
|
35
|
+
def find(coll, body)
|
|
36
|
+
page(post(coll, "find", Conditions.regexps(body)))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def aggregate(coll, body)
|
|
40
|
+
page(post(coll, "aggregate", Conditions.regexps(body)))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def insert(coll, docs)
|
|
44
|
+
json(post(coll, "insert", insert: docs)).fetch("results")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def update(coll, id, set: {}, unset: [], if_version: nil)
|
|
48
|
+
change = { id: id, set: set, unset: unset }
|
|
49
|
+
change[:if_version] = if_version if if_version
|
|
50
|
+
|
|
51
|
+
json(post(coll, "update", update: change))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delete(coll, id, if_version: nil)
|
|
55
|
+
change = { id: id }
|
|
56
|
+
change[:if_version] = if_version if if_version
|
|
57
|
+
|
|
58
|
+
json(post(coll, "delete", delete: change))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Streams the changes of a collection for a named consumer: first the ones
|
|
62
|
+
# after its last ack, then the new ones. Yields one Hash per event (pos,
|
|
63
|
+
# op, id, v, doc). where is the same Hash/Range conditions as find, and
|
|
64
|
+
# only the matching events come; limit ends the stream after that many
|
|
65
|
+
# events. Without a block it returns an Enumerator. Delivery is at least
|
|
66
|
+
# once: ack the pos of an event when its work is done.
|
|
67
|
+
#
|
|
68
|
+
# The stream raises WatchLagged when the consumer is too slow, and
|
|
69
|
+
# HistoryTruncated when its position is older than the kept WAL; call
|
|
70
|
+
# watch again for the first, start over for the second.
|
|
71
|
+
def watch(coll, consumer, where: nil, from: nil, limit: nil, &block)
|
|
72
|
+
return enum_for(:watch, coll, consumer, where: where, from: from, limit: limit) unless block
|
|
73
|
+
|
|
74
|
+
body = { consumer: consumer, from: from, limit: limit }.compact
|
|
75
|
+
body[:where] = Conditions.equality(where) if where
|
|
76
|
+
|
|
77
|
+
with_ready_retry { stream_events("#{path(coll)}/watch", body, &block) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Stores the position of an event for its consumer: the next watch with
|
|
81
|
+
# that consumer name starts after it.
|
|
82
|
+
def ack(coll, consumer, pos)
|
|
83
|
+
json(post(coll, "ack", consumer: consumer, pos: pos))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ensure_collection(coll, definition)
|
|
87
|
+
json(request("PUT", path(coll), definition))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def collections
|
|
91
|
+
json(request("GET", "/v1/c", nil)).fetch("collections")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Uploads an IO (streamed) and returns the reference hash.
|
|
95
|
+
def put_blob(io, content_type = nil)
|
|
96
|
+
json(request("PUT", "/v1/blobs", io, headers: { "Content-Type" => content_type || "application/octet-stream" }))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The bytes of a blob, or of its range from..to (inclusive).
|
|
100
|
+
def get_blob(ref, from: nil, to: nil)
|
|
101
|
+
headers = from ? { "Range" => "bytes=#{from}-#{to}" } : {}
|
|
102
|
+
|
|
103
|
+
request("GET", "/v1/blobs/#{ref}", nil, headers: headers).body
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def presence(value)
|
|
109
|
+
value.to_s.strip.empty? ? nil : value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def post(coll, verb, body)
|
|
113
|
+
request("POST", "#{path(coll)}/#{verb}", body)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def path(coll)
|
|
117
|
+
"/v1/c/#{URI.encode_www_form_component(coll).gsub('+', '%20')}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def request(method, target, body, headers: {})
|
|
121
|
+
payload = encode(body)
|
|
122
|
+
headers = authorize(headers)
|
|
123
|
+
|
|
124
|
+
safe = idempotent?(method, target) && !payload.respond_to?(:read)
|
|
125
|
+
|
|
126
|
+
with_ready_retry(idempotent: safe) do
|
|
127
|
+
response = @pool.with { |conn| conn.request(method, target, payload, headers: headers, idempotent: safe) }
|
|
128
|
+
|
|
129
|
+
raise_api_error(response) if response.status >= 300
|
|
130
|
+
|
|
131
|
+
response
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def idempotent?(method, target)
|
|
136
|
+
method == "GET" || target.match?(%r{/(find|count|aggregate|get|stats)\z})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Runs the block again while the daemon is not ready. It repeats only
|
|
140
|
+
# when the request certainly was not processed (not_ready, a failed
|
|
141
|
+
# connect), or for a read when the connection dropped. Never a timeout.
|
|
142
|
+
def with_ready_retry(idempotent: false)
|
|
143
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @ready_timeout
|
|
144
|
+
delay = RETRY_BASE
|
|
145
|
+
|
|
146
|
+
begin
|
|
147
|
+
yield
|
|
148
|
+
rescue NotReady, ConnectError, ConnectionError => e
|
|
149
|
+
raise if @ready_timeout <= 0 || e.is_a?(TimeoutError)
|
|
150
|
+
raise unless e.is_a?(NotReady) || e.is_a?(ConnectError) || idempotent
|
|
151
|
+
|
|
152
|
+
wait = (delay / 2) + (rand * delay / 2)
|
|
153
|
+
|
|
154
|
+
raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait > deadline
|
|
155
|
+
|
|
156
|
+
sleep wait
|
|
157
|
+
|
|
158
|
+
delay = [delay * 2, RETRY_CAP].min
|
|
159
|
+
|
|
160
|
+
retry
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def authorize(headers)
|
|
165
|
+
@token ? headers.merge("Authorization" => "Bearer #{@token}") : headers
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# JSON for a Hash, "" for nothing, an IO as it is (blob upload).
|
|
169
|
+
def encode(body)
|
|
170
|
+
return "" if body.nil?
|
|
171
|
+
return body if body.respond_to?(:read)
|
|
172
|
+
|
|
173
|
+
JSON.generate(body)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def json(response)
|
|
177
|
+
JSON.parse(response.body)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def page(response)
|
|
181
|
+
headers = response.headers
|
|
182
|
+
|
|
183
|
+
Page.new(
|
|
184
|
+
ndjson(response.body),
|
|
185
|
+
headers["x-ndb-next"],
|
|
186
|
+
headers["x-ndb-plan"],
|
|
187
|
+
headers["x-ndb-examined"]&.to_i,
|
|
188
|
+
headers["x-ndb-total"]&.to_i
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def ndjson(body)
|
|
193
|
+
body.each_line.filter_map do |line|
|
|
194
|
+
line = line.strip
|
|
195
|
+
|
|
196
|
+
JSON.parse(line) unless line.empty?
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def stream_events(target, body, &block)
|
|
201
|
+
pending = +""
|
|
202
|
+
|
|
203
|
+
response = @pool.with do |conn|
|
|
204
|
+
conn.stream("POST", target, JSON.generate(body), headers: authorize({})) do |chunk|
|
|
205
|
+
pending << chunk
|
|
206
|
+
|
|
207
|
+
while (newline = pending.index("\n"))
|
|
208
|
+
emit_event(pending.slice!(0, newline + 1), &block)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
raise_api_error(response) if response.status >= 300
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A heartbeat is skipped; an error line raises.
|
|
217
|
+
def emit_event(line)
|
|
218
|
+
line = line.strip
|
|
219
|
+
|
|
220
|
+
return if line.empty?
|
|
221
|
+
|
|
222
|
+
event = JSON.parse(line)
|
|
223
|
+
|
|
224
|
+
return if event["heartbeat"]
|
|
225
|
+
|
|
226
|
+
if (error = event["error"])
|
|
227
|
+
raise Ndb.error_for(error["code"], error["message"], 200)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
yield event
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def raise_api_error(response)
|
|
234
|
+
parsed = JSON.parse(response.body)
|
|
235
|
+
|
|
236
|
+
raise Ndb.error_for("not_ready", "ndb is recovering", 503) if response.status == 503 && parsed["status"] == "recovering"
|
|
237
|
+
|
|
238
|
+
error = parsed.fetch("error")
|
|
239
|
+
|
|
240
|
+
raise Ndb.error_for(error["code"], error["message"], response.status)
|
|
241
|
+
rescue JSON::ParserError, KeyError
|
|
242
|
+
raise Error.new("HTTP #{response.status}: #{response.body.strip}", code: "http_status", status: response.status)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Ndb
|
|
6
|
+
# Ruby values -> the where JSON of the daemon (plan §12.2). No semantics:
|
|
7
|
+
# every operator is evaluated by the daemon.
|
|
8
|
+
module Conditions
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# {status: "x", n: 1..3} -> {"status" => "x", "n" => {"$gte" => 1, "$lte" => 3}}
|
|
12
|
+
# A key that starts with "$" ("$or", "$and", "$not") is passed as is.
|
|
13
|
+
def equality(attrs)
|
|
14
|
+
attrs.to_h do |field, value|
|
|
15
|
+
key = field.to_s
|
|
16
|
+
|
|
17
|
+
[key, key.start_with?("$") ? json_value(value) : condition(value)]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# where.not: every field is negated on its own.
|
|
22
|
+
def negation(attrs)
|
|
23
|
+
attrs.each_with_object({}) do |(field, value), out|
|
|
24
|
+
key = field.to_s
|
|
25
|
+
|
|
26
|
+
case value
|
|
27
|
+
when nil then out[key] = { "$exists" => true }
|
|
28
|
+
when Array then out[key] = { "$nin" => json_value(value) }
|
|
29
|
+
when Regexp then out[key] = { "$not" => json_value(value) }
|
|
30
|
+
when Range then out["$not"] = { key => condition(value) }
|
|
31
|
+
when Hash then out["$not"] = { key => condition(value) }
|
|
32
|
+
else out[key] = { "$ne" => json_value(value) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# AND of clauses: flat when the keys are apart, "$and" when they collide.
|
|
38
|
+
def combine(clauses)
|
|
39
|
+
clauses.reduce({}) do |acc, clause|
|
|
40
|
+
next acc.merge(clause) if (acc.keys & clause.keys).empty?
|
|
41
|
+
|
|
42
|
+
{ "$and" => [acc, clause] }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def condition(value)
|
|
47
|
+
case value
|
|
48
|
+
when nil then { "$exists" => false }
|
|
49
|
+
when Array then { "$in" => json_value(value) }
|
|
50
|
+
when Range then range(value)
|
|
51
|
+
when Hash then json_value(value)
|
|
52
|
+
else json_value(value)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def range(range)
|
|
57
|
+
out = {}
|
|
58
|
+
|
|
59
|
+
out["$gte"] = json_value(range.begin) unless range.begin.nil?
|
|
60
|
+
|
|
61
|
+
unless range.end.nil?
|
|
62
|
+
out[range.exclude_end? ? "$lt" : "$lte"] = json_value(range.end)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
out
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Times become RFC 3339 strings with milliseconds; the rest is JSON as is.
|
|
69
|
+
def json_value(value)
|
|
70
|
+
case value
|
|
71
|
+
when Time, DateTime then value.utc.iso8601(3)
|
|
72
|
+
when Date then value.iso8601
|
|
73
|
+
when Symbol then value.to_s
|
|
74
|
+
when Array then value.map { |v| json_value(v) }
|
|
75
|
+
when Regexp then regex(value)
|
|
76
|
+
when Hash then hash_value(value)
|
|
77
|
+
else time_like?(value) ? value.utc.iso8601(3) : value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def hash_value(hash)
|
|
82
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
83
|
+
if key.to_s == "$regex" && value.is_a?(Regexp)
|
|
84
|
+
out.merge!(regex(value))
|
|
85
|
+
else
|
|
86
|
+
out[key.to_s] = json_value(value)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Only the Regexp objects of a request body become $regex; every other
|
|
92
|
+
# value is left as it is (find and aggregate send the body as it is).
|
|
93
|
+
def regexps(value)
|
|
94
|
+
case value
|
|
95
|
+
when Regexp then regex(value)
|
|
96
|
+
when Array then value.map { |v| regexps(v) }
|
|
97
|
+
when Hash
|
|
98
|
+
value.each_with_object({}) do |(key, v), out|
|
|
99
|
+
if key.to_s == "$regex" && v.is_a?(Regexp)
|
|
100
|
+
out.merge!(regex(v))
|
|
101
|
+
else
|
|
102
|
+
out[key] = regexps(v)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
else value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A Ruby Regexp -> {"$regex" => source, "$options" => flags}. The flag m
|
|
110
|
+
# is always set: in Ruby ^ and $ match at each line. IGNORECASE is i,
|
|
111
|
+
# MULTILINE (dot matches a newline) is s, EXTENDED is x.
|
|
112
|
+
def regex(regexp)
|
|
113
|
+
reject_unsupported!(regexp.source)
|
|
114
|
+
|
|
115
|
+
options = +"m"
|
|
116
|
+
|
|
117
|
+
options << "i" if regexp.options.anybits?(Regexp::IGNORECASE)
|
|
118
|
+
options << "s" if regexp.options.anybits?(Regexp::MULTILINE)
|
|
119
|
+
options << "x" if regexp.options.anybits?(Regexp::EXTENDED)
|
|
120
|
+
|
|
121
|
+
{ "$regex" => regexp.source, "$options" => options.chars.sort_by { |c| "imsx".index(c) }.join }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The daemon uses RE2. These Ruby features do not exist there, so they
|
|
125
|
+
# fail here, before any request.
|
|
126
|
+
def reject_unsupported!(source)
|
|
127
|
+
feature = unsupported_feature(source)
|
|
128
|
+
|
|
129
|
+
return unless feature
|
|
130
|
+
|
|
131
|
+
raise InvalidQuery.new("regexp /#{source}/ uses #{feature}, which RE2 does not support",
|
|
132
|
+
code: "invalid_query")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
LOOKAROUND = { "?=" => "look-ahead (?=", "?!" => "negative look-ahead (?!",
|
|
136
|
+
"?<=" => "look-behind (?<=", "?<!" => "negative look-behind (?<!",
|
|
137
|
+
"?>" => "an atomic group (?>" }.freeze
|
|
138
|
+
ESCAPES = { "h" => "\\h", "G" => "\\G", "R" => "\\R", "X" => "\\X", "k" => "\\k<name>" }.freeze
|
|
139
|
+
|
|
140
|
+
def unsupported_feature(source)
|
|
141
|
+
in_class = false
|
|
142
|
+
quantifier = false
|
|
143
|
+
i = 0
|
|
144
|
+
|
|
145
|
+
while i < source.length
|
|
146
|
+
char = source[i]
|
|
147
|
+
|
|
148
|
+
if char == "\\"
|
|
149
|
+
nxt = source[i + 1].to_s
|
|
150
|
+
|
|
151
|
+
return "a backreference \\#{nxt}" if nxt.match?(/[1-9]/)
|
|
152
|
+
return ESCAPES[nxt] if ESCAPES.key?(nxt)
|
|
153
|
+
|
|
154
|
+
quantifier = false
|
|
155
|
+
i += 2
|
|
156
|
+
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if in_class
|
|
161
|
+
in_class = false if char == "]"
|
|
162
|
+
elsif char == "["
|
|
163
|
+
in_class = true
|
|
164
|
+
i += 1 if source[i + 1] == "^"
|
|
165
|
+
i += 1 if source[i + 1] == "]"
|
|
166
|
+
elsif char == "("
|
|
167
|
+
LOOKAROUND.each { |prefix, name| return name if source[i + 1, prefix.length] == prefix }
|
|
168
|
+
elsif char == "+" && quantifier
|
|
169
|
+
return "a possessive quantifier"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
quantifier = !in_class && %w[* + ?].include?(char) && !(char == "?" && source[i - 1] == "(")
|
|
173
|
+
i += 1
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# ActiveSupport::TimeWithZone and the like, without a dependency on them.
|
|
180
|
+
def time_like?(value)
|
|
181
|
+
value.respond_to?(:utc) && value.respond_to?(:iso8601) && !value.is_a?(Date)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Ndb
|
|
7
|
+
Response = Struct.new(:status, :headers, :body)
|
|
8
|
+
|
|
9
|
+
# Where the daemon listens: a unix socket path, or a TCP host and port.
|
|
10
|
+
# The host name is resolved each time a connection opens, so a changed
|
|
11
|
+
# DNS record is followed after a reconnect.
|
|
12
|
+
class Endpoint
|
|
13
|
+
attr_reader :path, :host, :port
|
|
14
|
+
|
|
15
|
+
# "unix:///path", "tcp://host:port", or a bare socket path.
|
|
16
|
+
def self.parse(address)
|
|
17
|
+
address = address.to_s
|
|
18
|
+
|
|
19
|
+
return new(path: address) if address.start_with?("/")
|
|
20
|
+
|
|
21
|
+
uri = URI.parse(address)
|
|
22
|
+
|
|
23
|
+
case uri.scheme
|
|
24
|
+
when "unix" then new(path: uri.path)
|
|
25
|
+
when "tcp" then new(host: uri.host, port: uri.port)
|
|
26
|
+
else raise ArgumentError, "ndb address #{address.inspect} must be unix:///path or tcp://host:port"
|
|
27
|
+
end
|
|
28
|
+
rescue URI::InvalidURIError
|
|
29
|
+
raise ArgumentError, "ndb address #{address.inspect} must be unix:///path or tcp://host:port"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(path: nil, host: nil, port: nil)
|
|
33
|
+
if path.to_s.empty? && (host.to_s.empty? || port.nil?)
|
|
34
|
+
raise ArgumentError, "ndb address needs a socket path or a host and port"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@path = path
|
|
38
|
+
@host = host
|
|
39
|
+
@port = port
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def unix? = !@path.to_s.empty?
|
|
43
|
+
|
|
44
|
+
def to_s = unix? ? "unix://#{@path}" : "tcp://#{@host}:#{@port}"
|
|
45
|
+
|
|
46
|
+
def host_header = unix? ? "ndb" : "#{@host}:#{@port}"
|
|
47
|
+
|
|
48
|
+
def connect(timeout)
|
|
49
|
+
return UNIXSocket.new(@path) if unix?
|
|
50
|
+
|
|
51
|
+
TCPSocket.new(@host, @port, connect_timeout: timeout).tap { |sock| sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# One HTTP/1.1 keep-alive connection on a unix socket or TCP. Not
|
|
56
|
+
# thread-safe: the Pool hands one connection to one thread at a time.
|
|
57
|
+
class Connection
|
|
58
|
+
STREAM_CHUNK = 256 * 1024
|
|
59
|
+
|
|
60
|
+
attr_reader :socket
|
|
61
|
+
|
|
62
|
+
def initialize(endpoint, timeout)
|
|
63
|
+
@endpoint = endpoint
|
|
64
|
+
@timeout = timeout
|
|
65
|
+
@socket = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# body is a String, or an IO that is sent chunked and never retried. A
|
|
69
|
+
# dropped reused connection is sent again only when idempotent is true
|
|
70
|
+
# (a read): a write may already have been processed.
|
|
71
|
+
def request(method, target, body, headers: {}, idempotent: false)
|
|
72
|
+
reused = !@socket.nil?
|
|
73
|
+
|
|
74
|
+
begin
|
|
75
|
+
send_request(method, target, body, headers)
|
|
76
|
+
read_response
|
|
77
|
+
rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
78
|
+
close
|
|
79
|
+
|
|
80
|
+
raise unless reused && idempotent && !body.respond_to?(:read)
|
|
81
|
+
|
|
82
|
+
reused = false
|
|
83
|
+
|
|
84
|
+
retry
|
|
85
|
+
end
|
|
86
|
+
rescue SystemCallError, EOFError, IOError => e
|
|
87
|
+
close
|
|
88
|
+
|
|
89
|
+
raise ConnectionError, "cannot reach ndb at #{@endpoint}: #{e.message}"
|
|
90
|
+
rescue IO::TimeoutError
|
|
91
|
+
close
|
|
92
|
+
|
|
93
|
+
raise TimeoutError, "ndb did not answer within #{@timeout}s"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Sends the request and yields the body of a 2xx response chunk by chunk,
|
|
97
|
+
# as it arrives (a watch stream). The connection is closed at the end: a
|
|
98
|
+
# stream is never reused. An error response is read whole and returned.
|
|
99
|
+
def stream(method, target, body, headers: {}, &)
|
|
100
|
+
close
|
|
101
|
+
|
|
102
|
+
send_request(method, target, body, headers)
|
|
103
|
+
|
|
104
|
+
status = @socket.gets("\r\n") or raise EOFError
|
|
105
|
+
response_headers = read_headers
|
|
106
|
+
code = status.split(" ", 3)[1].to_i
|
|
107
|
+
|
|
108
|
+
return Response.new(code, response_headers, read_body(response_headers)) if code >= 300
|
|
109
|
+
|
|
110
|
+
each_body_chunk(response_headers, &)
|
|
111
|
+
|
|
112
|
+
Response.new(code, response_headers, nil)
|
|
113
|
+
rescue SystemCallError, EOFError, IOError => e
|
|
114
|
+
raise ConnectionError, "cannot reach ndb at #{@endpoint}: #{e.message}"
|
|
115
|
+
rescue IO::TimeoutError
|
|
116
|
+
raise TimeoutError, "ndb did not answer within #{@timeout}s"
|
|
117
|
+
ensure
|
|
118
|
+
close
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def close
|
|
122
|
+
@socket&.close
|
|
123
|
+
rescue IOError
|
|
124
|
+
nil
|
|
125
|
+
ensure
|
|
126
|
+
@socket = nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def open_socket
|
|
132
|
+
@socket = @endpoint.connect(@timeout)
|
|
133
|
+
@socket.timeout = @timeout
|
|
134
|
+
rescue SystemCallError, SocketError => e
|
|
135
|
+
raise ConnectError, "cannot reach ndb at #{@endpoint}: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def send_request(method, target, body, headers)
|
|
139
|
+
open_socket unless @socket
|
|
140
|
+
|
|
141
|
+
head = +"#{method} #{target} HTTP/1.1\r\nHost: #{@endpoint.host_header}\r\nAccept: application/json\r\n"
|
|
142
|
+
|
|
143
|
+
{ "Content-Type" => "application/json" }.merge(headers).each { |name, value| head << "#{name}: #{value}\r\n" }
|
|
144
|
+
|
|
145
|
+
return send_streamed(head, body) if body.respond_to?(:read)
|
|
146
|
+
|
|
147
|
+
head << "Content-Length: #{body.bytesize}\r\n\r\n"
|
|
148
|
+
|
|
149
|
+
@socket.write(head, body)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def send_streamed(head, io)
|
|
153
|
+
@socket.write(head, "Transfer-Encoding: chunked\r\n\r\n")
|
|
154
|
+
|
|
155
|
+
while (chunk = io.read(STREAM_CHUNK))
|
|
156
|
+
@socket.write(chunk.bytesize.to_s(16), "\r\n", chunk, "\r\n")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@socket.write("0\r\n\r\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def read_response
|
|
163
|
+
status = @socket.gets("\r\n") or raise EOFError
|
|
164
|
+
headers = read_headers
|
|
165
|
+
body = read_body(headers)
|
|
166
|
+
|
|
167
|
+
close if headers["connection"].to_s.casecmp?("close")
|
|
168
|
+
|
|
169
|
+
Response.new(status.split(" ", 3)[1].to_i, headers, body)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def read_headers
|
|
173
|
+
headers = {}
|
|
174
|
+
|
|
175
|
+
while (line = @socket.gets("\r\n")) && line != "\r\n"
|
|
176
|
+
name, value = line.split(":", 2)
|
|
177
|
+
headers[name.downcase] = value.to_s.strip
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
headers
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def read_body(headers)
|
|
184
|
+
return read_chunked if headers["transfer-encoding"].to_s.casecmp?("chunked")
|
|
185
|
+
return (@socket.read(headers["content-length"].to_i) || +"") if headers["content-length"]
|
|
186
|
+
|
|
187
|
+
body = @socket.read.to_s
|
|
188
|
+
close
|
|
189
|
+
|
|
190
|
+
body
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def each_body_chunk(headers)
|
|
194
|
+
return yield(read_body(headers)) unless headers["transfer-encoding"].to_s.casecmp?("chunked")
|
|
195
|
+
|
|
196
|
+
loop do
|
|
197
|
+
size = @socket.gets("\r\n").to_s.to_i(16)
|
|
198
|
+
|
|
199
|
+
break if size.zero?
|
|
200
|
+
|
|
201
|
+
yield @socket.read(size)
|
|
202
|
+
@socket.read(2)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def read_chunked
|
|
207
|
+
body = +""
|
|
208
|
+
|
|
209
|
+
loop do
|
|
210
|
+
size = @socket.gets("\r\n").to_s.to_i(16)
|
|
211
|
+
|
|
212
|
+
break if size.zero?
|
|
213
|
+
|
|
214
|
+
body << @socket.read(size)
|
|
215
|
+
@socket.read(2)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
@socket.gets("\r\n")
|
|
219
|
+
|
|
220
|
+
body
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A small thread-safe pool: one connection per in-flight request, idle
|
|
225
|
+
# ones are kept for keep-alive. It drops its connections after a fork.
|
|
226
|
+
class Pool
|
|
227
|
+
attr_reader :created
|
|
228
|
+
|
|
229
|
+
def initialize(endpoint, timeout:, size:)
|
|
230
|
+
@endpoint = endpoint
|
|
231
|
+
@timeout = timeout
|
|
232
|
+
@size = size
|
|
233
|
+
@mutex = Mutex.new
|
|
234
|
+
@idle = []
|
|
235
|
+
@created = 0
|
|
236
|
+
@pid = Process.pid
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def with
|
|
240
|
+
conn = checkout
|
|
241
|
+
|
|
242
|
+
begin
|
|
243
|
+
yield conn
|
|
244
|
+
rescue StandardError
|
|
245
|
+
conn.close
|
|
246
|
+
|
|
247
|
+
raise
|
|
248
|
+
ensure
|
|
249
|
+
checkin(conn)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def idle_connections
|
|
254
|
+
@mutex.synchronize { @idle.dup }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def checkout
|
|
260
|
+
@mutex.synchronize do
|
|
261
|
+
reset_after_fork
|
|
262
|
+
|
|
263
|
+
return @idle.pop if @idle.any?
|
|
264
|
+
|
|
265
|
+
@created += 1
|
|
266
|
+
|
|
267
|
+
Connection.new(@endpoint, @timeout)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def checkin(conn)
|
|
272
|
+
@mutex.synchronize do
|
|
273
|
+
@idle << conn if @idle.size < @size
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def reset_after_fork
|
|
278
|
+
return if @pid == Process.pid
|
|
279
|
+
|
|
280
|
+
@idle.clear
|
|
281
|
+
@pid = Process.pid
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
data/lib/ndb/errors.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ndb
|
|
4
|
+
# An error the daemon answered (code + HTTP status), or a transport failure.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :code, :status
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, code: nil, status: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
|
|
11
|
+
@code = code
|
|
12
|
+
@status = status
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class ConnectionError < Error; end
|
|
17
|
+
class TimeoutError < ConnectionError; end
|
|
18
|
+
|
|
19
|
+
# The connect failed: the socket is missing or refuses. Nothing was sent.
|
|
20
|
+
class ConnectError < ConnectionError; end
|
|
21
|
+
|
|
22
|
+
# The daemon is up but still recovering (HTTP 503). Nothing was done.
|
|
23
|
+
class NotReady < Error; end
|
|
24
|
+
|
|
25
|
+
class Unauthorized < Error; end
|
|
26
|
+
class NotFound < Error; end
|
|
27
|
+
class UnknownCollection < Error; end
|
|
28
|
+
class VersionConflict < Error; end
|
|
29
|
+
class UniqueViolation < Error; end
|
|
30
|
+
class CatalogConflict < Error; end
|
|
31
|
+
class CatalogInvalid < Error; end
|
|
32
|
+
class ReadOnlyCollection < Error; end
|
|
33
|
+
class InvalidQuery < Error; end
|
|
34
|
+
class InvalidCursor < Error; end
|
|
35
|
+
|
|
36
|
+
# The consumer was too slow: call watch again, it resumes from the last ack.
|
|
37
|
+
class WatchLagged < Error; end
|
|
38
|
+
|
|
39
|
+
# The WAL files that hold the history were deleted (wal_retention): the
|
|
40
|
+
# consumer cannot resume from its position and must start over.
|
|
41
|
+
class HistoryTruncated < Error; end
|
|
42
|
+
|
|
43
|
+
# The daemon error code -> the Ruby class.
|
|
44
|
+
CODE_TO_ERROR = {
|
|
45
|
+
"unauthorized" => Unauthorized,
|
|
46
|
+
"not_ready" => NotReady,
|
|
47
|
+
"not_found" => NotFound,
|
|
48
|
+
"unknown_collection" => UnknownCollection,
|
|
49
|
+
"version_conflict" => VersionConflict,
|
|
50
|
+
"unique_violation" => UniqueViolation,
|
|
51
|
+
"duplicate_id" => UniqueViolation,
|
|
52
|
+
"catalog_conflict" => CatalogConflict,
|
|
53
|
+
"catalog_invalid" => CatalogInvalid,
|
|
54
|
+
"direct_collection" => ReadOnlyCollection,
|
|
55
|
+
"invalid_query" => InvalidQuery,
|
|
56
|
+
"invalid_request" => InvalidQuery,
|
|
57
|
+
"cursor_invalid" => InvalidCursor,
|
|
58
|
+
"cursor_mismatch" => InvalidCursor,
|
|
59
|
+
"cursor_expired" => InvalidCursor,
|
|
60
|
+
"watch_lagged" => WatchLagged,
|
|
61
|
+
"history_truncated" => HistoryTruncated
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
def self.error_for(code, message, status)
|
|
65
|
+
CODE_TO_ERROR.fetch(code, Error).new("#{code}: #{message}", code: code, status: status)
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/ndb/version.rb
ADDED
data/lib/ndb.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ndb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- thadeu
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Ndb::Client talks HTTP to ndb over a unix socket or TCP: collections,
|
|
13
|
+
documents with versions, queries, aggregates, watch streams and blobs. Stdlib only,
|
|
14
|
+
no runtime dependencies. The daemon decides every query semantic.'
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- lib/ndb.rb
|
|
22
|
+
- lib/ndb/client.rb
|
|
23
|
+
- lib/ndb/conditions.rb
|
|
24
|
+
- lib/ndb/connection.rb
|
|
25
|
+
- lib/ndb/errors.rb
|
|
26
|
+
- lib/ndb/version.rb
|
|
27
|
+
homepage: https://github.com/thadeu/ndb
|
|
28
|
+
licenses:
|
|
29
|
+
- MIT
|
|
30
|
+
metadata:
|
|
31
|
+
homepage_uri: https://github.com/thadeu/ndb
|
|
32
|
+
source_code_uri: https://github.com/thadeu/ndb/tree/main/sdk/ruby
|
|
33
|
+
changelog_uri: https://github.com/thadeu/ndb/blob/main/CHANGELOG.md
|
|
34
|
+
bug_tracker_uri: https://github.com/thadeu/ndb/issues
|
|
35
|
+
rubygems_mfa_required: 'true'
|
|
36
|
+
rdoc_options: []
|
|
37
|
+
require_paths:
|
|
38
|
+
- lib
|
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '3.2'
|
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0'
|
|
49
|
+
requirements: []
|
|
50
|
+
rubygems_version: 3.6.9
|
|
51
|
+
specification_version: 4
|
|
52
|
+
summary: Plain Ruby client for the ndb NDJSON query daemon
|
|
53
|
+
test_files: []
|