ndb 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d05135b99146700c912046914e228098dc99c4bd67181074f39ea399c1d2e375
4
- data.tar.gz: 76be9b065c8505b1a7c59ce69bf66bce0aa179bb6e4368f73facaea10e30c848
3
+ metadata.gz: b60b364a32410e0d6c1356e427e4844bba9b31a65dcd0d5afb4fcb9acbafa3aa
4
+ data.tar.gz: e2a3603636b2ab7bcf922c9c0d759a98d871dc292bc9a2299ef3d284c6faaf26
5
5
  SHA512:
6
- metadata.gz: b3e6fa3bded8d9ee8824f7c0038e2fe74c462cdd95561b20f0a1548c59b40ff825bb1cd41348c9a2b919166fba3ba2ded2c5b58f61133cd729adf2f915e7c0b9
7
- data.tar.gz: 5ace970046bad037e2977795771133017dc594ef83f64ee914799d732fb4b71baca7446e2788464644d9e56e75320d0c7987f87305801730a36a1f043d4a6ceb
6
+ metadata.gz: e26d00115fc7530e4f766437f1019202a644ada9a4a0127f96dbd23efcf9e83d6afefda557f2ad63f892be159d8a7c38bd18112bc75744fb740f6ceeddd9edf5
7
+ data.tar.gz: 45dde6bb1ceb82b3a381f2047f2bf09e33d1d8182214422537ee2a6dc97a0bdb01463e7f2eadeb4371007863f71a1566653081e53fb9db49620c1c77e9a4565c
data/README.md CHANGED
@@ -80,26 +80,46 @@ results = ndb.insert("calls", [
80
80
  ])
81
81
  results # => [{"id" => "c1", "v" => 1}, {"id" => "c2", "v" => 1}]
82
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
83
+ # Atomic update by filter: the filter, then the write operators.
84
+ ndb.update("calls", { call_id: "c1" }, { "$set" => { status: "done" }, "$unset" => { caller: true } })
85
+ # => {"matched" => 1, "modified" => 1, "doc" => {"call_id" => "c1", ..., "_v" => 2}}
85
86
 
86
- ndb.delete("calls", "c2")
87
- ndb.delete("calls", "c1", if_version: 3)
87
+ ndb.update("calls", { call_id: "c1", _v: 2 }, { "$set" => { duration: 50 } }) # only if the version is still 2
88
+ ndb.update("stock", { sku: "a", qty: { "$gte" => 1 } }, { "$inc" => { qty: -1 } }) # guarded decrement
89
+
90
+ ndb.update("calls", { status: "failed" }, { "$set" => { status: "retry" } }, many: true) # => {"matched" => 3, "modified" => 3}
91
+ ndb.update("counters", { name: "hits" }, { "$inc" => { n: 1 } }, upsert: true)
92
+
93
+ ndb.delete("calls", { call_id: "c2" }) # => {"deleted" => 1}
94
+ ndb.delete("calls", { status: "failed" }, many: true)
88
95
  ```
89
96
 
90
- - `if_version:` is optimistic locking. If another writer changed the document first, you get `Ndb::VersionConflict` (code `version_conflict`). Reload and try again.
97
+ - Operators: `$set`, `$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.
98
+ - Nothing matched is not an error: `matched` is 0. A stale `_v` is a filter that matches nothing, so read again and retry. `modified` is 0 when the operators leave the document as it is.
99
+ - `many: true` needs the id or an indexed field in the filter (`Ndb::UnindexedMany`). `upsert: true` creates the document from the equality fields of the filter. The answer has `doc` only for one document.
100
+ - The contract errors are `Ndb::UnknownOperator`, `NoUpdate`, `ConflictingUpdate`, `InvalidField`, `UnindexedMany` and `EmptyFilter`, all under `Ndb::InvalidUpdate`.
91
101
  - 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`.
102
+
103
+ ## Expiry (TTL like Redis)
104
+
105
+ The collection needs `expires_field`.
93
106
 
94
107
  ```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
108
+ ndb.ensure_collection("sessions", { id: "sid", expires_field: "exp" })
109
+
110
+ ndb.insert("sessions", [{ sid: "s1" }], expire_in: 3600) # seconds, or "1h" / "30m" / "1d"
111
+ ndb.update("sessions", { sid: "s1" }, { "$set" => { user: "u" } }, expire_in: "30m")
112
+
113
+ ndb.expire("sessions", "s1", in: 1800) # => {"id", "v", "expires_at"}
114
+ ndb.expire("sessions", "s1", at: Time.now + 7200)
115
+ ndb.ttl("sessions", "s1") # => 7199, -1 (never expires), or nil (missing or expired)
116
+ ndb.persist("sessions", "s1") # => {"id", "v"}
101
117
  ```
102
118
 
119
+ - `expire_in` wins over a value of `expires_field` in the same call. `ttl` rounds up to whole seconds.
120
+ - `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`.
121
+ - 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).
122
+
103
123
  ## Find
104
124
 
105
125
  `find` returns a `Ndb::Page` with `rows` (an array of Hash), `next_cursor`, `plan`, `examined` and `total`.
@@ -255,7 +275,7 @@ All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `st
255
275
  | `NotReady` | `not_ready` |
