ndb 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +204 -99
- data/lib/ndb/client.rb +153 -80
- data/lib/ndb/collection.rb +269 -0
- data/lib/ndb/connection.rb +2 -2
- data/lib/ndb/version.rb +1 -1
- data/lib/ndb.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3cee02332a613d6dcb575f559504c2efde35cece0259791103d63113fa485ac1
|
|
4
|
+
data.tar.gz: 7a81d66e1e69c9208d24556c59e5249e413a9e71d45e774b581a05bca237df22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29f3541621bd7f13917db6d2ccff09d5b400728ac14d9758df26dea0df529c43db95e0420a7d3977cfe2f3872d54be246ca18926b8cc3cbafd18ca3c2ef17945
|
|
7
|
+
data.tar.gz: e6904af991afc68bf5a131d299fe789c3b07225ba87ae1175d43e9ee468104b2f4a74e6e8e499d064d97ce56f907f253dc1ea641291d52691ec2021db09f6eae
|
data/README.md
CHANGED
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
+
It has two layers:
|
|
8
|
+
|
|
9
|
+
- `Ndb.connect` and `Ndb::Collection`: a collection with a query builder (`query`, `sort`, `limit`, `each`). Start here.
|
|
10
|
+
- `Ndb::Client`: the low-level layer, one method per HTTP route. `Ndb.connect` returns one.
|
|
11
|
+
|
|
7
12
|
Building a Rails app with models? Use [`ndb-rails`](https://github.com/thadeu/ndb/tree/main/sdk/rails) on top of this gem.
|
|
8
13
|
|
|
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
|
|
14
|
+
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.md).
|
|
10
15
|
|
|
11
16
|
## Install
|
|
12
17
|
|
|
@@ -29,13 +34,14 @@ require "ndb"
|
|
|
29
34
|
## Connect
|
|
30
35
|
|
|
31
36
|
```ruby
|
|
32
|
-
ndb = Ndb
|
|
33
|
-
ndb = Ndb
|
|
34
|
-
ndb = Ndb
|
|
35
|
-
|
|
37
|
+
ndb = Ndb.connect("unix:///run/ndb/ndb.sock") # unix socket
|
|
38
|
+
ndb = Ndb.connect("tcp://10.0.0.5:7070", token: "secret") # TCP with a bearer token
|
|
39
|
+
ndb = Ndb.connect # reads NDB_URL and NDB_TOKEN
|
|
40
|
+
|
|
41
|
+
ndb.health # => true when the daemon is ready (GET /_health)
|
|
36
42
|
```
|
|
37
43
|
|
|
38
|
-
All options:
|
|
44
|
+
`Ndb.connect(addr, **opts)` returns an `Ndb::Client`. It is the same as `Ndb::Client.new(url: addr, **opts)`. All options:
|
|
39
45
|
|
|
40
46
|
```ruby
|
|
41
47
|
Ndb::Client.new(
|
|
@@ -48,130 +54,166 @@ Ndb::Client.new(
|
|
|
48
54
|
)
|
|
49
55
|
```
|
|
50
56
|
|
|
51
|
-
- The address is, in order: `url
|
|
57
|
+
- The address is, in order: `url:` (the `addr` of `Ndb.connect`), `socket:`, env `NDB_URL`, then the default socket `/var/run/ndb/ndb.sock`.
|
|
52
58
|
- The token is `token:`, or env `NDB_TOKEN`. It goes only in the `Authorization` header. A unix socket does not need it.
|
|
53
59
|
- A bad address raises `ArgumentError` at once.
|
|
54
60
|
- `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.
|
|
61
|
+
- `health` does not wait: a daemon that is recovering raises `Ndb::NotReady`.
|
|
55
62
|
- A reused keep-alive connection that the server closed is sent again only for a read. A write is never sent twice.
|
|
56
63
|
- The pool is thread-safe and drops its connections after a `fork`.
|
|
57
64
|
|
|
58
65
|
## Collections
|
|
59
66
|
|
|
60
67
|
```ruby
|
|
61
|
-
ndb.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
calls = ndb.collection("calls",
|
|
69
|
+
indexes: ["status"],
|
|
70
|
+
unique: ["caller"],
|
|
71
|
+
time: "at") # also: expires_field:, ttl:
|
|
65
72
|
|
|
66
|
-
ndb.collections # => [{"name" => "calls", ...}]
|
|
73
|
+
ndb.collections # => [{"name" => "calls", ...}] (GET /_collections)
|
|
67
74
|
```
|
|
68
75
|
|
|
69
|
-
|
|
76
|
+
- `collection` sends nothing. The first call on the collection declares it (`PUT /calls`) with this definition, once per object. A different definition for an existing collection raises `Ndb::CatalogConflict` on that call.
|
|
77
|
+
- A collection declared in `ndb.yml` needs no definition: `ndb.collection("app_logs")`.
|
|
78
|
+
- A name that ends with `*` is a collection pattern: `ndb.collection("logs-2026-*")` reads every collection whose name starts with `logs-2026-`. It declares nothing, and a write raises `Ndb::Error` with code `pattern_read_only`. Each row has `"_coll"`, the name of its collection.
|
|
70
79
|
|
|
71
|
-
`
|
|
80
|
+
Every collection has the primary key `_id`. You can send your own (any non-empty string), or leave it out: the daemon makes a UUIDv7 (`Ndb.new_id` makes the same kind of id on the client). For a natural key such as an email, keep `_id` and declare that field in `unique:`.
|
|
72
81
|
|
|
73
82
|
## Insert, update, delete
|
|
74
83
|
|
|
75
|
-
Every document has a version
|
|
84
|
+
Every document has a version, `_v`. A new document has version 1, and each change adds 1.
|
|
76
85
|
|
|
77
86
|
```ruby
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
calls.insert(_id: "c1", status: "answered", caller: "a", duration: 42) # one document (POST /calls/_doc)
|
|
88
|
+
# => {"_id" => "c1", "_v" => 1}
|
|
89
|
+
|
|
90
|
+
calls.insert([ # many in one transaction (POST /calls/_bulk)
|
|
91
|
+
{ _id: "c2", status: "failed", caller: "b", duration: 5 },
|
|
92
|
+
{ _id: "c3", status: "failed", caller: "c", duration: 7 }
|
|
81
93
|
])
|
|
82
|
-
|
|
94
|
+
# => [{"_id" => "c2", "_v" => 1}, {"_id" => "c3", "_v" => 1}]
|
|
95
|
+
|
|
96
|
+
calls.upsert(_id: "c2", status: "answered") # create by _id, or merge into the stored document
|
|
97
|
+
calls.upsert_all([{ _id: "c2", duration: 9 }, { _id: "c4", status: "new" }]) # one transaction
|
|
83
98
|
|
|
84
|
-
|
|
99
|
+
calls.get("c1") # => {"_id" => "c1", ..., "_v" => 1}, or nil (GET /calls/_doc/c1)
|
|
85
100
|
|
|
86
|
-
# Atomic update by _id (
|
|
87
|
-
|
|
101
|
+
# Atomic update by _id (POST /calls/_update/c1): the id, then the write operators.
|
|
102
|
+
calls.update("c1", { "$set" => { status: "done" }, "$unset" => { caller: true } })
|
|
88
103
|
# => {"matched" => 1, "modified" => 1, "doc" => {"_id" => "c1", ..., "_v" => 2}}
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
calls.update("c1", { "$set" => { duration: 50 } }, guard: { _v: 2 }) # only if the version is still 2
|
|
106
|
+
stock.update("a", { "$inc" => { qty: -1 } }, guard: { qty: 1.. }) # guarded decrement
|
|
107
|
+
counters.update("hits", { "$inc" => { n: 1 } }, upsert: true) # created with this _id when missing
|
|
92
108
|
|
|
93
|
-
|
|
94
|
-
|
|
109
|
+
# Atomic update by query (POST /calls/_update_by_query): the first match, or every match with many.
|
|
110
|
+
calls.update_by({ status: "failed" }, { "$set" => { status: "retry" } }, many: true) # => {"matched" => 2, "modified" => 2}
|
|
111
|
+
calls.update_by({ caller: "z" }, { "$inc" => { duration: 1 } }, upsert: true)
|
|
95
112
|
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
calls.delete("c2") # by _id => {"deleted" => 1} (DELETE /calls/_doc/c2)
|
|
114
|
+
calls.delete_by({ status: "retry" }, many: true) # by query (POST /calls/_delete_by_query)
|
|
98
115
|
```
|
|
99
116
|
|
|
100
117
|
- Operators: `$set`, `$setOnInsert` (like `$set`, but only when an upsert creates the document), `$unset`, `$inc`, `$push` (with `{ "$each" => [...] }`), `$pull`, `$min`, `$max`. Field names can be dot paths. A key of the operators without `$` raises `ArgumentError` before anything is sent.
|
|
101
|
-
- Nothing matched is not an error: `matched` is 0. A stale `_v`
|
|
102
|
-
- `
|
|
118
|
+
- Nothing matched is not an error: `matched` is 0. A stale `_v` guard matches nothing, so read again and retry. `modified` is 0 when the operators leave the document as it is.
|
|
119
|
+
- `update` takes `guard:`, `upsert:` (creates the document with that `_id`) and `expire_in:`. `update_by` takes `many:`, `upsert:` (creates the document from the equality fields of the query) and `expire_in:`. `delete_by` takes `many:`. The answer has `doc` only for one document.
|
|
120
|
+
- The query and the guard take the Ruby forms of `query` (below): an Array is `$in`, a Range is `$gte`/`$lte`, `nil` is `$exists: false`.
|
|
121
|
+
- `many: true` needs `_id` or an indexed field in the query (`Ndb::UnindexedMany`). An empty query raises `Ndb::EmptyFilter`.
|
|
103
122
|
- The contract errors are `Ndb::UnknownOperator`, `NoUpdate`, `ConflictingUpdate`, `InvalidField`, `UnindexedMany` and `EmptyFilter`, all under `Ndb::InvalidUpdate`.
|
|
104
|
-
- A duplicate `_id` raises `Ndb::UniqueViolation` (code `duplicate_id`). A broken unique field raises it too (code `unique_violation`).
|
|
123
|
+
- A duplicate `_id` raises `Ndb::UniqueViolation` (code `duplicate_id`), and a many-document insert writes nothing. A broken unique field raises it too (code `unique_violation`).
|
|
105
124
|
|
|
106
125
|
### Truncate and drop
|
|
107
126
|
|
|
108
127
|
```ruby
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
calls.truncate # every document goes; the definition and indexes stay => {"collection" => "calls", "truncated" => true}
|
|
129
|
+
calls.drop # the collection, its definition and its watch consumers go
|
|
111
130
|
```
|
|
112
131
|
|
|
113
132
|
- Both take the same time for 10 or 10 million documents. Watch consumers get one event with `"op" => "truncate"` (no `_id`), then the new changes.
|
|
114
|
-
-
|
|
133
|
+
- After `drop`, the next call on the collection declares it again. A collection declared in `ndb.yml` comes back, empty, when the daemon restarts.
|
|
134
|
+
- A direct-mode collection raises `Ndb::ReadOnlyCollection`.
|
|
115
135
|
|
|
116
136
|
## Expiry (TTL like Redis)
|
|
117
137
|
|
|
118
138
|
The collection needs `expires_field`.
|
|
119
139
|
|
|
120
140
|
```ruby
|
|
121
|
-
ndb.
|
|
141
|
+
sessions = ndb.collection("sessions", expires_field: "exp")
|
|
122
142
|
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
sessions.insert({ _id: "s1" }, expire_in: 3600) # seconds, or "1h" / "30m" / "1d"
|
|
144
|
+
sessions.update("s1", { "$set" => { user: "u" } }, expire_in: "30m")
|
|
125
145
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
sessions.expire("s1", in: 1800) # => {"_id", "_v", "expires_at"}
|
|
147
|
+
sessions.expire("s1", at: Time.now + 7200)
|
|
148
|
+
sessions.ttl("s1") # => 7199, -1 (never expires), or nil (missing or expired)
|
|
149
|
+
sessions.persist("s1") # => {"_id", "_v"}
|
|
130
150
|
```
|
|
131
151
|
|
|
132
152
|
- `expire_in` wins over a value of `expires_field` in the same call. `ttl` rounds up to whole seconds.
|
|
133
153
|
- `expire` and `persist` raise `Ndb::NotFound` for a missing or expired document. `Ndb::TtlNotEnabled` means no `expires_field`; `Ndb::TtlDerived` means the expiry comes from `ttl` + `time`.
|
|
134
154
|
- Disk space of expired documents returns after compaction, and fully only with `wal_retention`; see [Freeing disk](https://github.com/thadeu/ndb/blob/main/docs/dev/configuration.md#freeing-disk).
|
|
135
155
|
|
|
136
|
-
##
|
|
156
|
+
## Query
|
|
137
157
|
|
|
138
|
-
|
|
158
|
+
A query is immutable: every step returns a new one. The request (`POST /{coll}/_search`) goes out when you call `all`, `first`, `page`, `count` or `each`.
|
|
139
159
|
|
|
140
160
|
```ruby
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
calls.query(status: "answered").all
|
|
162
|
+
calls.query(status: %w[answered failed]) # $in
|
|
163
|
+
calls.query(duration: 30..60) # $gte and $lte
|
|
164
|
+
calls.query(duration: 30...60) # $gte and $lt
|
|
165
|
+
calls.query(duration: nil) # $exists: false
|
|
166
|
+
calls.query(at: Time.now - 86_400..)
|
|
167
|
+
calls.query("agent.id" => 7) # dot path
|
|
168
|
+
calls.query(duration: { "$gt" => 10 }) # any daemon operator, as a Hash
|
|
169
|
+
calls.query("$or" => [{ status: "failed" }, { duration: { "$gt" => 50 } }])
|
|
170
|
+
|
|
171
|
+
calls.query(status: "answered")
|
|
172
|
+
.query(caller: %w[a b]) # ANDed
|
|
173
|
+
.sort(at: :desc, _id: :asc)
|
|
174
|
+
.fields(:_id, :status)
|
|
175
|
+
.limit(50)
|
|
176
|
+
.skip(100)
|
|
177
|
+
.all # => [{"_id" => "c1", "status" => "answered"}, ...]
|
|
178
|
+
|
|
179
|
+
calls.query(status: "answered").first # the first row, or nil
|
|
151
180
|
```
|
|
152
181
|
|
|
153
|
-
|
|
182
|
+
- `query` turns Ruby values into operators: an Array is `$in`, a Range is `$gte` and `$lte` (or `$lt`), `nil` is `$exists: false`, a `Time` is an RFC 3339 string, a `Regexp` is `$regex`. A key that starts with `$` is passed as is.
|
|
183
|
+
- `sort(at: -1, _id: 1)`: `1`, `:asc` ascending, `-1`, `:desc` descending. The key order is the sort order. The id is always the last key.
|
|
184
|
+
- `fields` returns only these fields.
|
|
185
|
+
- See the [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md) for every operator.
|
|
186
|
+
|
|
187
|
+
### A query built elsewhere
|
|
188
|
+
|
|
189
|
+
A query from a search form or a saved search is a `raw_query`. Its Hash is sent as it is (only Times and Regexps are converted), ANDed with the other conditions:
|
|
154
190
|
|
|
155
|
-
|
|
191
|
+
```ruby
|
|
192
|
+
calls.raw_query("status" => "answered", "at" => { "$gte" => "now-7d" }).limit(50).all
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
For a whole search body (`query`, `sort`, `fields`, `limit`, `skip`, `after`, `count`, `explain`), use `search`. It returns one `Ndb::Page`:
|
|
156
196
|
|
|
157
197
|
```ruby
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
198
|
+
page = calls.search(query: { status: "answered" }, sort: { at: -1 }, limit: 50, count: true)
|
|
199
|
+
|
|
200
|
+
page.rows # => [{"_id" => "c1", ...}, ...]
|
|
201
|
+
page.next # the cursor of the next page, nil on the last one
|
|
202
|
+
page.total # with count: true
|
|
203
|
+
page.plan # "id", "index:status", "scan" or "snapshot"
|
|
204
|
+
page.examined # rows the daemon looked at
|
|
162
205
|
```
|
|
163
206
|
|
|
164
|
-
|
|
165
|
-
- `versions: true` adds the field `_v` (the document version) to each row.
|
|
207
|
+
`query(...).page` returns the same `Ndb::Page` for a built query (`page(count: true)` fills `total`).
|
|
166
208
|
|
|
167
209
|
### Regular expressions
|
|
168
210
|
|
|
169
|
-
A Ruby `Regexp` is accepted anywhere a value is: a field value, `"$regex" => /x/`, inside `$not`, `$or`, `$and`, in `
|
|
211
|
+
A Ruby `Regexp` is accepted anywhere a value is: a field value, `"$regex" => /x/`, inside `$not`, `$or`, `$and`, in `query`, `search`, `aggregate` (`$match`) and in the `query:` of `watch`.
|
|
170
212
|
|
|
171
213
|
```ruby
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
214
|
+
calls.query(name: /^alpha/i) # i: ignore case
|
|
215
|
+
calls.query(name: { "$regex" => /bet$/ })
|
|
216
|
+
calls.query("$or" => [{ name: /^a/ }, { name: /^b/ }])
|
|
175
217
|
```
|
|
176
218
|
|
|
177
219
|
The daemon uses RE2. The mapping to `$options` is:
|
|
@@ -185,56 +227,67 @@ The daemon uses RE2. The mapping to `$options` is:
|
|
|
185
227
|
|
|
186
228
|
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`.
|
|
187
229
|
|
|
188
|
-
A String pattern is sent unchanged and has no `m`: `
|
|
230
|
+
A String pattern is sent unchanged and has no `m`: `query(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.
|
|
189
231
|
|
|
190
232
|
### Count
|
|
191
233
|
|
|
192
234
|
```ruby
|
|
193
|
-
|
|
194
|
-
|
|
235
|
+
calls.count(status: "answered") # POST /calls/_count
|
|
236
|
+
calls.query(status: "answered").count # the same
|
|
237
|
+
calls.count # every document
|
|
195
238
|
```
|
|
196
239
|
|
|
240
|
+
`limit`, `skip` and `sort` do not change the count.
|
|
241
|
+
|
|
197
242
|
### Cursor pagination
|
|
198
243
|
|
|
244
|
+
Offset pages get slower as `skip` grows. Use a cursor for deep pages.
|
|
245
|
+
|
|
199
246
|
```ruby
|
|
200
|
-
|
|
247
|
+
query = calls.query(status: "answered").sort(_id: 1).limit(100)
|
|
201
248
|
|
|
202
249
|
loop do
|
|
203
|
-
page =
|
|
250
|
+
page = query.page
|
|
204
251
|
|
|
205
252
|
page.rows.each { |row| puts row["_id"] }
|
|
206
253
|
|
|
207
|
-
|
|
254
|
+
break if page.next.nil? # nil on the last page
|
|
208
255
|
|
|
209
|
-
|
|
256
|
+
query = query.after(page.next)
|
|
210
257
|
end
|
|
211
258
|
```
|
|
212
259
|
|
|
213
|
-
A cursor works only with the same
|
|
260
|
+
A cursor works only with the same query and `sort`. Another query shape raises `Ndb::InvalidCursor`.
|
|
261
|
+
|
|
262
|
+
To walk every row, use `each`. It follows the cursor, with `limit` as the batch size. A query is `Enumerable`:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
calls.query(status: "answered").sort(_id: 1).limit(500).each { |row| puts row["_id"] }
|
|
266
|
+
```
|
|
214
267
|
|
|
215
268
|
## Aggregate
|
|
216
269
|
|
|
217
270
|
The pipeline needs exactly one `$group`. Accumulators: `$count`, `$sum`, `$avg`, `$min`, `$max`, `$first`, `$last`.
|
|
218
271
|
|
|
219
272
|
```ruby
|
|
220
|
-
page =
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
count: true)
|
|
273
|
+
page = calls.aggregate([
|
|
274
|
+
{ "$match" => { "duration" => { "$gte" => 1 } } },
|
|
275
|
+
{ "$group" => {
|
|
276
|
+
"_id" => "$status",
|
|
277
|
+
"n" => { "$count" => {} },
|
|
278
|
+
"avg" => { "$avg" => "$duration" }
|
|
279
|
+
} },
|
|
280
|
+
{ "$sort" => { "n" => -1 } }
|
|
281
|
+
],
|
|
282
|
+
limit: 100,
|
|
283
|
+
count: true)
|
|
232
284
|
|
|
233
285
|
page.rows # => [{"_id" => "answered", "n" => 2, "avg" => 35.5}, ...]
|
|
234
286
|
page.total # with count: true
|
|
287
|
+
page.next # the cursor of the next page, nil on the last one
|
|
235
288
|
```
|
|
236
289
|
|
|
237
|
-
`$dateTrunc` units are `minute`, `hour`, `day`, `week` and `month`.
|
|
290
|
+
Options: `limit:`, `skip:`, `after:`, `count:`. `$dateTrunc` units are `minute`, `hour`, `day`, `week` and `month`.
|
|
238
291
|
|
|
239
292
|
## Watch
|
|
240
293
|
|
|
@@ -242,11 +295,11 @@ A watch streams the changes of a collection to a named consumer: first the event
|
|
|
242
295
|
|
|
243
296
|
```ruby
|
|
244
297
|
begin
|
|
245
|
-
|
|
246
|
-
# event has "pos", "op" ("put", "del" or "truncate"), "_id", "
|
|
298
|
+
calls.watch("relay", query: { status: "failed" }) do |event|
|
|
299
|
+
# event has "pos", "op" ("put", "del" or "truncate"), "_id", "_v" and "doc"
|
|
247
300
|
handle(event)
|
|
248
301
|
|
|
249
|
-
|
|
302
|
+
calls.ack(event, "relay")
|
|
250
303
|
end
|
|
251
304
|
rescue Ndb::WatchLagged
|
|
252
305
|
retry # the consumer was too slow. watch continues after the last ack.
|
|
@@ -255,10 +308,11 @@ rescue Ndb::HistoryTruncated
|
|
|
255
308
|
end
|
|
256
309
|
```
|
|
257
310
|
|
|
258
|
-
- `
|
|
311
|
+
- `query:` takes the same conditions as `query`: equality, an Array (`$in`), a Range, a Regexp, `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.
|
|
259
312
|
- `from: "now"` starts a new consumer at the end. `limit: n` ends the stream after `n` events.
|
|
260
|
-
- Without a block, `watch` returns an Enumerator: `
|
|
261
|
-
-
|
|
313
|
+
- Without a block, `watch` returns an Enumerator: `calls.watch("relay", limit: 10).to_a`.
|
|
314
|
+
- `ack(event, consumer)` stores the position of the event (`POST /calls/_ack`): the next `watch` with that consumer name starts after it. The event Hash has no consumer name, so pass it.
|
|
315
|
+
- Delivery is at least once. After a restart an event can come again, so make the work safe to repeat.
|
|
262
316
|
- `HistoryTruncated` means the WAL files for the position were deleted (`wal_retention`).
|
|
263
317
|
- A watch holds one connection until it ends. It is retried only for the initial open, never in the middle of a stream.
|
|
264
318
|
|
|
@@ -267,17 +321,68 @@ end
|
|
|
267
321
|
Large values live in the blob store. A document keeps only a reference.
|
|
268
322
|
|
|
269
323
|
```ruby
|
|
270
|
-
stored = ndb.put_blob(File.open("call.wav"), "audio/wav") # an IO, streamed in chunks
|
|
324
|
+
stored = ndb.put_blob(File.open("call.wav"), "audio/wav") # an IO, streamed in chunks (POST /_blobs)
|
|
271
325
|
stored # => {"$blob" => "sha256:...", "size" => 123456, "type" => "audio/wav"}
|
|
272
326
|
|
|
273
|
-
|
|
327
|
+
calls.insert(_id: "c9", recording: stored)
|
|
274
328
|
|
|
275
|
-
ndb.get_blob(stored["$blob"]) # all the bytes
|
|
329
|
+
ndb.get_blob(stored["$blob"]) # all the bytes (GET /_blobs/{ref})
|
|
276
330
|
ndb.get_blob(stored["$blob"], from: 0, to: 1023) # bytes 0..1023 (inclusive)
|
|
277
331
|
```
|
|
278
332
|
|
|
279
333
|
The same content always gives the same reference. An IO upload is never retried.
|
|
280
334
|
|
|
335
|
+
## Admin
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
calls.stats # rows, indexes, files, bytes (GET /calls/_stats)
|
|
339
|
+
calls.compact # merge every segment now (POST /calls/_compact)
|
|
340
|
+
calls.ensure_index("caller") # built in the background (PUT /calls/_indexes/caller) => the indexed fields
|
|
341
|
+
calls.drop_index("caller")
|
|
342
|
+
|
|
343
|
+
ndb.collections # one definition per collection (GET /_collections)
|
|
344
|
+
ndb.stats # every collection, totals, version, uptime (GET /_stats)
|
|
345
|
+
ndb.size # disk use per collection and part (GET /_size)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## The low-level client
|
|
349
|
+
|
|
350
|
+
`Ndb::Client` has one method per route. The collection methods above call it. Use it when the collection name is only known at run time, or to send a body built elsewhere. The first argument is the collection name.
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
ndb.ensure_collection("calls", indexes: ["status"], time: "at") # PUT /calls
|
|
354
|
+
|
|
355
|
+
ndb.insert("calls", { _id: "c1", status: "answered" }) # POST /calls/_doc => {"_id", "_v"}
|
|
356
|
+
ndb.bulk("calls", [{ _id: "c2" }, { _id: "c3" }], expire_in: "1h") # POST /calls/_bulk => the results
|
|
357
|
+
ndb.get("calls", "c1") # GET /calls/_doc/c1 => the document, or nil
|
|
358
|
+
|
|
359
|
+
ndb.update("calls", "c1", { "$set" => { status: "done" } }, guard: { _v: 1 }) # POST /calls/_update/c1
|
|
360
|
+
ndb.update_by("calls", { status: "failed" }, { "$set" => { status: "retry" } }, many: true) # POST /calls/_update_by_query
|
|
361
|
+
ndb.delete("calls", "c2") # DELETE /calls/_doc/c2
|
|
362
|
+
ndb.delete_by("calls", { status: "retry" }, many: true) # POST /calls/_delete_by_query
|
|
363
|
+
|
|
364
|
+
page = ndb.search("calls", query: { status: "answered" }, sort: { at: -1 }, limit: 50) # POST /calls/_search => an Ndb::Page
|
|
365
|
+
ndb.count("calls", { status: "answered" }) # POST /calls/_count => an Integer
|
|
366
|
+
ndb.aggregate("calls", pipeline: [{ "$group" => { "_id" => "$status", "n" => { "$count" => {} } } }]) # POST /calls/_aggregate
|
|
367
|
+
|
|
368
|
+
ndb.expire("calls", "c1", in: "30m") # POST /calls/_expire/c1
|
|
369
|
+
ndb.persist("calls", "c1") # POST /calls/_persist/c1
|
|
370
|
+
ndb.ttl("calls", "c1") # GET /calls/_ttl/c1
|
|
371
|
+
|
|
372
|
+
ndb.watch("calls", "relay", query: { status: "failed" }) { |event| ndb.ack("calls", "relay", event["pos"]) } # POST /calls/_watch, /calls/_ack
|
|
373
|
+
|
|
374
|
+
ndb.truncate("calls") # POST /calls/_truncate?confirm=calls
|
|
375
|
+
ndb.drop("calls") # DELETE /calls?confirm=calls
|
|
376
|
+
ndb.ensure_index("calls", "caller") # PUT /calls/_indexes/caller
|
|
377
|
+
ndb.drop_index("calls", "caller") # DELETE /calls/_indexes/caller
|
|
378
|
+
ndb.collection_stats("calls") # GET /calls/_stats
|
|
379
|
+
ndb.compact("calls") # POST /calls/_compact
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- `search`, `count` and `aggregate` send the body as it is: write the daemon operators yourself. Only a Regexp is turned into `$regex`.
|
|
383
|
+
- `update`, `update_by`, `delete_by` and `watch` take the Ruby forms of `query` (Array, Range, `nil`, Time, Regexp) in the query and the guard.
|
|
384
|
+
- `ndb.collection(name, definition)` returns the `Ndb::Collection` of the sections above.
|
|
385
|
+
|
|
281
386
|
## Errors
|
|
282
387
|
|
|
283
388
|
All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `status` (the HTTP status).
|
|
@@ -300,11 +405,11 @@ All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `st
|
|
|
300
405
|
| `TtlNotEnabled`, `TtlDerived` | `ttl_not_enabled`, `ttl_derived` |
|
|
301
406
|
| `ConnectionError` | cannot reach the daemon. Subclasses: `ConnectError` (nothing was sent), `TimeoutError` |
|
|
302
407
|
|
|
303
|
-
Any other code is a plain `Ndb::Error`. A response that is not a daemon error has the code `http_status`.
|
|
408
|
+
Any other code (for example `unknown_route`, `pattern_read_only`) is a plain `Ndb::Error`. A response that is not a daemon error has the code `http_status`.
|
|
304
409
|
|
|
305
410
|
```ruby
|
|
306
411
|
begin
|
|
307
|
-
|
|
412
|
+
calls.insert(_id: "c1")
|
|
308
413
|
rescue Ndb::UniqueViolation => e
|
|
309
414
|
e.code # "duplicate_id"
|
|
310
415
|
e.status # 409
|
|
@@ -313,14 +418,14 @@ end
|
|
|
313
418
|
|
|
314
419
|
## Testing
|
|
315
420
|
|
|
316
|
-
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 /
|
|
421
|
+
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:
|
|
317
422
|
|
|
318
423
|
```sh
|
|
319
424
|
ndb -config /nonexistent.yml -data-dir "$(mktemp -d)" -unix /tmp/ndb-test.sock
|
|
320
425
|
```
|
|
321
426
|
|
|
322
427
|
```ruby
|
|
323
|
-
ndb = Ndb
|
|
428
|
+
ndb = Ndb.connect("unix:///tmp/ndb-test.sock")
|
|
324
429
|
```
|
|
325
430
|
|
|
326
431
|
Use a new collection name per test. Run the suite with `bundle exec rake test` in `sdk/ruby`.
|
data/lib/ndb/client.rb
CHANGED
|
@@ -5,10 +5,14 @@ require "uri"
|
|
|
5
5
|
require "time"
|
|
6
6
|
|
|
7
7
|
module Ndb
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
8
|
+
# One page of a search or an aggregate: the rows, the cursor of the next
|
|
9
|
+
# page (nil on the last one), the plan, how many rows were examined, and
|
|
10
|
+
# the total (with count: true).
|
|
11
|
+
Page = Data.define(:rows, :next, :plan, :examined, :total)
|
|
12
|
+
|
|
13
|
+
# The HTTP API of the daemon, one method per route. It builds JSON and
|
|
14
|
+
# reads the answers; it has no query semantics. Ndb::Collection is the
|
|
15
|
+
# friendlier layer on top of it.
|
|
12
16
|
class Client
|
|
13
17
|
DEFAULT_SOCKET = "/var/run/ndb/ndb.sock"
|
|
14
18
|
|
|
@@ -33,123 +37,153 @@ module Ndb
|
|
|
33
37
|
|
|
34
38
|
def socket = @endpoint.path
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
# A collection (or a pattern such as "logs-2026-*") with the query
|
|
41
|
+
# builder. definition is what the first call declares (PUT /{coll}).
|
|
42
|
+
def collection(name, definition = {})
|
|
43
|
+
Collection.new(self, name, definition)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# POST /{coll}/_search: body is { "query", "sort", "fields", "limit",
|
|
47
|
+
# "skip", "after", "count", ... }. Returns a Page.
|
|
48
|
+
def search(coll, body = {})
|
|
49
|
+
page(post(coll, "_search", Conditions.regexps(body)))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# POST /{coll}/_count: how many documents match query.
|
|
53
|
+
def count(coll, query = nil)
|
|
54
|
+
body = query ? { query: Conditions.regexps(query) } : {}
|
|
55
|
+
|
|
56
|
+
json(post(coll, "_count", body)).fetch("count")
|
|
38
57
|
end
|
|
39
58
|
|
|
59
|
+
# POST /{coll}/_aggregate: body is { "pipeline", "limit", "skip",
|
|
60
|
+
# "after", "count" }. Returns a Page.
|
|
40
61
|
def aggregate(coll, body)
|
|
41
|
-
page(post(coll, "
|
|
62
|
+
page(post(coll, "_aggregate", Conditions.regexps(body)))
|
|
42
63
|
end
|
|
43
64
|
|
|
44
65
|
# The document with this _id (and its "_v"), or nil.
|
|
45
66
|
def get(coll, id)
|
|
46
|
-
json(request("GET",
|
|
67
|
+
json(request("GET", doc_path(coll, "_doc", id), nil))
|
|
47
68
|
rescue NotFound
|
|
48
69
|
nil
|
|
49
70
|
end
|
|
50
71
|
|
|
51
|
-
#
|
|
72
|
+
# POST /{coll}/_doc: one document; it gets a generated UUIDv7 _id unless
|
|
73
|
+
# it carries one. Returns {"_id", "_v"}.
|
|
74
|
+
def insert(coll, doc)
|
|
75
|
+
json(post(coll, "_doc", doc))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# POST /{coll}/_bulk: many documents in one transaction, all or nothing.
|
|
52
79
|
# expire_in (seconds, or "30m" / "1h" / "1d") sets the expires_field of
|
|
53
|
-
# every document to now + expire_in
|
|
54
|
-
|
|
55
|
-
|
|
80
|
+
# every document to now + expire_in. Returns the results.
|
|
81
|
+
#
|
|
82
|
+
# upsert: true merges a document whose _id exists into the stored one
|
|
83
|
+
# (its fields replace the fields of the same name; the other fields
|
|
84
|
+
# stay), like doc_as_upsert in Elasticsearch, instead of duplicate_id.
|
|
85
|
+
def bulk(coll, docs, expire_in: nil, upsert: false)
|
|
86
|
+
body = { docs: docs }
|
|
56
87
|
body[:expire_in] = expire_in if expire_in
|
|
88
|
+
body[:upsert] = true if upsert
|
|
57
89
|
|
|
58
|
-
json(post(coll, "
|
|
90
|
+
json(post(coll, "_bulk", body)).fetch("results")
|
|
59
91
|
end
|
|
60
92
|
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
# concurrent updates never interleave.
|
|
93
|
+
# POST /{coll}/_update/{id}: applies the write operators to the document
|
|
94
|
+
# with this _id, in one atomic step. guard adds conditions on the same
|
|
95
|
+
# document ({ _v: 7 }); matched is 0 when they fail.
|
|
65
96
|
#
|
|
66
|
-
# client.update("stock",
|
|
97
|
+
# client.update("stock", "a", { "$inc" => { qty: -1 } }, guard: { qty: { "$gte" => 1 } })
|
|
67
98
|
# # => {"matched" => 1, "modified" => 1, "doc" => {...with "_v"}}
|
|
68
99
|
#
|
|
69
|
-
# Operators: $set, $setOnInsert, $unset, $inc, $push, $pull, $min, $max
|
|
70
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def update(coll, id_or_filter, ops, many: false, upsert: false, expire_in: nil)
|
|
77
|
-
if id_or_filter.is_a?(String)
|
|
78
|
-
query = write_query(upsert: upsert, expire_in: expire_in)
|
|
79
|
-
|
|
80
|
-
return json(request("POST", "#{id_path(coll, id_or_filter)}/update#{query}", merge_update({}, ops)))
|
|
81
|
-
end
|
|
100
|
+
# Operators: $set, $setOnInsert, $unset, $inc, $push, $pull, $min, $max
|
|
101
|
+
# (dot paths work). upsert: true creates the document with this _id.
|
|
102
|
+
def update(coll, id, ops, guard: nil, upsert: false, expire_in: nil)
|
|
103
|
+
body = write_body(update: check_ops(ops), query: guard && Conditions.equality(guard), upsert: upsert, expire_in: expire_in)
|
|
104
|
+
|
|
105
|
+
json(request("POST", doc_path(coll, "_update", id), body))
|
|
106
|
+
end
|
|
82
107
|
|
|
83
|
-
|
|
108
|
+
# POST /{coll}/_update_by_query: the atomic update by query. The daemon
|
|
109
|
+
# matches and writes in one step on the newest state, so concurrent
|
|
110
|
+
# updates never interleave. Without many it updates the first match;
|
|
111
|
+
# many: true updates every match and needs _id or an indexed field in
|
|
112
|
+
# the query. upsert: true creates the document from the equality fields
|
|
113
|
+
# of the query when nothing matches.
|
|
114
|
+
def update_by(coll, query, ops, many: false, upsert: false, expire_in: nil)
|
|
115
|
+
body = write_body(query: Conditions.equality(query), update: check_ops(ops), many: many, upsert: upsert, expire_in: expire_in)
|
|
84
116
|
|
|
85
|
-
json(post(coll, "
|
|
117
|
+
json(post(coll, "_update_by_query", body))
|
|
86
118
|
end
|
|
87
119
|
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return json(request("DELETE", id_path(coll, id_or_filter), nil)) if id_or_filter.is_a?(String)
|
|
120
|
+
# DELETE /{coll}/_doc/{id}: {"deleted" => 0 | 1}.
|
|
121
|
+
def delete(coll, id)
|
|
122
|
+
json(request("DELETE", doc_path(coll, "_doc", id), nil))
|
|
123
|
+
end
|
|
93
124
|
|
|
94
|
-
|
|
125
|
+
# POST /{coll}/_delete_by_query: the first match, or every match with
|
|
126
|
+
# many: true (same rule as update_by). {"deleted" => n}
|
|
127
|
+
def delete_by(coll, query, many: false)
|
|
128
|
+
json(post(coll, "_delete_by_query", write_body(query: Conditions.equality(query), many: many)))
|
|
95
129
|
end
|
|
96
130
|
|
|
97
131
|
# Makes a document expire, like EXPIRE in Redis: in: seconds or "30m", or
|
|
98
|
-
# at: a Time or an RFC 3339 String. Returns {"_id", "
|
|
132
|
+
# at: a Time or an RFC 3339 String. Returns {"_id", "_v", "expires_at"}.
|
|
99
133
|
# Raises NotFound for a missing or expired document, TtlNotEnabled when
|
|
100
134
|
# the collection has no expires_field, TtlDerived when the expiry comes
|
|
101
135
|
# from ttl + time.
|
|
102
136
|
def expire(coll, id, **time)
|
|
103
|
-
body = {
|
|
137
|
+
body = {}
|
|
104
138
|
body[:in] = time[:in] if time[:in]
|
|
105
139
|
body[:at] = time[:at].respond_to?(:utc) ? time[:at].utc.iso8601(9) : time[:at] if time[:at]
|
|
106
140
|
|
|
107
|
-
json(
|
|
141
|
+
json(request("POST", doc_path(coll, "_expire", id), body))
|
|
108
142
|
end
|
|
109
143
|
|
|
110
144
|
# Removes the expiry of a document, like PERSIST in Redis.
|
|
111
145
|
def persist(coll, id)
|
|
112
|
-
json(
|
|
146
|
+
json(request("POST", doc_path(coll, "_persist", id), nil))
|
|
113
147
|
end
|
|
114
148
|
|
|
115
149
|
# The seconds left of a document (an Integer, rounded up), -1 when it
|
|
116
150
|
# never expires, nil when it is missing or expired.
|
|
117
151
|
def ttl(coll, id)
|
|
118
|
-
json(request("GET",
|
|
152
|
+
json(request("GET", doc_path(coll, "_ttl", id), nil)).fetch("ttl")
|
|
119
153
|
rescue NotFound
|
|
120
154
|
nil
|
|
121
155
|
end
|
|
122
156
|
|
|
123
157
|
# Streams the changes of a collection for a named consumer: first the ones
|
|
124
158
|
# after its last ack, then the new ones. Yields one Hash per event (pos,
|
|
125
|
-
# op,
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
159
|
+
# op, _id, _v, doc). query is the same conditions as a search, and only
|
|
160
|
+
# the matching events come; limit ends the stream after that many events.
|
|
161
|
+
# Without a block it returns an Enumerator. Delivery is at least once: ack
|
|
162
|
+
# the pos of an event when its work is done.
|
|
129
163
|
#
|
|
130
164
|
# The stream raises WatchLagged when the consumer is too slow, and
|
|
131
165
|
# HistoryTruncated when its position is older than the kept WAL; call
|
|
132
166
|
# watch again for the first, start over for the second.
|
|
133
|
-
def watch(coll, consumer,
|
|
134
|
-
return enum_for(:watch, coll, consumer,
|
|
167
|
+
def watch(coll, consumer, query: nil, from: nil, limit: nil, &block)
|
|
168
|
+
return enum_for(:watch, coll, consumer, query: query, from: from, limit: limit) unless block
|
|
135
169
|
|
|
136
170
|
body = { consumer: consumer, from: from, limit: limit }.compact
|
|
137
|
-
body[:
|
|
171
|
+
body[:query] = Conditions.equality(query) if query
|
|
138
172
|
|
|
139
|
-
with_ready_retry { stream_events("#{path(coll)}/
|
|
173
|
+
with_ready_retry { stream_events("#{path(coll)}/_watch", body, &block) }
|
|
140
174
|
end
|
|
141
175
|
|
|
142
176
|
# Stores the position of an event for its consumer: the next watch with
|
|
143
177
|
# that consumer name starts after it.
|
|
144
178
|
def ack(coll, consumer, pos)
|
|
145
|
-
json(post(coll, "
|
|
179
|
+
json(post(coll, "_ack", consumer: consumer, pos: pos))
|
|
146
180
|
end
|
|
147
181
|
|
|
148
182
|
# Removes every document of the collection and keeps its definition and
|
|
149
183
|
# indexes. The cost does not depend on the number of documents. Watch
|
|
150
184
|
# consumers get one event with op "truncate". {"collection", "truncated"}
|
|
151
185
|
def truncate(coll)
|
|
152
|
-
json(request("POST", "#{path(coll)}/
|
|
186
|
+
json(request("POST", "#{path(coll)}/_truncate?confirm=#{confirm(coll)}", nil))
|
|
153
187
|
end
|
|
154
188
|
|
|
155
189
|
# Removes the collection: its documents, its definition and the positions
|
|
@@ -159,24 +193,64 @@ module Ndb
|
|
|
159
193
|
json(request("DELETE", "#{path(coll)}?confirm=#{confirm(coll)}", nil))
|
|
160
194
|
end
|
|
161
195
|
|
|
196
|
+
# PUT /{coll}: creates the collection or merges the definition into it.
|
|
162
197
|
def ensure_collection(coll, definition = {})
|
|
163
198
|
json(request("PUT", path(coll), definition))
|
|
164
199
|
end
|
|
165
200
|
|
|
201
|
+
# PUT /{coll}/_indexes/{field}: adds an index, built in the background.
|
|
202
|
+
def ensure_index(coll, field)
|
|
203
|
+
json(request("PUT", doc_path(coll, "_indexes", field), nil)).fetch("indexes")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# DELETE /{coll}/_indexes/{field}.
|
|
207
|
+
def drop_index(coll, field)
|
|
208
|
+
json(request("DELETE", doc_path(coll, "_indexes", field), nil)).fetch("indexes")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# GET /{coll}/_stats: rows, indexes, files and bytes of one collection.
|
|
212
|
+
def collection_stats(coll)
|
|
213
|
+
json(request("GET", "#{path(coll)}/_stats", nil))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# POST /{coll}/_compact: seals and merges every segment now.
|
|
217
|
+
def compact(coll)
|
|
218
|
+
json(post(coll, "_compact", nil))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# GET /_health: true when the daemon is ready; raises NotReady while it
|
|
222
|
+
# recovers. It does not wait.
|
|
223
|
+
def health
|
|
224
|
+
request("GET", "/_health", nil, wait: false)
|
|
225
|
+
|
|
226
|
+
true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# GET /_collections: one definition per collection.
|
|
166
230
|
def collections
|
|
167
|
-
json(request("GET", "/
|
|
231
|
+
json(request("GET", "/_collections", nil)).fetch("collections")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# GET /_stats: every collection, totals, version and uptime.
|
|
235
|
+
def stats
|
|
236
|
+
json(request("GET", "/_stats", nil))
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# GET /_size: disk use per collection and per part.
|
|
240
|
+
def size
|
|
241
|
+
json(request("GET", "/_size", nil))
|
|
168
242
|
end
|
|
169
243
|
|
|
170
244
|
# Uploads an IO (streamed) and returns the reference hash.
|
|
171
245
|
def put_blob(io, content_type = nil)
|
|
172
|
-
json(request("
|
|
246
|
+
json(request("POST", "/_blobs", io, headers: { "Content-Type" => content_type || "application/octet-stream" }))
|
|
173
247
|
end
|
|
174
248
|
|
|
175
249
|
# The bytes of a blob, or of its range from..to (inclusive).
|
|
176
250
|
def get_blob(ref, from: nil, to: nil)
|
|
177
251
|
headers = from ? { "Range" => "bytes=#{from}-#{to}" } : {}
|
|
178
252
|
|
|
179
|
-
request("GET", "/
|
|
253
|
+
request("GET", "/_blobs/#{ref}", nil, headers: headers).body
|
|
180
254
|
end
|
|
181
255
|
|
|
182
256
|
private
|
|
@@ -185,51 +259,50 @@ module Ndb
|
|
|
185
259
|
value.to_s.strip.empty? ? nil : value
|
|
186
260
|
end
|
|
187
261
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
params["many"] = "true" if many
|
|
192
|
-
params["upsert"] = "true" if upsert
|
|
193
|
-
params["expire_in"] = expire_in.to_s if expire_in
|
|
194
|
-
|
|
195
|
-
params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
262
|
+
# The body of a write, without the options that are off.
|
|
263
|
+
def write_body(**parts)
|
|
264
|
+
parts.reject { |_, v| v.nil? || v == false }
|
|
196
265
|
end
|
|
197
266
|
|
|
198
|
-
#
|
|
199
|
-
|
|
200
|
-
def merge_update(filter, ops)
|
|
267
|
+
# An operator key without "$" is a mistake, so it is refused here.
|
|
268
|
+
def check_ops(ops)
|
|
201
269
|
ops.each_key do |key|
|
|
202
270
|
next if key.to_s.start_with?("$")
|
|
203
271
|
|
|
204
272
|
raise ArgumentError, "update operator #{key.to_s.inspect} must start with $ (for example \"$set\")"
|
|
205
273
|
end
|
|
206
274
|
|
|
207
|
-
|
|
275
|
+
ops
|
|
208
276
|
end
|
|
209
277
|
|
|
210
|
-
def post(coll,
|
|
211
|
-
request("POST", "#{path(coll)}/#{
|
|
278
|
+
def post(coll, action, body)
|
|
279
|
+
request("POST", "#{path(coll)}/#{action}", body)
|
|
212
280
|
end
|
|
213
281
|
|
|
214
282
|
def path(coll)
|
|
215
|
-
"
|
|
283
|
+
"/#{escape(coll)}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def escape(segment)
|
|
287
|
+
URI.encode_www_form_component(segment).gsub("+", "%20")
|
|
216
288
|
end
|
|
217
289
|
|
|
218
290
|
def confirm(coll)
|
|
219
291
|
URI.encode_www_form_component(coll)
|
|
220
292
|
end
|
|
221
293
|
|
|
222
|
-
def
|
|
223
|
-
"#{path(coll)}/#{
|
|
294
|
+
def doc_path(coll, action, id)
|
|
295
|
+
"#{path(coll)}/#{action}/#{escape(id)}"
|
|
224
296
|
end
|
|
225
297
|
|
|
226
|
-
def request(method, target, body, headers: {})
|
|
298
|
+
def request(method, target, body, headers: {}, wait: true)
|
|
227
299
|
payload = encode(body)
|
|
228
300
|
headers = authorize(headers)
|
|
229
301
|
|
|
230
302
|
safe = idempotent?(method, target) && !payload.respond_to?(:read)
|
|
303
|
+
timeout = wait ? @ready_timeout : 0
|
|
231
304
|
|
|
232
|
-
with_ready_retry(idempotent: safe) do
|
|
305
|
+
with_ready_retry(idempotent: safe, timeout: timeout) do
|
|
233
306
|
response = @pool.with { |conn| conn.request(method, target, payload, headers: headers, idempotent: safe) }
|
|
234
307
|
|
|
235
308
|
raise_api_error(response) if response.status >= 300
|
|
@@ -239,20 +312,20 @@ module Ndb
|
|
|
239
312
|
end
|
|
240
313
|
|
|
241
314
|
def idempotent?(method, target)
|
|
242
|
-
method == "GET" || target.match?(%r{/(
|
|
315
|
+
method == "GET" || target.match?(%r{/(_search|_count|_aggregate)\z})
|
|
243
316
|
end
|
|
244
317
|
|
|
245
318
|
# Runs the block again while the daemon is not ready. It repeats only
|
|
246
319
|
# when the request certainly was not processed (not_ready, a failed
|
|
247
320
|
# connect), or for a read when the connection dropped. Never a timeout.
|
|
248
|
-
def with_ready_retry(idempotent: false)
|
|
249
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) +
|
|
321
|
+
def with_ready_retry(idempotent: false, timeout: @ready_timeout)
|
|
322
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
250
323
|
delay = RETRY_BASE
|
|
251
324
|
|
|
252
325
|
begin
|
|
253
326
|
yield
|
|
254
327
|
rescue NotReady, ConnectError, ConnectionError => e
|
|
255
|
-
raise if
|
|
328
|
+
raise if timeout <= 0 || e.is_a?(TimeoutError)
|
|
256
329
|
raise unless e.is_a?(NotReady) || e.is_a?(ConnectError) || idempotent
|
|
257
330
|
|
|
258
331
|
wait = (delay / 2) + (rand * delay / 2)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ndb
|
|
4
|
+
# One collection of the daemon, with the query builder. The first call
|
|
5
|
+
# declares it (PUT /{coll}) with its definition. A name with a "*" at the
|
|
6
|
+
# end is a collection pattern ("logs-2026-*"): it reads every collection
|
|
7
|
+
# whose name starts with the prefix, declares nothing, and its writes raise
|
|
8
|
+
# Ndb::Error with code pattern_read_only.
|
|
9
|
+
#
|
|
10
|
+
# db = Ndb.connect("unix:///run/ndb/ndb.sock")
|
|
11
|
+
# calls = db.collection("calls", time: "ts", indexes: ["status"])
|
|
12
|
+
# calls.insert(status: "answered", ts: Time.now)
|
|
13
|
+
# calls.query(status: "answered").sort(ts: -1).limit(10).all
|
|
14
|
+
class Collection
|
|
15
|
+
attr_reader :client, :name, :definition
|
|
16
|
+
|
|
17
|
+
def initialize(client, name, definition = {})
|
|
18
|
+
@client = client
|
|
19
|
+
@name = name
|
|
20
|
+
@definition = definition
|
|
21
|
+
@ensured = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def pattern? = name.include?("*")
|
|
25
|
+
|
|
26
|
+
# A Hash is one document (_doc) and returns {"_id", "_v"}; an Array is
|
|
27
|
+
# many in one transaction (_bulk) and returns the results. expire_in
|
|
28
|
+
# applies to every document.
|
|
29
|
+
def insert(doc_or_docs = nil, expire_in: nil, **fields)
|
|
30
|
+
doc_or_docs ||= fields
|
|
31
|
+
ready!
|
|
32
|
+
|
|
33
|
+
return client.bulk(name, doc_or_docs, expire_in: expire_in) if doc_or_docs.is_a?(Array)
|
|
34
|
+
return client.bulk(name, [doc_or_docs], expire_in: expire_in).first if expire_in
|
|
35
|
+
|
|
36
|
+
client.insert(name, doc_or_docs)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Writes a document by its _id: creates it when the _id is free, and
|
|
40
|
+
# merges its fields into the stored document when it exists (the other
|
|
41
|
+
# fields stay), like doc_as_upsert in Elasticsearch. No _id is an insert.
|
|
42
|
+
# Returns {"_id", "_v"}.
|
|
43
|
+
def upsert(doc = nil, expire_in: nil, **fields)
|
|
44
|
+
upsert_all([doc || fields], expire_in: expire_in).first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# upsert for many documents in one transaction, all or nothing.
|
|
48
|
+
def upsert_all(docs, expire_in: nil)
|
|
49
|
+
ready!
|
|
50
|
+
client.bulk(name, docs, expire_in: expire_in, upsert: true)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The document with this _id, or nil.
|
|
54
|
+
def get(id)
|
|
55
|
+
ready!
|
|
56
|
+
client.get(name, id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Applies the write operators to the document with this _id (atomic).
|
|
60
|
+
# guard adds conditions on the same document, such as { _v: 7 }.
|
|
61
|
+
def update(id, ops, guard: nil, upsert: false, expire_in: nil)
|
|
62
|
+
ready!
|
|
63
|
+
client.update(name, id, ops, guard: guard, upsert: upsert, expire_in: expire_in)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The atomic update by query: the first match, or every match with many.
|
|
67
|
+
def update_by(query, ops, many: false, upsert: false, expire_in: nil)
|
|
68
|
+
ready!
|
|
69
|
+
client.update_by(name, query, ops, many: many, upsert: upsert, expire_in: expire_in)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def delete(id)
|
|
73
|
+
ready!
|
|
74
|
+
client.delete(name, id)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def delete_by(query = nil, many: false, **conditions)
|
|
78
|
+
query ||= conditions
|
|
79
|
+
ready!
|
|
80
|
+
client.delete_by(name, query, many: many)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A search; the conditions use the Ruby forms of Ndb::Conditions
|
|
84
|
+
# (Ranges, Times, Regexps, an Array as $in).
|
|
85
|
+
def query(conditions = nil, **keywords)
|
|
86
|
+
Query.new(self).query(conditions || keywords)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A query built elsewhere, such as from a search form, sent as is.
|
|
90
|
+
def raw_query(hash)
|
|
91
|
+
Query.new(self).raw_query(hash)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# How many documents match.
|
|
95
|
+
def count(conditions = nil, **keywords)
|
|
96
|
+
query(conditions || keywords).count
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A whole search body built elsewhere: { "query", "sort", "limit",
|
|
100
|
+
# "after", "count" }. Returns an Ndb::Page.
|
|
101
|
+
def search(body)
|
|
102
|
+
ready!
|
|
103
|
+
client.search(name, body)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def aggregate(pipeline, **opts)
|
|
107
|
+
ready!
|
|
108
|
+
client.aggregate(name, { pipeline: pipeline }.merge(opts))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Each event Hash also has "consumer", so ack(event) needs nothing else.
|
|
112
|
+
def watch(consumer, query: nil, from: nil, limit: nil, &block)
|
|
113
|
+
return enum_for(:watch, consumer, query: query, from: from, limit: limit) unless block
|
|
114
|
+
|
|
115
|
+
ready!
|
|
116
|
+
client.watch(name, consumer, query: query, from: from, limit: limit) do |event|
|
|
117
|
+
block.call(event.merge("consumer" => consumer))
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Stores the position of an event from watch for its consumer: the next
|
|
122
|
+
# watch with that consumer name starts after it.
|
|
123
|
+
def ack(event, consumer = nil)
|
|
124
|
+
client.ack(name, consumer || event.fetch("consumer"), event.fetch("pos"))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def expire(id, **time)
|
|
128
|
+
ready!
|
|
129
|
+
client.expire(name, id, **time)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def persist(id)
|
|
133
|
+
ready!
|
|
134
|
+
client.persist(name, id)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ttl(id)
|
|
138
|
+
ready!
|
|
139
|
+
client.ttl(name, id)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def truncate = client.truncate(name)
|
|
143
|
+
|
|
144
|
+
def drop
|
|
145
|
+
@ensured = false
|
|
146
|
+
client.drop(name)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def stats
|
|
150
|
+
ready!
|
|
151
|
+
client.collection_stats(name)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def compact
|
|
155
|
+
ready!
|
|
156
|
+
client.compact(name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def ensure_index(field)
|
|
160
|
+
ready!
|
|
161
|
+
client.ensure_index(name, field)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def drop_index(field)
|
|
165
|
+
ready!
|
|
166
|
+
client.drop_index(name, field)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Declares the collection once (not a pattern).
|
|
170
|
+
def ready!
|
|
171
|
+
return if @ensured || pattern?
|
|
172
|
+
|
|
173
|
+
client.ensure_collection(name, definition)
|
|
174
|
+
@ensured = true
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# An immutable search on a collection: every step returns a new Query.
|
|
179
|
+
class Query
|
|
180
|
+
include Enumerable
|
|
181
|
+
|
|
182
|
+
def initialize(collection, clauses: [], options: {})
|
|
183
|
+
@collection = collection
|
|
184
|
+
@clauses = clauses
|
|
185
|
+
@options = options
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Adds conditions (Ruby forms), ANDed with the ones already there.
|
|
189
|
+
def query(conditions)
|
|
190
|
+
return self if conditions.empty?
|
|
191
|
+
|
|
192
|
+
with(clauses: @clauses + [Conditions.equality(conditions)])
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Adds a query built elsewhere, sent as is.
|
|
196
|
+
def raw_query(hash)
|
|
197
|
+
with(clauses: @clauses + [Conditions.json_value(hash)])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def sort(spec) = with(sort: spec.to_h { |k, v| [k.to_s, direction(v)] })
|
|
201
|
+
def limit(count) = with(limit: count)
|
|
202
|
+
def skip(count) = with(skip: count)
|
|
203
|
+
def after(token) = with(after: token)
|
|
204
|
+
def fields(*names) = with(fields: names.flatten.map(&:to_s))
|
|
205
|
+
|
|
206
|
+
# The rows of one request: one page with limit, every row without it.
|
|
207
|
+
def all = page.rows
|
|
208
|
+
|
|
209
|
+
def first = limit(1).all.first
|
|
210
|
+
|
|
211
|
+
# One page: rows, next cursor, total (with count: true via #page(count: true)).
|
|
212
|
+
def page(count: false)
|
|
213
|
+
body = body_hash
|
|
214
|
+
body["count"] = true if count
|
|
215
|
+
|
|
216
|
+
@collection.search(body)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# How many documents match (POST /{coll}/_count).
|
|
220
|
+
def count
|
|
221
|
+
@collection.ready!
|
|
222
|
+
@collection.client.count(@collection.name, where)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Every row, following the cursor page by page (limit is the page size).
|
|
226
|
+
def each(&block)
|
|
227
|
+
return enum_for(:each) unless block
|
|
228
|
+
|
|
229
|
+
query = self
|
|
230
|
+
|
|
231
|
+
loop do
|
|
232
|
+
result = query.page
|
|
233
|
+
result.rows.each(&block)
|
|
234
|
+
|
|
235
|
+
break unless result.next
|
|
236
|
+
|
|
237
|
+
query = query.after(result.next).with(skip: nil)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def body_hash
|
|
242
|
+
body = { "query" => where }.merge(@options.compact.transform_keys(&:to_s))
|
|
243
|
+
body.delete("query") if body["query"].empty?
|
|
244
|
+
body
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
protected
|
|
248
|
+
|
|
249
|
+
def with(**options)
|
|
250
|
+
clauses = options.delete(:clauses) || @clauses
|
|
251
|
+
|
|
252
|
+
self.class.new(@collection, clauses: clauses, options: @options.merge(options))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
def where = Conditions.combine(@clauses)
|
|
258
|
+
|
|
259
|
+
def direction(value)
|
|
260
|
+
%w[desc -1].include?(value.to_s.downcase) ? -1 : 1
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A client for addr: unix:///path/to/ndb.sock or tcp://host:port. Without
|
|
265
|
+
# addr it reads NDB_URL. See Ndb::Client for the options.
|
|
266
|
+
def self.connect(addr = nil, **opts)
|
|
267
|
+
Client.new(url: addr, **opts)
|
|
268
|
+
end
|
|
269
|
+
end
|
data/lib/ndb/connection.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "socket"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
|
|
6
6
|
module Ndb
|
|
7
|
-
Response =
|
|
7
|
+
Response = Data.define(:status, :headers, :body)
|
|
8
8
|
|
|
9
9
|
# Where the daemon listens: a unix socket path, or a TCP host and port.
|
|
10
10
|
# The host name is resolved each time a connection opens, so a changed
|
|
@@ -138,7 +138,7 @@ module Ndb
|
|
|
138
138
|
def send_request(method, target, body, headers)
|
|
139
139
|
open_socket unless @socket
|
|
140
140
|
|
|
141
|
-
head = +"#{method} #{target} HTTP/1.1\r\nHost: #{@endpoint.host_header}\r\nAccept: application/
|
|
141
|
+
head = +"#{method} #{target} HTTP/1.1\r\nHost: #{@endpoint.host_header}\r\nAccept: application/x-ndjson\r\n"
|
|
142
142
|
|
|
143
143
|
{ "Content-Type" => "application/json" }.merge(headers).each { |name, value| head << "#{name}: #{value}\r\n" }
|
|
144
144
|
|
data/lib/ndb/version.rb
CHANGED
data/lib/ndb.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ndb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- thadeu
|
|
@@ -20,6 +20,7 @@ files:
|
|
|
20
20
|
- README.md
|
|
21
21
|
- lib/ndb.rb
|
|
22
22
|
- lib/ndb/client.rb
|
|
23
|
+
- lib/ndb/collection.rb
|
|
23
24
|
- lib/ndb/conditions.rb
|
|
24
25
|
- lib/ndb/connection.rb
|
|
25
26
|
- lib/ndb/errors.rb
|