256
276
  | `NotFound` | `not_found` |
257
277
  | `UnknownCollection` | `unknown_collection` |
258
- | `VersionConflict` | `version_conflict` |
278
+ | `InvalidUpdate` and its subclasses | `unknown_operator`, `no_update`, `conflicting_update`, `invalid_field`, `unindexed_many`, `empty_filter` |
259
279
  | `UniqueViolation` | `unique_violation`, `duplicate_id` |
260
280
  | `CatalogConflict` | `catalog_conflict` |
261
281
  | `CatalogInvalid` | `catalog_invalid` |
@@ -264,6 +284,7 @@ All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `st
264
284
  | `InvalidCursor` | `cursor_invalid`, `cursor_mismatch`, `cursor_expired` |
265
285
  | `WatchLagged` | `watch_lagged` |
266
286
  | `HistoryTruncated` | `history_truncated` |
287
+ | `TtlNotEnabled`, `TtlDerived` | `ttl_not_enabled`, `ttl_derived` |
267
288
  | `ConnectionError` | cannot reach the daemon. Subclasses: `ConnectError` (nothing was sent), `TimeoutError` |
268
289
 
269
290
  Any other code is a plain `Ndb::Error`. A response that is not a daemon error has the code `http_status`.
data/lib/ndb/client.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "uri"
5
+ require "time"
5
6
 
6
7
  module Ndb
7
8
  Page = Struct.new(:rows, :next_cursor, :plan, :examined, :total)
@@ -40,22 +41,65 @@ module Ndb
40
41
  page(post(coll, "aggregate", Conditions.regexps(body)))
41
42
  end
42
43
 
43
- def insert(coll, docs)
44
- json(post(coll, "insert", insert: docs)).fetch("results")
44
+ # expire_in (seconds, or "30m" / "1h" / "1d") sets the expires_field of
45
+ # every document to now + expire_in, in the same transaction.
46
+ def insert(coll, docs, expire_in: nil)
47
+ body = { insert: docs }
48
+ body[:expire_in] = expire_in if expire_in
49
+
50
+ json(post(coll, "insert", body)).fetch("results")
51
+ end
52
+
53
+ # Atomic update by filter. The filter and the write operators go in one
54
+ # body; the daemon matches and writes in one step on the newest state, so
55
+ # concurrent updates never interleave.
56
+ #
57
+ # client.update("stock", { sku: "a", qty: { "$gte" => 1 } }, { "$inc" => { qty: -1 } })
58
+ # # => {"matched" => 1, "modified" => 1, "doc" => {...with "_v"}}
59
+ #
60
+ # Operators: $set, $unset, $inc, $push, $pull, $min, $max (dot paths work).
61
+ # Nothing matched is not an error: {"matched" => 0, "modified" => 0}. A
62
+ # version guard is a filter: { id: "x", _v: 7 }. many: true updates every
63
+ # match (no "doc" in the answer) and needs the id or an indexed field in
64
+ # the filter. upsert: true creates the document from the equality fields
65
+ # of the filter when nothing matches. expire_in (seconds, or "30m") sets
66
+ # the expires_field.
67
+ def update(coll, filter, ops, many: false, upsert: false, expire_in: nil)
68
+ query = write_query(many: many, upsert: upsert, expire_in: expire_in)
69
+
70
+ json(post(coll, "update#{query}", merge_update(filter, ops)))
45
71
  end
46
72
 
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
73
+ # Deletes the documents that the filter matches (one, or every match with
74
+ # many: true, which needs the id or an indexed field). {"deleted" => n}
75
+ def delete(coll, filter, many: false)
76
+ json(post(coll, "delete#{write_query(many: many)}", filter))
77
+ end
50
78
 
51
- json(post(coll, "update", update: change))
79
+ # Makes a document expire, like EXPIRE in Redis: in: seconds or "30m", or
80
+ # at: a Time or an RFC 3339 String. Returns {"id", "v", "expires_at"}.
81
+ # Raises NotFound for a missing or expired document, TtlNotEnabled when
82
+ # the collection has no expires_field, TtlDerived when the expiry comes
83
+ # from ttl + time.
84
+ def expire(coll, id, **time)
85
+ body = { id: id }
86
+ body[:in] = time[:in] if time[:in]
87
+ body[:at] = time[:at].respond_to?(:utc) ? time[:at].utc.iso8601(9) : time[:at] if time[:at]
88
+
89
+ json(post(coll, "expire", body))
52
90
  end
53
91
 
54
- def delete(coll, id, if_version: nil)
55
- change = { id: id }
56
- change[:if_version] = if_version if if_version
92
+ # Removes the expiry of a document, like PERSIST in Redis.
93
+ def persist(coll, id)
94
+ json(post(coll, "persist", id: id))
95
+ end
57
96
 
58
- json(post(coll, "delete", delete: change))
97
+ # The seconds left of a document (an Integer, rounded up), -1 when it
98
+ # never expires, nil when it is missing or expired.
99
+ def ttl(coll, id)
100
+ json(request("GET", "#{path(coll)}/ttl/#{URI.encode_www_form_component(id).gsub('+', '%20')}", nil)).fetch("ttl")
101
+ rescue NotFound
102
+ nil
59
103
  end
60
104
 
61
105
  # Streams the changes of a collection for a named consumer: first the ones
@@ -109,6 +153,28 @@ module Ndb
109
153
  value.to_s.strip.empty? ? nil : value
110
154
  end
111
155
 
156
+ def write_query(many: false, upsert: false, expire_in: nil)
157
+ params = {}
158
+
159
+ params["many"] = "true" if many
160
+ params["upsert"] = "true" if upsert
161
+ params["expire_in"] = expire_in.to_s if expire_in
162
+
163
+ params.empty? ? "" : "?#{URI.encode_www_form(params)}"
164
+ end
165
+
166
+ # The filter and the operators in one body. An operator key without "$"
167
+ # would be read as a filter key by the daemon, so it is refused here.
168
+ def merge_update(filter, ops)
169
+ ops.each_key do |key|
170
+ next if key.to_s.start_with?("$")
171
+
172
+ raise ArgumentError, "update operator #{key.to_s.inspect} must start with $ (for example \"$set\")"
173
+ end
174
+
175
+ filter.to_h.transform_keys(&:to_s).merge(ops.to_h.transform_keys(&:to_s))
176
+ end
177
+
112
178
  def post(coll, verb, body)
113
179
  request("POST", "#{path(coll)}/#{verb}", body)
114
180
  end
data/lib/ndb/errors.rb CHANGED
@@ -25,7 +25,6 @@ module Ndb
25
25
  class Unauthorized < Error; end
26
26
  class NotFound < Error; end
27
27
  class UnknownCollection < Error; end
28
- class VersionConflict < Error; end
29
28
  class UniqueViolation < Error; end
30
29
  class CatalogConflict < Error; end
31
30
  class CatalogInvalid < Error; end
@@ -33,6 +32,19 @@ module Ndb
33
32
  class InvalidQuery < Error; end
34
33
  class InvalidCursor < Error; end
35
34
 
35
+ # The update or delete body breaks the contract (see Client#update).
36
+ class InvalidUpdate < Error; end
37
+ class UnknownOperator < InvalidUpdate; end
38
+ class NoUpdate < InvalidUpdate; end
39
+ class ConflictingUpdate < InvalidUpdate; end
40
+ class InvalidField < InvalidUpdate; end
41
+ class UnindexedMany < InvalidUpdate; end
42
+ class EmptyFilter < InvalidUpdate; end
43
+
44
+ # The collection has no expires_field, or its expiry comes from ttl + time.
45
+ class TtlNotEnabled < Error; end
46
+ class TtlDerived < Error; end
47
+
36
48
  # The consumer was too slow: call watch again, it resumes from the last ack.
37
49
  class WatchLagged < Error; end
38
50
 
@@ -46,7 +58,6 @@ module Ndb
46
58
  "not_ready" => NotReady,
47
59
  "not_found" => NotFound,
48
60
  "unknown_collection" => UnknownCollection,
49
- "version_conflict" => VersionConflict,
50
61
  "unique_violation" => UniqueViolation,
51
62
  "duplicate_id" => UniqueViolation,
52
63
  "catalog_conflict" => CatalogConflict,
@@ -58,7 +69,15 @@ module Ndb
58
69
  "cursor_mismatch" => InvalidCursor,
59
70
  "cursor_expired" => InvalidCursor,
60
71
  "watch_lagged" => WatchLagged,
61
- "history_truncated" => HistoryTruncated
72
+ "history_truncated" => HistoryTruncated,
73
+ "ttl_not_enabled" => TtlNotEnabled,
74
+ "ttl_derived" => TtlDerived,
75
+ "unknown_operator" => UnknownOperator,
76
+ "no_update" => NoUpdate,
77
+ "conflicting_update" => ConflictingUpdate,
78
+ "invalid_field" => InvalidField,
79
+ "unindexed_many" => UnindexedMany,
80
+ "empty_filter" => EmptyFilter
62
81
  }.freeze
63
82
 
64
83
  def self.error_for(code, message, status)
data/lib/ndb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ndb
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
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.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - thadeu