tricoredb 0.1.0 → 0.3.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/CHANGELOG.md +58 -1
- data/README.md +201 -6
- data/lib/tricoredb/builders.rb +142 -0
- data/lib/tricoredb/client.rb +1307 -30
- data/lib/tricoredb/errors.rb +7 -3
- data/lib/tricoredb/response.rb +10 -5
- data/lib/tricoredb/version.rb +1 -1
- metadata +2 -2
data/lib/tricoredb/client.rb
CHANGED
|
@@ -30,6 +30,10 @@ module TriCoreDB
|
|
|
30
30
|
DEFAULT_PORT = 8427
|
|
31
31
|
DEFAULT_DATABASE = "main"
|
|
32
32
|
CLOSE_TIMEOUT = 2
|
|
33
|
+
# Following the leader: the longest pause between attempts, and the connect
|
|
34
|
+
# budget for the new connection, in seconds.
|
|
35
|
+
MAX_LEADER_RETRY_DELAY = 2.0
|
|
36
|
+
FOLLOW_CONNECT_TIMEOUT = 3
|
|
33
37
|
|
|
34
38
|
# @return [Integer] the feature bitmap the server granted in HELLO_OK
|
|
35
39
|
attr_reader :granted_features
|
|
@@ -43,6 +47,14 @@ module TriCoreDB
|
|
|
43
47
|
attr_accessor :read_timeout
|
|
44
48
|
# @return [Integer, nil] a server-side deadline in milliseconds sent with every request
|
|
45
49
|
attr_accessor :request_timeout_ms
|
|
50
|
+
# @return [Boolean] send a `not_leader` refusal again, to the leader (see {Client.connect})
|
|
51
|
+
attr_accessor :follow_leader
|
|
52
|
+
# @return [Integer] how many times a refused request is sent again
|
|
53
|
+
attr_accessor :leader_retries
|
|
54
|
+
# @return [Numeric] the first pause, in seconds, before asking again; it doubles up to 2
|
|
55
|
+
attr_accessor :leader_retry_delay
|
|
56
|
+
# @return [String] the `host:port` this connection talks to; it changes when the client follows the leader
|
|
57
|
+
attr_reader :address
|
|
46
58
|
|
|
47
59
|
# Connect, handshake and (when `user` is given) authenticate.
|
|
48
60
|
#
|
|
@@ -57,11 +69,19 @@ module TriCoreDB
|
|
|
57
69
|
# @param tls [Hash, true, nil] see {Transport.tls_context}
|
|
58
70
|
# @param client_name [String]
|
|
59
71
|
# @param features [Integer] capabilities to request
|
|
72
|
+
# @param follow_leader [Boolean] send a request the server refused with `not_leader` again: to the
|
|
73
|
+
# leader the refusal names, over a new connection that replaces this one (same user, secret and
|
|
74
|
+
# TLS settings, with the leader's host as the certificate name), or, while no leader is known, to
|
|
75
|
+
# the same node after a pause. Safe because a `not_leader` refusal was applied nowhere; never
|
|
76
|
+
# inside a session transaction.
|
|
77
|
+
# @param leader_retries [Integer] how many times a refused request is sent again
|
|
78
|
+
# @param leader_retry_delay [Numeric] the first pause, in seconds; it doubles up to 2
|
|
60
79
|
# @return [Client]
|
|
61
80
|
# @raise [ConnectionError, ProtocolError, AuthError]
|
|
62
81
|
def self.connect(host: "127.0.0.1", port: DEFAULT_PORT, user: nil, secret: "", database: DEFAULT_DATABASE,
|
|
63
82
|
connect_timeout: 10, read_timeout: nil, request_timeout_ms: nil, tls: nil,
|
|
64
|
-
client_name: "tricoredb-ruby/#{VERSION}", features: Features::ALL
|
|
83
|
+
client_name: "tricoredb-ruby/#{VERSION}", features: Features::ALL,
|
|
84
|
+
follow_leader: true, leader_retries: 5, leader_retry_delay: 0.25)
|
|
65
85
|
transport = Transport.open(host, Integer(port), connect_timeout: connect_timeout, tls: tls)
|
|
66
86
|
client = new(transport, database: database)
|
|
67
87
|
begin
|
|
@@ -73,6 +93,14 @@ module TriCoreDB
|
|
|
73
93
|
end
|
|
74
94
|
client.read_timeout = read_timeout
|
|
75
95
|
client.request_timeout_ms = request_timeout_ms
|
|
96
|
+
client.follow_leader = follow_leader
|
|
97
|
+
client.leader_retries = leader_retries
|
|
98
|
+
client.leader_retry_delay = leader_retry_delay
|
|
99
|
+
client.send(:remember_connect_options,
|
|
100
|
+
host: host, port: Integer(port), user: user, secret: secret, database: database,
|
|
101
|
+
connect_timeout: connect_timeout, read_timeout: read_timeout,
|
|
102
|
+
request_timeout_ms: request_timeout_ms, tls: tls, client_name: client_name,
|
|
103
|
+
features: features)
|
|
76
104
|
client
|
|
77
105
|
end
|
|
78
106
|
|
|
@@ -90,6 +118,11 @@ module TriCoreDB
|
|
|
90
118
|
@txn_open = false
|
|
91
119
|
@read_timeout = nil
|
|
92
120
|
@request_timeout_ms = nil
|
|
121
|
+
@follow_leader = true
|
|
122
|
+
@leader_retries = 5
|
|
123
|
+
@leader_retry_delay = 0.25
|
|
124
|
+
@connect_options = nil
|
|
125
|
+
@address = ""
|
|
93
126
|
end
|
|
94
127
|
|
|
95
128
|
# @return [Hash{Symbol => Object}] the granted features by name, plus `:mask`
|
|
@@ -192,9 +225,10 @@ module TriCoreDB
|
|
|
192
225
|
# @param op [Hash, String] an externally tagged TriCoreOp, e.g. `{"Cache" => "Ping"}`
|
|
193
226
|
# @param database [String]
|
|
194
227
|
# @param correlation_id [String, nil] requires the CORRELATION_ID feature
|
|
228
|
+
# @param txn [String, nil] an open document or graph transaction the request runs inside
|
|
195
229
|
# @return [Response] only when its status is `ok`
|
|
196
230
|
# @raise [ServerError] for any other status, or an ERROR frame
|
|
197
|
-
def request(op, database: @database, correlation_id: nil)
|
|
231
|
+
def request(op, database: @database, correlation_id: nil, txn: nil)
|
|
198
232
|
payload = {
|
|
199
233
|
"request_id" => next_request_id,
|
|
200
234
|
"database" => database,
|
|
@@ -214,14 +248,26 @@ module TriCoreDB
|
|
|
214
248
|
"llm" => nil
|
|
215
249
|
}
|
|
216
250
|
end
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
251
|
+
payload["options"] = (payload["options"] || {}).merge("txn" => txn.to_s) unless txn.nil?
|
|
252
|
+
pause = @leader_retry_delay
|
|
253
|
+
retried = 0
|
|
254
|
+
loop do
|
|
255
|
+
response = send_request(payload)
|
|
256
|
+
return response if response.ok?
|
|
257
|
+
|
|
258
|
+
# A `not_leader` refusal was applied nowhere, so sending it again cannot
|
|
259
|
+
# apply it twice. A session transaction belongs to this connection.
|
|
260
|
+
unless response.redirect? && @follow_leader && !@txn_open && retried < @leader_retries
|
|
261
|
+
raise response.to_error(@txn_open, retried: @follow_leader ? retried : nil)
|
|
262
|
+
end
|
|
220
263
|
|
|
221
|
-
|
|
222
|
-
|
|
264
|
+
retried += 1
|
|
265
|
+
hint = response.leader_hint
|
|
266
|
+
next if hint && hint != @address && move_to(hint)
|
|
223
267
|
|
|
224
|
-
|
|
268
|
+
sleep(pause)
|
|
269
|
+
pause = (pause * 2).clamp(0.001, MAX_LEADER_RETRY_DELAY)
|
|
270
|
+
end
|
|
225
271
|
end
|
|
226
272
|
|
|
227
273
|
# ---- SQL -----------------------------------------------------------------
|
|
@@ -501,6 +547,470 @@ module TriCoreDB
|
|
|
501
547
|
json(cache("XTrim", nk(namespace, key).merge("max_len" => Integer(max_len)), database), "xtrim")["trimmed"]
|
|
502
548
|
end
|
|
503
549
|
|
|
550
|
+
# Store a value that answers as fresh for the given time and as stale after it, until
|
|
551
|
+
# its TTL (if any) ends. Returns the instant, in Unix milliseconds, it stops being
|
|
552
|
+
# fresh.
|
|
553
|
+
# @return [Integer]
|
|
554
|
+
def cache_set_fresh(namespace, key, value, fresh_ms, ttl_ms: nil, database: @database)
|
|
555
|
+
body = {
|
|
556
|
+
"namespace" => namespace,
|
|
557
|
+
"key" => key,
|
|
558
|
+
"value" => bytes(value, "value"),
|
|
559
|
+
"fresh_ms" => Integer(fresh_ms),
|
|
560
|
+
"ttl_ms" => ttl_ms
|
|
561
|
+
}
|
|
562
|
+
json(cache("SetFresh", body, database), "setFresh")["freshUntilMs"]
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Read with stale-while-revalidate and stampede protection. The state is `fresh`,
|
|
566
|
+
# `stale` (with `refresh` true for the one caller holding the refresh lease),
|
|
567
|
+
# `missing` (a cached absence) or `miss` (with `lease` true for the one caller that
|
|
568
|
+
# should compute the value).
|
|
569
|
+
# @return [Hash] `"state"`, plus `"value"` (a binary String), `"refresh"`, `"lease"` and `"retryAfterMs"` when they apply
|
|
570
|
+
def cache_get_fresh(namespace, key, owner, lease_ms, database: @database)
|
|
571
|
+
body = { "namespace" => namespace, "key" => key, "owner" => owner, "lease_ms" => Integer(lease_ms) }
|
|
572
|
+
fresh_read(json(cache("GetFresh", body, database), "getFresh"))
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Negative caching: remember for the given time that the source has no value for the
|
|
576
|
+
# key.
|
|
577
|
+
def cache_set_missing(namespace, key, ttl_ms, database: @database)
|
|
578
|
+
body = { "namespace" => namespace, "key" => key, "ttl_ms" => Integer(ttl_ms) }
|
|
579
|
+
cache("SetMissing", body, database)
|
|
580
|
+
nil
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Cache warming: write the entries that are not already cached, leaving live values
|
|
584
|
+
# alone. Answers `written` and `alreadyCached`.
|
|
585
|
+
# @return [Hash]
|
|
586
|
+
def cache_warm(namespace, entries, ttl_ms: nil, database: @database)
|
|
587
|
+
body = { "namespace" => namespace, "entries" => cache_entries(entries), "ttl_ms" => ttl_ms }
|
|
588
|
+
json(cache("Warm", body, database), "warm")
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Read many values in one request. One result per requested key, in order, with null
|
|
592
|
+
# where the key is absent or expired. Not a snapshot.
|
|
593
|
+
# @return [Array<String, nil>] binary Strings, nil where a key is absent
|
|
594
|
+
def cache_mget(namespace, keys, database: @database)
|
|
595
|
+
body = { "namespace" => namespace, "keys" => Array(keys).map(&:to_s) }
|
|
596
|
+
cache_values(cache("MGet", body, database), "mget")
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Write many values as one batch, all with the same optional TTL. A repeated key is
|
|
600
|
+
# refused. Returns how many were written.
|
|
601
|
+
# @return [Integer]
|
|
602
|
+
def cache_mset(namespace, entries, ttl_ms: nil, database: @database)
|
|
603
|
+
body = { "namespace" => namespace, "entries" => cache_entries(entries), "ttl_ms" => ttl_ms }
|
|
604
|
+
json(cache("MSet", body, database), "mset")["set"]
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Write a value under a condition, with explicit control over the TTL. False means the
|
|
608
|
+
# condition was not met, not that anything failed.
|
|
609
|
+
# @return [Boolean]
|
|
610
|
+
def cache_set_if(namespace, key, value, condition: "Always", expiry: CacheExpiry.clear, database: @database)
|
|
611
|
+
body = {
|
|
612
|
+
"namespace" => namespace,
|
|
613
|
+
"key" => key,
|
|
614
|
+
"value" => bytes(value, "value"),
|
|
615
|
+
"condition" => condition.to_s,
|
|
616
|
+
"expiry" => expiry
|
|
617
|
+
}
|
|
618
|
+
json(cache("SetIf", body, database), "setIf")["set"] == true
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Add scored members to a sorted set, or re-score ones already there. Answers `added`
|
|
622
|
+
# and `updated`. A NaN score is refused.
|
|
623
|
+
# @return [Hash]
|
|
624
|
+
def cache_zadd(namespace, key, members, database: @database)
|
|
625
|
+
body = { "namespace" => namespace, "key" => key, "members" => scored_members(members) }
|
|
626
|
+
json(cache("ZAdd", body, database), "zadd")
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# Remove members from a sorted set, returning how many were present.
|
|
630
|
+
# @return [Integer]
|
|
631
|
+
def cache_zrem(namespace, key, members, database: @database)
|
|
632
|
+
body = { "namespace" => namespace, "key" => key, "members" => byte_list(members, "members") }
|
|
633
|
+
json(cache("ZRem", body, database), "zrem")["removed"]
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# The score of one member, or null when it is not in the set.
|
|
637
|
+
# @return [Float, nil]
|
|
638
|
+
def cache_zscore(namespace, key, member, database: @database)
|
|
639
|
+
body = { "namespace" => namespace, "key" => key, "member" => bytes(member, "member") }
|
|
640
|
+
json(cache("ZScore", body, database), "zscore")["score"]
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# How many members a sorted set holds.
|
|
644
|
+
# @return [Integer]
|
|
645
|
+
def cache_zcard(namespace, key, database: @database)
|
|
646
|
+
body = { "namespace" => namespace, "key" => key }
|
|
647
|
+
json(cache("ZCard", body, database), "zcard")["cardinality"]
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# A member's position in the ordering, or null when it is absent. `rev` counts from
|
|
651
|
+
# the high-scoring end.
|
|
652
|
+
# @return [Integer, nil]
|
|
653
|
+
def cache_zrank(namespace, key, member, rev: false, database: @database)
|
|
654
|
+
body = {
|
|
655
|
+
"namespace" => namespace,
|
|
656
|
+
"key" => key,
|
|
657
|
+
"member" => bytes(member, "member"),
|
|
658
|
+
"rev" => rev ? true : false
|
|
659
|
+
}
|
|
660
|
+
json(cache("ZRank", body, database), "zrank")["rank"]
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# Members by rank, `start..=stop` inclusive. Negative indices count from the end, so
|
|
664
|
+
# `0, -1` is the whole set.
|
|
665
|
+
# @return [Array] members as binary Strings, or `[member, score]` pairs when read with scores
|
|
666
|
+
def cache_zrange(namespace, key, start, stop, rev: false, with_scores: false, database: @database)
|
|
667
|
+
body = {
|
|
668
|
+
"namespace" => namespace,
|
|
669
|
+
"key" => key,
|
|
670
|
+
"start" => Integer(start),
|
|
671
|
+
"stop" => Integer(stop),
|
|
672
|
+
"rev" => rev ? true : false,
|
|
673
|
+
"with_scores" => with_scores ? true : false
|
|
674
|
+
}
|
|
675
|
+
sorted_members(json(cache("ZRange", body, database), "zrange"))
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Members by score, between two bounds. `limit` is `(offset, count)`, applied after
|
|
679
|
+
# the range is selected.
|
|
680
|
+
# @return [Array] members as binary Strings, or `[member, score]` pairs when read with scores
|
|
681
|
+
def cache_zrangebyscore(namespace, key, min, max, rev: false, with_scores: false, limit: nil, database: @database)
|
|
682
|
+
body = {
|
|
683
|
+
"namespace" => namespace,
|
|
684
|
+
"key" => key,
|
|
685
|
+
"min" => score_bound(min),
|
|
686
|
+
"max" => score_bound(max),
|
|
687
|
+
"rev" => rev ? true : false,
|
|
688
|
+
"with_scores" => with_scores ? true : false,
|
|
689
|
+
"limit" => int_pair(limit)
|
|
690
|
+
}
|
|
691
|
+
sorted_members(json(cache("ZRangeByScore", body, database), "zrangebyscore"))
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Add to a member's score, creating it at that score when absent. Returns the
|
|
695
|
+
# resulting score.
|
|
696
|
+
# @return [Float]
|
|
697
|
+
def cache_zincrby(namespace, key, member, by, database: @database)
|
|
698
|
+
body = { "namespace" => namespace, "key" => key, "member" => bytes(member, "member"), "by" => by }
|
|
699
|
+
json(cache("ZIncrBy", body, database), "zincrby")["score"]
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Take a lease on a lock, or refresh one already held by `owner`. Answers `acquired`,
|
|
703
|
+
# `token` (a fencing token that strictly increases with every acquisition and is never
|
|
704
|
+
# reused), `holder` and `expiresAtMs`.
|
|
705
|
+
# @return [Hash]
|
|
706
|
+
def cache_lock_acquire(namespace, key, owner, ttl_ms, database: @database)
|
|
707
|
+
body = { "namespace" => namespace, "key" => key, "owner" => owner, "ttl_ms" => Integer(ttl_ms) }
|
|
708
|
+
json(cache("LockAcquire", body, database), "lockAcquire")
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Extend a lease the caller still holds. Answers `renewed`, `token` and `expiresAtMs`.
|
|
712
|
+
# Refused once the lease has lapsed, even if nobody else has taken the lock since.
|
|
713
|
+
# @return [Hash]
|
|
714
|
+
def cache_lock_renew(namespace, key, owner, ttl_ms, database: @database)
|
|
715
|
+
body = { "namespace" => namespace, "key" => key, "owner" => owner, "ttl_ms" => Integer(ttl_ms) }
|
|
716
|
+
json(cache("LockRenew", body, database), "lockRenew")
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# Give up a lock. Only the current, live holder can release it.
|
|
720
|
+
# @return [Boolean]
|
|
721
|
+
def cache_lock_release(namespace, key, owner, database: @database)
|
|
722
|
+
body = { "namespace" => namespace, "key" => key, "owner" => owner }
|
|
723
|
+
json(cache("LockRelease", body, database), "lockRelease")["released"] == true
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Set one bit of a string value, growing it with zero bytes if needed. Returns the
|
|
727
|
+
# bit's old value. Bits are numbered from the most significant bit of byte 0.
|
|
728
|
+
# @return [Integer]
|
|
729
|
+
def cache_setbit(namespace, key, offset, value, database: @database)
|
|
730
|
+
body = { "namespace" => namespace, "key" => key, "offset" => Integer(offset), "value" => value ? true : false }
|
|
731
|
+
json(cache("SetBit", body, database), "setbit")["previous"]
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# Read one bit. Past the end of the value, and on a missing key, every bit is 0.
|
|
735
|
+
# @return [Integer]
|
|
736
|
+
def cache_getbit(namespace, key, offset, database: @database)
|
|
737
|
+
body = { "namespace" => namespace, "key" => key, "offset" => Integer(offset) }
|
|
738
|
+
json(cache("GetBit", body, database), "getbit")["bit"]
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# Count the set bits, optionally within a byte range `(start, end)` with signed
|
|
742
|
+
# indices.
|
|
743
|
+
# @return [Integer]
|
|
744
|
+
def cache_bitcount(namespace, key, range: nil, database: @database)
|
|
745
|
+
body = { "namespace" => namespace, "key" => key, "range" => int_pair(range) }
|
|
746
|
+
json(cache("BitCount", body, database), "bitcount")["count"]
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# The position of the first bit equal to `bit`, optionally within a byte range, or -1
|
|
750
|
+
# when there is none.
|
|
751
|
+
# @return [Integer]
|
|
752
|
+
def cache_bitpos(namespace, key, bit, range: nil, database: @database)
|
|
753
|
+
body = { "namespace" => namespace, "key" => key, "bit" => bit ? true : false, "range" => int_pair(range) }
|
|
754
|
+
json(cache("BitPos", body, database), "bitpos")["position"]
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Combine several values bitwise and store the result at `dest`. Returns the result's
|
|
758
|
+
# size in bytes.
|
|
759
|
+
# @return [Integer]
|
|
760
|
+
def cache_bitop(namespace, op, dest, keys, database: @database)
|
|
761
|
+
body = { "namespace" => namespace, "op" => op.to_s, "dest" => dest, "keys" => Array(keys).map(&:to_s) }
|
|
762
|
+
json(cache("BitOp", body, database), "bitop")["length"]
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# Add elements to a HyperLogLog, creating it when absent. Returns whether the estimate
|
|
766
|
+
# may have moved.
|
|
767
|
+
# @return [Boolean]
|
|
768
|
+
def cache_pfadd(namespace, key, elements, database: @database)
|
|
769
|
+
body = { "namespace" => namespace, "key" => key, "elements" => byte_list(elements, "elements") }
|
|
770
|
+
json(cache("PfAdd", body, database), "pfadd")["changed"] == true
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# Estimated number of distinct elements. With several keys, the estimate is of their
|
|
774
|
+
# union.
|
|
775
|
+
# @return [Integer]
|
|
776
|
+
def cache_pfcount(namespace, keys, database: @database)
|
|
777
|
+
body = { "namespace" => namespace, "keys" => Array(keys).map(&:to_s) }
|
|
778
|
+
json(cache("PfCount", body, database), "pfcount")["count"]
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# Merge several HyperLogLogs into `dest`, which is included as a source when it
|
|
782
|
+
# already exists.
|
|
783
|
+
def cache_pfmerge(namespace, dest, sources, database: @database)
|
|
784
|
+
body = { "namespace" => namespace, "dest" => dest, "sources" => Array(sources).map(&:to_s) }
|
|
785
|
+
cache("PfMerge", body, database)
|
|
786
|
+
nil
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# Atomically pop an element from one end of `source` and push it onto one end of
|
|
790
|
+
# `destination`. Returns the element, or null when `source` is empty.
|
|
791
|
+
# @return [String, nil]
|
|
792
|
+
def cache_lmove(namespace, source, destination, from, to, database: @database)
|
|
793
|
+
body = {
|
|
794
|
+
"namespace" => namespace,
|
|
795
|
+
"source" => source,
|
|
796
|
+
"destination" => destination,
|
|
797
|
+
"from" => from.to_s,
|
|
798
|
+
"to" => to.to_s
|
|
799
|
+
}
|
|
800
|
+
cache_value(cache("LMove", body, database), "lmove")
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Set the absolute instant an existing live key dies. A deadline already passed
|
|
804
|
+
# deletes the key. Answers `updated` and `deleted`.
|
|
805
|
+
# @return [Hash]
|
|
806
|
+
def cache_expire_at(namespace, key, at_ms, database: @database)
|
|
807
|
+
body = { "namespace" => namespace, "key" => key, "at_ms" => Integer(at_ms) }
|
|
808
|
+
json(cache("ExpireAt", body, database), "expireAt")
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# What this node's cache is doing: lookup hits and misses, the memory budget and the
|
|
812
|
+
# eviction policy. Node-wide, not per database.
|
|
813
|
+
# @return [Hash]
|
|
814
|
+
def cache_stats(database: @database)
|
|
815
|
+
json(request({ "Cache" => "Stats" }, database: database), "stats")
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Delete every cache key in this database, across every namespace. Returns how many
|
|
819
|
+
# keys were removed.
|
|
820
|
+
# @return [Integer]
|
|
821
|
+
def cache_flush_database(database: @database)
|
|
822
|
+
json(request({ "Cache" => "FlushDatabase" }, database: database), "flushDatabase")["cleared"]
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# Concatenate bytes onto what a key already holds, and return the new length. A
|
|
826
|
+
# missing key is created; an existing key keeps its expiry.
|
|
827
|
+
# @return [Integer]
|
|
828
|
+
def cache_append(namespace, key, value, database: @database)
|
|
829
|
+
body = { "namespace" => namespace, "key" => key, "value" => bytes(value, "value") }
|
|
830
|
+
json(cache("Append", body, database), "append")["length"]
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# Write only if the key still holds `expected` (null: the key must be absent). A null
|
|
834
|
+
# `value` deletes the key. Answers `swapped`; on a mismatch, `current` holds the value
|
|
835
|
+
# present when it is valid UTF-8, and `currentIsBinary` is true when it is not.
|
|
836
|
+
# @return [Hash]
|
|
837
|
+
def cache_compare_and_swap(namespace, key, expected, value, ttl_ms: nil, database: @database)
|
|
838
|
+
body = {
|
|
839
|
+
"namespace" => namespace,
|
|
840
|
+
"key" => key,
|
|
841
|
+
"expected" => expected.nil? ? nil : bytes(expected, "expected"),
|
|
842
|
+
"value" => value.nil? ? nil : bytes(value, "value"),
|
|
843
|
+
"ttl_ms" => ttl_ms
|
|
844
|
+
}
|
|
845
|
+
json(cache("CompareAndSwap", body, database), "compareAndSwap")
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# Read a key and delete it in one atomic step. Null when the key was absent or
|
|
849
|
+
# expired.
|
|
850
|
+
# @return [String, nil]
|
|
851
|
+
def cache_getdel(namespace, key, database: @database)
|
|
852
|
+
body = { "namespace" => namespace, "key" => key }
|
|
853
|
+
cache_value(cache("GetDel", body, database), "getdel")
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Write a value and return the one it replaced, atomically. Any TTL on the old value
|
|
857
|
+
# is dropped.
|
|
858
|
+
# @return [String, nil]
|
|
859
|
+
def cache_getset(namespace, key, value, ttl_ms: nil, database: @database)
|
|
860
|
+
body = { "namespace" => namespace, "key" => key, "value" => bytes(value, "value"), "ttl_ms" => ttl_ms }
|
|
861
|
+
cache_value(cache("GetSet", body, database), "getset")
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# Read a key and change its expiry in the same atomic step. The value itself is never
|
|
865
|
+
# modified.
|
|
866
|
+
# @return [String, nil]
|
|
867
|
+
def cache_getex(namespace, key, expiry, database: @database)
|
|
868
|
+
body = { "namespace" => namespace, "key" => key, "expiry" => expiry }
|
|
869
|
+
cache_value(cache("GetEx", body, database), "getex")
|
|
870
|
+
end
|
|
871
|
+
|
|
872
|
+
# Keep only the inclusive index range of a list, dropping everything outside it.
|
|
873
|
+
# Answers `length` and `removed`.
|
|
874
|
+
# @return [Hash]
|
|
875
|
+
def cache_ltrim(namespace, key, start, stop, database: @database)
|
|
876
|
+
body = { "namespace" => namespace, "key" => key, "start" => Integer(start), "stop" => Integer(stop) }
|
|
877
|
+
json(cache("LTrim", body, database), "ltrim")
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# Every member of every named set, without duplicates. A missing key is the empty set.
|
|
881
|
+
# @return [Array<String>]
|
|
882
|
+
def cache_sunion(namespace, keys, database: @database)
|
|
883
|
+
body = { "namespace" => namespace, "keys" => Array(keys).map(&:to_s) }
|
|
884
|
+
binaries(json(cache("SUnion", body, database), "sunion")["members"])
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# The members present in every named set.
|
|
888
|
+
# @return [Array<String>]
|
|
889
|
+
def cache_sinter(namespace, keys, database: @database)
|
|
890
|
+
body = { "namespace" => namespace, "keys" => Array(keys).map(&:to_s) }
|
|
891
|
+
binaries(json(cache("SInter", body, database), "sinter")["members"])
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# The members of the first set that are in none of the rest.
|
|
895
|
+
# @return [Array<String>]
|
|
896
|
+
def cache_sdiff(namespace, keys, database: @database)
|
|
897
|
+
body = { "namespace" => namespace, "keys" => Array(keys).map(&:to_s) }
|
|
898
|
+
binaries(json(cache("SDiff", body, database), "sdiff")["members"])
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# Random members of a set, without removing them. No count: at most one. A positive
|
|
902
|
+
# count: up to that many distinct members. A negative count: exactly that many,
|
|
903
|
+
# repeats allowed.
|
|
904
|
+
# @return [Array<String>]
|
|
905
|
+
def cache_srandmember(namespace, key, count: nil, database: @database)
|
|
906
|
+
body = { "namespace" => namespace, "key" => key, "count" => count }
|
|
907
|
+
binaries(json(cache("SRandMember", body, database), "srandmember")["members"])
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
# Add to one hash field, atomically, returning its new value. A missing field starts
|
|
911
|
+
# at zero.
|
|
912
|
+
# @return [Integer]
|
|
913
|
+
def cache_hincrby(namespace, key, field, by, database: @database)
|
|
914
|
+
body = { "namespace" => namespace, "key" => key, "field" => bytes(field, "field"), "by" => Integer(by) }
|
|
915
|
+
json(cache("HIncrBy", body, database), "hincrby")["value"]
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
# Run cache operations in order in one round trip, each exactly as if it had been sent
|
|
919
|
+
# alone. A failing operation does not stop the rest.
|
|
920
|
+
# @return [Array<Hash>] one per operation: `{"ok" => data}` or `{"error" => message}`
|
|
921
|
+
def cache_pipeline(ops, database: @database)
|
|
922
|
+
body = { "ops" => ops }
|
|
923
|
+
json(cache("Pipeline", body, database), "pipeline")["results"] || []
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# Run cache operations in order holding the cache write lock, so no other writer
|
|
927
|
+
# interleaves with them. Every operation's inputs are checked before any runs; after
|
|
928
|
+
# that there is no rollback.
|
|
929
|
+
# @return [Array<Hash>] one per operation: `{"ok" => data}` or `{"error" => message}`
|
|
930
|
+
def cache_transaction(ops, database: @database)
|
|
931
|
+
body = { "ops" => ops }
|
|
932
|
+
json(cache("Transaction", body, database), "transaction")["results"] || []
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
# Store a script: operations run as one transaction, where any string (or value)
|
|
936
|
+
# written `$1`, `$2`, ... is replaced by that argument at run time. Loading the same
|
|
937
|
+
# name again replaces it.
|
|
938
|
+
def cache_script_load(name, ops, database: @database)
|
|
939
|
+
body = { "name" => name, "ops" => ops }
|
|
940
|
+
cache("ScriptLoad", body, database)
|
|
941
|
+
nil
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# Run a stored script with these arguments as one transaction.
|
|
945
|
+
# @return [Array<Hash>] one per operation: `{"ok" => data}` or `{"error" => message}`
|
|
946
|
+
def cache_script_run(name, args: [], database: @database)
|
|
947
|
+
body = { "name" => name, "args" => Array(args).map(&:to_s) }
|
|
948
|
+
json(cache("ScriptRun", body, database), "scriptRun")["results"] || []
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
# The most frequently addressed keys on this node since it started, with estimated
|
|
952
|
+
# counts. The server must be configured with `[cache] hot_keys` above zero; otherwise
|
|
953
|
+
# it refuses.
|
|
954
|
+
# @return [Hash]
|
|
955
|
+
def cache_hot_keys(limit: nil, database: @database)
|
|
956
|
+
body = { "limit" => limit }
|
|
957
|
+
json(cache("HotKeys", body, database), "hotKeys")
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# Delete every expired cache key in this database now, rather than leaving it to
|
|
961
|
+
# compaction. Returns how many went.
|
|
962
|
+
# @return [Integer]
|
|
963
|
+
def cache_purge_expired(database: @database)
|
|
964
|
+
json(request({ "Cache" => "PurgeExpired" }, database: database), "purgeExpired")["purged"]
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
# Pop the head of the first non-empty list among `keys`, waiting up to the timeout for
|
|
968
|
+
# one to receive an element. Null when the wait ran out.
|
|
969
|
+
# @return [Array(String, String), nil] `[key, value]`, or nil when the wait ran out
|
|
970
|
+
def cache_blpop(namespace, keys, timeout_ms, database: @database)
|
|
971
|
+
body = { "namespace" => namespace, "keys" => Array(keys).map(&:to_s), "timeout_ms" => Integer(timeout_ms) }
|
|
972
|
+
popped(cache("BLPop", body, database), "blpop")
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Pop the tail of the first non-empty list among `keys`, waiting up to the timeout for
|
|
976
|
+
# one to receive an element. Null when the wait ran out.
|
|
977
|
+
# @return [Array(String, String), nil] `[key, value]`, or nil when the wait ran out
|
|
978
|
+
def cache_brpop(namespace, keys, timeout_ms, database: @database)
|
|
979
|
+
body = { "namespace" => namespace, "keys" => Array(keys).map(&:to_s), "timeout_ms" => Integer(timeout_ms) }
|
|
980
|
+
popped(cache("BRPop", body, database), "brpop")
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
# Cap a namespace at `max_keys` live keys and `max_bytes` of values. A write that
|
|
984
|
+
# would pass either is refused. Both absent removes the limit.
|
|
985
|
+
# @return [Hash]
|
|
986
|
+
def cache_set_namespace_limit(namespace, max_keys: nil, max_bytes: nil, database: @database)
|
|
987
|
+
body = { "namespace" => namespace, "max_keys" => max_keys, "max_bytes" => max_bytes }
|
|
988
|
+
json(cache("SetNamespaceLimit", body, database), "setNamespaceLimit")
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
# A namespace's limit and what it holds now: `max_keys`, `max_bytes`, `keys` and
|
|
992
|
+
# `bytes`.
|
|
993
|
+
# @return [Hash]
|
|
994
|
+
def cache_get_namespace_limit(namespace, database: @database)
|
|
995
|
+
body = { "namespace" => namespace }
|
|
996
|
+
json(cache("GetNamespaceLimit", body, database), "getNamespaceLimit")
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
# Append a message to a channel, returning the id it was given.
|
|
1000
|
+
# @return [Integer]
|
|
1001
|
+
def cache_publish(channel, message, database: @database)
|
|
1002
|
+
body = { "channel" => channel, "message" => message }
|
|
1003
|
+
json(cache("Publish", body, database), "publish")["id"]
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
# The messages after id `after` on these channels (a trailing `*` is a pattern).
|
|
1007
|
+
# Answers `messages` and `next`, the id to pass as `after` next time.
|
|
1008
|
+
# @return [Hash]
|
|
1009
|
+
def cache_poll(channels, after: 0, limit: 100, database: @database)
|
|
1010
|
+
body = { "channels" => Array(channels).map(&:to_s), "after" => Integer(after), "limit" => Integer(limit) }
|
|
1011
|
+
json(cache("Poll", body, database), "poll")
|
|
1012
|
+
end
|
|
1013
|
+
|
|
504
1014
|
# ---- document --------------------------------------------------------------
|
|
505
1015
|
|
|
506
1016
|
def doc_create_collection(collection, database: @database)
|
|
@@ -510,41 +1020,41 @@ module TriCoreDB
|
|
|
510
1020
|
|
|
511
1021
|
# @param id [String, nil] omitted, the server generates one
|
|
512
1022
|
# @return [String] the stored id
|
|
513
|
-
def doc_insert(collection, doc, id: nil, database: @database)
|
|
514
|
-
json(document("Insert", { "collection" => collection, "id" => id, "document" => doc }, database), "insert")["id"]
|
|
1023
|
+
def doc_insert(collection, doc, id: nil, txn: nil, database: @database)
|
|
1024
|
+
json(document("Insert", { "collection" => collection, "id" => id, "document" => doc }, database, txn), "insert")["id"]
|
|
515
1025
|
end
|
|
516
1026
|
|
|
517
1027
|
# @return [Hash, nil]
|
|
518
|
-
def doc_get(collection, id, database: @database)
|
|
519
|
-
documents(document("Get", { "collection" => collection, "id" => id }, database), "get").first
|
|
1028
|
+
def doc_get(collection, id, txn: nil, database: @database)
|
|
1029
|
+
documents(document("Get", { "collection" => collection, "id" => id }, database, txn), "get").first
|
|
520
1030
|
end
|
|
521
1031
|
|
|
522
1032
|
# @param filter [String, Hash] from {Filter}
|
|
523
1033
|
# @return [Array<Hash>]
|
|
524
|
-
def doc_find(collection, filter = Filter.all, limit: nil, database: @database)
|
|
525
|
-
documents(document("Find", { "collection" => collection, "filter" => filter, "limit" => limit }, database), "find")
|
|
1034
|
+
def doc_find(collection, filter = Filter.all, limit: nil, txn: nil, database: @database)
|
|
1035
|
+
documents(document("Find", { "collection" => collection, "filter" => filter, "limit" => limit }, database, txn), "find")
|
|
526
1036
|
end
|
|
527
1037
|
|
|
528
1038
|
# Set fields on an existing document (dot paths). Not an upsert.
|
|
529
|
-
def doc_update(collection, id, set, database: @database)
|
|
530
|
-
document("Update", { "collection" => collection, "id" => id, "set" => set }, database)
|
|
1039
|
+
def doc_update(collection, id, set, txn: nil, database: @database)
|
|
1040
|
+
document("Update", { "collection" => collection, "id" => id, "set" => set }, database, txn)
|
|
531
1041
|
nil
|
|
532
1042
|
end
|
|
533
1043
|
|
|
534
1044
|
# @return [Hash] `"updated"`, `"inserted"`, `"id"`
|
|
535
|
-
def doc_update_one(collection, id, set: nil, inc: nil, upsert: false, database: @database)
|
|
1045
|
+
def doc_update_one(collection, id, set: nil, inc: nil, upsert: false, txn: nil, database: @database)
|
|
536
1046
|
body = { "collection" => collection, "id" => id, "update" => update_body(set, inc), "upsert" => upsert ? true : false }
|
|
537
|
-
json(document("UpdateOne", body, database), "updateOne")
|
|
1047
|
+
json(document("UpdateOne", body, database, txn), "updateOne")
|
|
538
1048
|
end
|
|
539
1049
|
|
|
540
1050
|
# @return [Hash] `"matched"`, `"modified"`
|
|
541
|
-
def doc_update_many(collection, filter, set: nil, inc: nil, database: @database)
|
|
1051
|
+
def doc_update_many(collection, filter, set: nil, inc: nil, txn: nil, database: @database)
|
|
542
1052
|
body = { "collection" => collection, "filter" => filter, "update" => update_body(set, inc) }
|
|
543
|
-
json(document("UpdateMany", body, database), "updateMany")
|
|
1053
|
+
json(document("UpdateMany", body, database, txn), "updateMany")
|
|
544
1054
|
end
|
|
545
1055
|
|
|
546
|
-
def doc_delete(collection, id, database: @database)
|
|
547
|
-
document("Delete", { "collection" => collection, "id" => id }, database)
|
|
1056
|
+
def doc_delete(collection, id, txn: nil, database: @database)
|
|
1057
|
+
document("Delete", { "collection" => collection, "id" => id }, database, txn)
|
|
548
1058
|
nil
|
|
549
1059
|
end
|
|
550
1060
|
|
|
@@ -581,8 +1091,125 @@ module TriCoreDB
|
|
|
581
1091
|
|
|
582
1092
|
# @param pipeline [Array<Hash>] from {Stage}
|
|
583
1093
|
# @return [Array<Hash>]
|
|
584
|
-
def doc_aggregate(collection, pipeline, database: @database)
|
|
585
|
-
documents(document("Aggregate", { "collection" => collection, "pipeline" => pipeline }, database), "aggregate")
|
|
1094
|
+
def doc_aggregate(collection, pipeline, txn: nil, database: @database)
|
|
1095
|
+
documents(document("Aggregate", { "collection" => collection, "pipeline" => pipeline }, database, txn), "aggregate")
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# Swap a document's whole body for a new one: afterwards the stored body is exactly
|
|
1099
|
+
# `document`, plus `_id`. With `upsert`, a missing document is created rather than
|
|
1100
|
+
# refused. Answers `id`, `replaced`, `inserted` and, when asked for, `document`.
|
|
1101
|
+
# @return [Hash]
|
|
1102
|
+
def doc_replace(collection, id, document,
|
|
1103
|
+
upsert: false, return_document: "None", expected_version: nil, txn: nil, database: @database)
|
|
1104
|
+
body = {
|
|
1105
|
+
"collection" => collection,
|
|
1106
|
+
"id" => id,
|
|
1107
|
+
"document" => document,
|
|
1108
|
+
"upsert" => upsert ? true : false,
|
|
1109
|
+
"return" => return_document.to_s,
|
|
1110
|
+
"expected_version" => expected_version
|
|
1111
|
+
}
|
|
1112
|
+
json(document("Replace", body, database, txn), "replace")
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
# Insert several documents as one atomic batch: either every document lands or none
|
|
1116
|
+
# does. Each document's id is its own `_id` when it carries one, and is generated
|
|
1117
|
+
# otherwise. Returns the ids, in order.
|
|
1118
|
+
# @return [Array<String>]
|
|
1119
|
+
def doc_insert_many(collection, documents, txn: nil, database: @database)
|
|
1120
|
+
body = { "collection" => collection, "documents" => documents }
|
|
1121
|
+
json(document("InsertMany", body, database, txn), "insertMany")["ids"] || []
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
# Delete every document a filter matches, returning how many went.
|
|
1125
|
+
# @return [Integer]
|
|
1126
|
+
def doc_delete_many(collection, filter, txn: nil, database: @database)
|
|
1127
|
+
body = { "collection" => collection, "filter" => filter }
|
|
1128
|
+
json(document("DeleteMany", body, database, txn), "deleteMany")["deleted"]
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
# Move a collection, with its documents and indexes, to a new name. Refused when the
|
|
1132
|
+
# new name already exists.
|
|
1133
|
+
def doc_rename_collection(collection, to, database: @database)
|
|
1134
|
+
body = { "collection" => collection, "to" => to }
|
|
1135
|
+
document("RenameCollection", body, database)
|
|
1136
|
+
nil
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
# Delete the documents a TTL index says have expired, now. Returns how many went.
|
|
1140
|
+
# @return [Integer]
|
|
1141
|
+
def doc_purge_expired(collection, database: @database)
|
|
1142
|
+
body = { "collection" => collection }
|
|
1143
|
+
json(document("PurgeExpired", body, database), "purgeExpired")["purged"]
|
|
1144
|
+
end
|
|
1145
|
+
|
|
1146
|
+
# Run a find and describe how it ran instead of returning its documents: the plan, the
|
|
1147
|
+
# index, why it scanned, the estimate, records examined and returned, and the time
|
|
1148
|
+
# taken.
|
|
1149
|
+
# @return [Hash]
|
|
1150
|
+
def doc_explain(collection, filter: Filter.all, limit: nil, sort: [], skip: nil, database: @database)
|
|
1151
|
+
body = {
|
|
1152
|
+
"collection" => collection,
|
|
1153
|
+
"filter" => filter,
|
|
1154
|
+
"limit" => limit,
|
|
1155
|
+
"sort" => sort || [],
|
|
1156
|
+
"skip" => skip
|
|
1157
|
+
}
|
|
1158
|
+
json(document("Explain", body, database), "explain")
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# Open a document transaction spanning requests, returning its id. Each later call
|
|
1162
|
+
# given that id as `txn` reads and writes inside it, seeing its own writes and nobody
|
|
1163
|
+
# else's since it began. Snapshot isolation, or serializable.
|
|
1164
|
+
# @return [String]
|
|
1165
|
+
def doc_begin_transaction(serializable: false, database: @database)
|
|
1166
|
+
body = { "serializable" => serializable ? true : false }
|
|
1167
|
+
json(document("BeginTransaction", body, database), "beginTransaction")["txn"]
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
# Commit a document transaction: every write lands as one atomic batch, or a conflict
|
|
1171
|
+
# is returned and nothing lands.
|
|
1172
|
+
# @return [Hash]
|
|
1173
|
+
def doc_commit_transaction(txn, database: @database)
|
|
1174
|
+
json(request({ "Document" => "CommitTransaction" }, database: database, txn: txn), "commitTransaction")
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
# Discard a document transaction.
|
|
1178
|
+
def doc_rollback_transaction(txn, database: @database)
|
|
1179
|
+
request({ "Document" => "RollbackTransaction" }, database: database, txn: txn)
|
|
1180
|
+
nil
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# Rebuild one index from the stored documents. Answers `rebuilt`, `documents` and
|
|
1184
|
+
# `entries`.
|
|
1185
|
+
# @return [Hash]
|
|
1186
|
+
def doc_rebuild_index(collection, index_name, database: @database)
|
|
1187
|
+
body = { "collection" => collection, "index_name" => index_name }
|
|
1188
|
+
json(document("RebuildIndex", body, database), "rebuildIndex")
|
|
1189
|
+
end
|
|
1190
|
+
|
|
1191
|
+
# Set a collection's options (`schema`, `defaults`, `versioned`), replacing any it
|
|
1192
|
+
# had. Refused when an existing document would break the new schema.
|
|
1193
|
+
# @return [Hash]
|
|
1194
|
+
def doc_set_collection_options(collection, options, database: @database)
|
|
1195
|
+
body = { "collection" => collection, "options" => options }
|
|
1196
|
+
json(document("SetCollectionOptions", body, database), "setCollectionOptions")
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
# A collection's options.
|
|
1200
|
+
# @return [Hash]
|
|
1201
|
+
def doc_get_collection_options(collection, database: @database)
|
|
1202
|
+
body = { "collection" => collection }
|
|
1203
|
+
json(document("GetCollectionOptions", body, database), "getCollectionOptions")["options"] || {}
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
# Many inserts, updates, replaces and deletes on one collection, applied in order as
|
|
1207
|
+
# one atomic batch: every operation lands or none does. Answers `inserted`,
|
|
1208
|
+
# `inserted_ids`, `updated`, `upserted`, `replaced` and `deleted`.
|
|
1209
|
+
# @return [Hash]
|
|
1210
|
+
def doc_bulk_write(collection, ops, txn: nil, database: @database)
|
|
1211
|
+
body = { "collection" => collection, "ops" => ops }
|
|
1212
|
+
json(document("BulkWrite", body, database, txn), "bulkWrite")
|
|
586
1213
|
end
|
|
587
1214
|
|
|
588
1215
|
# ---- vector ----------------------------------------------------------------
|
|
@@ -639,6 +1266,214 @@ module TriCoreDB
|
|
|
639
1266
|
nil
|
|
640
1267
|
end
|
|
641
1268
|
|
|
1269
|
+
# Embed texts with the server's built-in embedder (deterministic feature hashing:
|
|
1270
|
+
# lexical, no model). Nothing is stored. Answers `vectors`, `sparse`, `embedder` and
|
|
1271
|
+
# `dimension`.
|
|
1272
|
+
# @return [Hash]
|
|
1273
|
+
def vector_embed(texts, dimension, database: @database)
|
|
1274
|
+
body = { "texts" => Array(texts).map(&:to_s), "dimension" => Integer(dimension) }
|
|
1275
|
+
json(vector("Embed", body, database), "embed")
|
|
1276
|
+
end
|
|
1277
|
+
|
|
1278
|
+
# Embed each text at the collection's dimension and upsert the vectors as one atomic
|
|
1279
|
+
# batch. Items are `{id, text, metadata}`; the text is kept in the metadata under
|
|
1280
|
+
# `text`. Returns how many were written.
|
|
1281
|
+
# @return [Integer]
|
|
1282
|
+
def vector_upsert_text(collection, items, database: @database)
|
|
1283
|
+
body = { "collection" => collection, "items" => items }
|
|
1284
|
+
json(vector("UpsertText", body, database), "upsertText")["upserted"]
|
|
1285
|
+
end
|
|
1286
|
+
|
|
1287
|
+
# Point an alias at a collection; a null collection removes it. Every operation naming
|
|
1288
|
+
# the alias acts on the collection.
|
|
1289
|
+
def vector_set_alias(alias_name, collection: nil, database: @database)
|
|
1290
|
+
body = { "alias" => alias_name, "collection" => collection }
|
|
1291
|
+
vector("SetAlias", body, database)
|
|
1292
|
+
nil
|
|
1293
|
+
end
|
|
1294
|
+
|
|
1295
|
+
# Every alias and the collection it names, as `{alias, collection}`.
|
|
1296
|
+
# @return [Array<Hash>]
|
|
1297
|
+
def vector_list_aliases(database: @database)
|
|
1298
|
+
json(request({ "Vector" => "ListAliases" }, database: database), "listAliases")["aliases"] || []
|
|
1299
|
+
end
|
|
1300
|
+
|
|
1301
|
+
# Declare a named vector on a collection: a second vector per item, with its own
|
|
1302
|
+
# dimension and metric.
|
|
1303
|
+
def vector_add_named_vector(collection, name, dimension, metric: "cosine", database: @database)
|
|
1304
|
+
body = {
|
|
1305
|
+
"collection" => collection,
|
|
1306
|
+
"name" => name,
|
|
1307
|
+
"dimension" => Integer(dimension),
|
|
1308
|
+
"metric" => metric.to_s
|
|
1309
|
+
}
|
|
1310
|
+
vector("AddNamedVector", body, database)
|
|
1311
|
+
nil
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
# Write an item's vectors by name (`default` is the collection's own), each with the
|
|
1315
|
+
# same metadata.
|
|
1316
|
+
def vector_upsert_named(collection, id, vectors, metadata: nil, database: @database)
|
|
1317
|
+
body = { "collection" => collection, "id" => id, "vectors" => vectors, "metadata" => metadata }
|
|
1318
|
+
vector("UpsertNamed", body, database)
|
|
1319
|
+
nil
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
# Write many vectors as one atomic batch. Items are `{id, vector, metadata}`. Every
|
|
1323
|
+
# item is validated before any is written, and a repeated id is refused. Returns how
|
|
1324
|
+
# many were written.
|
|
1325
|
+
# @return [Integer]
|
|
1326
|
+
def vector_upsert_many(collection, items, database: @database)
|
|
1327
|
+
body = { "collection" => collection, "items" => items }
|
|
1328
|
+
json(vector("UpsertMany", body, database), "upsertMany")["upserted"]
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
# Delete many vectors by id as one atomic batch, returning how many of the ids were
|
|
1332
|
+
# present.
|
|
1333
|
+
# @return [Integer]
|
|
1334
|
+
def vector_delete_many(collection, ids, database: @database)
|
|
1335
|
+
body = { "collection" => collection, "ids" => Array(ids).map(&:to_s) }
|
|
1336
|
+
json(vector("DeleteMany", body, database), "deleteMany")["deleted"]
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
# Run several query vectors against one collection in one request. One result list per
|
|
1340
|
+
# query, in the order asked, each what a single search with the same arguments
|
|
1341
|
+
# returns.
|
|
1342
|
+
# @return [Array<Array<Hash>>] one list of hits per query
|
|
1343
|
+
def vector_search_batch(collection, vectors, top_k,
|
|
1344
|
+
filter: nil, min_score: nil, include_vectors: false, options: nil, database: @database)
|
|
1345
|
+
body = {
|
|
1346
|
+
"collection" => collection,
|
|
1347
|
+
"vectors" => vectors.map { |v| numbers(v) },
|
|
1348
|
+
"top_k" => Integer(top_k),
|
|
1349
|
+
"filter" => filter,
|
|
1350
|
+
"min_score" => min_score,
|
|
1351
|
+
"include_vectors" => include_vectors ? true : false
|
|
1352
|
+
}.merge(search_options(options))
|
|
1353
|
+
json(vector("SearchBatch", body, database), "searchBatch")["results"] || []
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
# Delete every vector whose metadata matches a filter, as one atomic batch. An empty
|
|
1357
|
+
# filter is refused.
|
|
1358
|
+
# @return [Integer]
|
|
1359
|
+
def vector_delete_by_filter(collection, filter, database: @database)
|
|
1360
|
+
body = { "collection" => collection, "filter" => filter }
|
|
1361
|
+
json(vector("DeleteByFilter", body, database), "deleteByFilter")["deleted"]
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
# Merge `set` into the metadata of every vector matching a filter, as one atomic
|
|
1365
|
+
# batch. Vectors are never touched.
|
|
1366
|
+
# @return [Integer]
|
|
1367
|
+
def vector_update_by_filter(collection, filter, set, database: @database)
|
|
1368
|
+
body = { "collection" => collection, "filter" => filter, "set" => set }
|
|
1369
|
+
json(vector("UpdateByFilter", body, database), "updateByFilter")["updated"]
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
# How many vectors a collection holds, or, with a filter, how many match it. An exact
|
|
1373
|
+
# count.
|
|
1374
|
+
# @return [Integer]
|
|
1375
|
+
def vector_count(collection, filter: nil, database: @database)
|
|
1376
|
+
body = { "collection" => collection, "filter" => filter }
|
|
1377
|
+
json(vector("Count", body, database), "count")["count"]
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
# The partitions of a collection: each distinct value of `field` (`_namespace` or
|
|
1381
|
+
# `_tenant`) with its vector count, under `partitions`, plus `unpartitioned`.
|
|
1382
|
+
# @return [Hash]
|
|
1383
|
+
def vector_list_partitions(collection, field: "_namespace", database: @database)
|
|
1384
|
+
body = { "collection" => collection, "field" => field }
|
|
1385
|
+
json(vector("ListPartitions", body, database), "listPartitions")
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1388
|
+
# Rename a collection, moving its catalog record and every vector with it. Refused
|
|
1389
|
+
# when the new name already exists.
|
|
1390
|
+
def vector_rename_collection(collection, to, database: @database)
|
|
1391
|
+
body = { "collection" => collection, "to" => to }
|
|
1392
|
+
vector("RenameCollection", body, database)
|
|
1393
|
+
nil
|
|
1394
|
+
end
|
|
1395
|
+
|
|
1396
|
+
# Rebuild the collection's ANN index from its stored vectors and persist it, clearing
|
|
1397
|
+
# every tombstone.
|
|
1398
|
+
# @return [Hash]
|
|
1399
|
+
def vector_rebuild_index(collection, database: @database)
|
|
1400
|
+
body = { "collection" => collection }
|
|
1401
|
+
json(vector("RebuildIndex", body, database), "rebuildIndex")
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
# The shape of the collection's ANN index: layers, degrees, tombstones, reachability
|
|
1405
|
+
# and memory.
|
|
1406
|
+
# @return [Hash]
|
|
1407
|
+
def vector_index_stats(collection, database: @database)
|
|
1408
|
+
body = { "collection" => collection }
|
|
1409
|
+
json(vector("IndexStats", body, database), "indexStats")
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
# Measure the ANN index's recall: sampled stored vectors are each searched through the
|
|
1413
|
+
# index and by an exact scan, and the overlap is reported under `recall`.
|
|
1414
|
+
# @return [Hash]
|
|
1415
|
+
def vector_measure_recall(collection, sample: nil, top_k: nil, ef_search: nil, database: @database)
|
|
1416
|
+
body = { "collection" => collection, "sample" => sample, "top_k" => top_k, "ef_search" => ef_search }
|
|
1417
|
+
json(vector("MeasureRecall", body, database), "measureRecall")
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
# Search with several query vectors and return one fused ranking.
|
|
1421
|
+
# @return [Array<Hash>] `"id"`, `"score"`, `"metadata"`, best first
|
|
1422
|
+
def vector_search_multi(collection, vectors, top_k, fusion: "rrf", filter: nil, database: @database)
|
|
1423
|
+
body = {
|
|
1424
|
+
"collection" => collection,
|
|
1425
|
+
"vectors" => vectors.map { |v| numbers(v) },
|
|
1426
|
+
"top_k" => Integer(top_k),
|
|
1427
|
+
"fusion" => fusion.to_s,
|
|
1428
|
+
"filter" => filter
|
|
1429
|
+
}
|
|
1430
|
+
json(vector("SearchMulti", body, database), "searchMulti")["results"] || []
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
# Change a collection's configuration, returning it. `normalize` can only change on an
|
|
1434
|
+
# empty collection; `m` and `ef_construction` shape the HNSW graph; `multi_tenant`
|
|
1435
|
+
# requires every vector to carry a `_tenant`.
|
|
1436
|
+
# @return [Hash]
|
|
1437
|
+
def vector_set_collection_config(collection,
|
|
1438
|
+
normalize: nil, m: nil, ef_construction: nil, multi_tenant: nil, database: @database)
|
|
1439
|
+
body = {
|
|
1440
|
+
"collection" => collection,
|
|
1441
|
+
"normalize" => normalize,
|
|
1442
|
+
"m" => m,
|
|
1443
|
+
"ef_construction" => ef_construction,
|
|
1444
|
+
"multi_tenant" => multi_tenant
|
|
1445
|
+
}
|
|
1446
|
+
json(vector("SetCollectionConfig", body, database), "setCollectionConfig")
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
# Copy a collection (configuration and every vector) to a new name. Refused when the
|
|
1450
|
+
# new name exists.
|
|
1451
|
+
def vector_copy_collection(collection, to, database: @database)
|
|
1452
|
+
body = { "collection" => collection, "to" => to }
|
|
1453
|
+
vector("CopyCollection", body, database)
|
|
1454
|
+
nil
|
|
1455
|
+
end
|
|
1456
|
+
|
|
1457
|
+
# Search with a query built from examples: the sum of the positive examples minus the
|
|
1458
|
+
# sum of the negative ones. An example is a stored vector's id or a vector given
|
|
1459
|
+
# inline. Examples named by id are left out of the results unless `exclude_examples`
|
|
1460
|
+
# is false.
|
|
1461
|
+
# @return [Array<Hash>] `"id"`, `"score"`, `"metadata"`, best first
|
|
1462
|
+
def vector_search_by_examples(collection, top_k,
|
|
1463
|
+
positive: [], negative: [], filter: nil, min_score: nil, include_vectors: false, exclude_examples: true, options: nil, database: @database)
|
|
1464
|
+
body = {
|
|
1465
|
+
"collection" => collection,
|
|
1466
|
+
"top_k" => Integer(top_k),
|
|
1467
|
+
"positive" => positive,
|
|
1468
|
+
"negative" => negative,
|
|
1469
|
+
"filter" => filter,
|
|
1470
|
+
"min_score" => min_score,
|
|
1471
|
+
"include_vectors" => include_vectors ? true : false,
|
|
1472
|
+
"exclude_examples" => exclude_examples ? true : false
|
|
1473
|
+
}.merge(search_options(options))
|
|
1474
|
+
json(vector("SearchByExamples", body, database), "searchByExamples")["results"] || []
|
|
1475
|
+
end
|
|
1476
|
+
|
|
642
1477
|
# ---- graph -----------------------------------------------------------------
|
|
643
1478
|
#
|
|
644
1479
|
# `direction` is `outgoing` (the default), `incoming` or `both`.
|
|
@@ -743,6 +1578,313 @@ module TriCoreDB
|
|
|
743
1578
|
json(graph_op("Query", { "graph" => graph, "cypher" => cypher }, database), "query")
|
|
744
1579
|
end
|
|
745
1580
|
|
|
1581
|
+
# Write many nodes and edges as one unit: every record lands or none does. Nodes are
|
|
1582
|
+
# `{id, labels, properties}` and edges `{id, from, to, label, properties}`; an edge
|
|
1583
|
+
# may join nodes created in the same batch. Answers `nodes` and `edges`.
|
|
1584
|
+
# @return [Hash]
|
|
1585
|
+
def graph_add_many(graph, nodes: [], edges: [], database: @database)
|
|
1586
|
+
body = { "graph" => graph, "nodes" => nodes || [], "edges" => edges || [] }
|
|
1587
|
+
json(graph_op("AddMany", body, database), "addMany")
|
|
1588
|
+
end
|
|
1589
|
+
|
|
1590
|
+
# Delete many nodes and edges as one unit. An id that is not there is skipped. With
|
|
1591
|
+
# `detach`, every edge touching a removed node goes too. Answers `nodes_removed` and
|
|
1592
|
+
# `edges_removed`.
|
|
1593
|
+
# @return [Hash]
|
|
1594
|
+
def graph_remove_many(graph, nodes: [], edges: [], detach: false, database: @database)
|
|
1595
|
+
body = {
|
|
1596
|
+
"graph" => graph,
|
|
1597
|
+
"nodes" => Array(nodes).map(&:to_s),
|
|
1598
|
+
"edges" => Array(edges).map(&:to_s),
|
|
1599
|
+
"detach" => detach ? true : false
|
|
1600
|
+
}
|
|
1601
|
+
json(graph_op("RemoveMany", body, database), "removeMany")
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
# Change an existing node in place, returning it. `properties` is a JSON merge patch:
|
|
1605
|
+
# a key set to a value is set, a key set to null is removed. `remove_labels` applies
|
|
1606
|
+
# before `add_labels`.
|
|
1607
|
+
# @return [Hash]
|
|
1608
|
+
def graph_update_node(graph, id, properties: {}, add_labels: [], remove_labels: [], database: @database)
|
|
1609
|
+
body = {
|
|
1610
|
+
"graph" => graph,
|
|
1611
|
+
"id" => id,
|
|
1612
|
+
"properties" => properties || {},
|
|
1613
|
+
"add_labels" => Array(add_labels).map(&:to_s),
|
|
1614
|
+
"remove_labels" => Array(remove_labels).map(&:to_s)
|
|
1615
|
+
}
|
|
1616
|
+
json(graph_op("UpdateNode", body, database), "updateNode")
|
|
1617
|
+
end
|
|
1618
|
+
|
|
1619
|
+
# Change an existing edge in place, returning it. `properties` merges as for a node,
|
|
1620
|
+
# and `label`, when given, changes the edge's type. The endpoints cannot change.
|
|
1621
|
+
# @return [Hash]
|
|
1622
|
+
def graph_update_edge(graph, id, properties: {}, label: nil, database: @database)
|
|
1623
|
+
body = { "graph" => graph, "id" => id, "properties" => properties || {}, "label" => label }
|
|
1624
|
+
json(graph_op("UpdateEdge", body, database), "updateEdge")
|
|
1625
|
+
end
|
|
1626
|
+
|
|
1627
|
+
# Copy a graph (nodes, edges and schema) to a new name. Refused when the new name
|
|
1628
|
+
# exists. Answers `nodes` and `edges`.
|
|
1629
|
+
# @return [Hash]
|
|
1630
|
+
def graph_copy(graph, to, database: @database)
|
|
1631
|
+
body = { "graph" => graph, "to" => to }
|
|
1632
|
+
json(graph_op("CopyGraph", body, database), "copy")
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
# A bounded depth-first walk from a node: the nodes within `max_depth` hops (default
|
|
1636
|
+
# 3, at most 10), in DFS preorder, each with the shortest `depth` found.
|
|
1637
|
+
# @return [Hash]
|
|
1638
|
+
def graph_dfs(graph, start, direction: "outgoing", label: nil, max_depth: nil, limit: nil, database: @database)
|
|
1639
|
+
body = {
|
|
1640
|
+
"graph" => graph,
|
|
1641
|
+
"start" => start,
|
|
1642
|
+
"direction" => direction.to_s,
|
|
1643
|
+
"label" => label,
|
|
1644
|
+
"max_depth" => max_depth,
|
|
1645
|
+
"limit" => limit
|
|
1646
|
+
}
|
|
1647
|
+
json(graph_op("Dfs", body, database), "dfs")
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1650
|
+
# Partition the graph's nodes into connected components, largest first. `weak` ignores
|
|
1651
|
+
# edge direction; `strong` groups nodes that can each reach the other.
|
|
1652
|
+
# @return [Hash]
|
|
1653
|
+
def graph_connected_components(graph, kind: "weak", label: nil, limit: nil, database: @database)
|
|
1654
|
+
body = { "graph" => graph, "kind" => kind.to_s, "label" => label, "limit" => limit }
|
|
1655
|
+
json(graph_op("ConnectedComponents", body, database), "connectedComponents")
|
|
1656
|
+
end
|
|
1657
|
+
|
|
1658
|
+
# Whether the graph contains a cycle (`hasCycle`), and one `cycle` as evidence.
|
|
1659
|
+
# @return [Hash]
|
|
1660
|
+
def graph_detect_cycle(graph, directed: true, label: nil, database: @database)
|
|
1661
|
+
body = { "graph" => graph, "directed" => directed ? true : false, "label" => label }
|
|
1662
|
+
json(graph_op("DetectCycle", body, database), "detectCycle")
|
|
1663
|
+
end
|
|
1664
|
+
|
|
1665
|
+
# Counts describing a graph: nodes and edges, per node label and per edge type,
|
|
1666
|
+
# self-loops, isolated nodes, degree extremes, and edges whose endpoint no longer
|
|
1667
|
+
# exists.
|
|
1668
|
+
# @return [Hash]
|
|
1669
|
+
def graph_stats(graph, database: @database)
|
|
1670
|
+
body = { "graph" => graph }
|
|
1671
|
+
json(graph_op("GraphStats", body, database), "stats")
|
|
1672
|
+
end
|
|
1673
|
+
|
|
1674
|
+
# The part of the graph within `max_depth` hops of the seeds: the nodes reached and
|
|
1675
|
+
# every edge joining two of them.
|
|
1676
|
+
# @return [Hash]
|
|
1677
|
+
def graph_subgraph(graph, seeds, direction: "both", label: nil, max_depth: nil, limit: nil, database: @database)
|
|
1678
|
+
body = {
|
|
1679
|
+
"graph" => graph,
|
|
1680
|
+
"seeds" => Array(seeds).map(&:to_s),
|
|
1681
|
+
"direction" => direction.to_s,
|
|
1682
|
+
"label" => label,
|
|
1683
|
+
"max_depth" => max_depth,
|
|
1684
|
+
"limit" => limit
|
|
1685
|
+
}
|
|
1686
|
+
json(graph_op("Subgraph", body, database), "subgraph")
|
|
1687
|
+
end
|
|
1688
|
+
|
|
1689
|
+
# What the graph's data looks like: its node labels and edge types, each with a count
|
|
1690
|
+
# and the property keys seen on it.
|
|
1691
|
+
# @return [Hash]
|
|
1692
|
+
def graph_schema(graph, database: @database)
|
|
1693
|
+
body = { "graph" => graph }
|
|
1694
|
+
json(graph_op("GraphSchema", body, database), "schema")
|
|
1695
|
+
end
|
|
1696
|
+
|
|
1697
|
+
# The `k` cheapest loopless paths between two nodes, cheapest first (default 3, at
|
|
1698
|
+
# most 20). Without `weight_property` every edge costs 1.
|
|
1699
|
+
# @return [Hash]
|
|
1700
|
+
def graph_k_shortest_paths(graph, from, to,
|
|
1701
|
+
k: nil, direction: "outgoing", label: nil, weight_property: nil, database: @database)
|
|
1702
|
+
body = {
|
|
1703
|
+
"graph" => graph,
|
|
1704
|
+
"from" => from,
|
|
1705
|
+
"to" => to,
|
|
1706
|
+
"k" => k,
|
|
1707
|
+
"direction" => direction.to_s,
|
|
1708
|
+
"label" => label,
|
|
1709
|
+
"weight_property" => weight_property
|
|
1710
|
+
}
|
|
1711
|
+
json(graph_op("KShortestPaths", body, database), "kShortestPaths")
|
|
1712
|
+
end
|
|
1713
|
+
|
|
1714
|
+
# Every simple path between two nodes of at most `max_depth` edges (default 5, at most
|
|
1715
|
+
# 10). `complete` says whether the enumeration finished.
|
|
1716
|
+
# @return [Hash]
|
|
1717
|
+
def graph_all_paths(graph, from, to,
|
|
1718
|
+
direction: "outgoing", label: nil, max_depth: nil, limit: nil, database: @database)
|
|
1719
|
+
body = {
|
|
1720
|
+
"graph" => graph,
|
|
1721
|
+
"from" => from,
|
|
1722
|
+
"to" => to,
|
|
1723
|
+
"direction" => direction.to_s,
|
|
1724
|
+
"label" => label,
|
|
1725
|
+
"max_depth" => max_depth,
|
|
1726
|
+
"limit" => limit
|
|
1727
|
+
}
|
|
1728
|
+
json(graph_op("AllPaths", body, database), "allPaths")
|
|
1729
|
+
end
|
|
1730
|
+
|
|
1731
|
+
# Betweenness, closeness or eigenvector centrality: the top `limit` nodes (default 20)
|
|
1732
|
+
# under `results`. `iterations` and `tolerance` tune eigenvector only.
|
|
1733
|
+
# @return [Hash]
|
|
1734
|
+
def graph_centrality(graph, measure,
|
|
1735
|
+
directed: true, label: nil, limit: nil, iterations: nil, tolerance: nil, database: @database)
|
|
1736
|
+
body = {
|
|
1737
|
+
"graph" => graph,
|
|
1738
|
+
"measure" => measure.to_s,
|
|
1739
|
+
"directed" => directed ? true : false,
|
|
1740
|
+
"label" => label,
|
|
1741
|
+
"limit" => limit,
|
|
1742
|
+
"iterations" => iterations,
|
|
1743
|
+
"tolerance" => tolerance
|
|
1744
|
+
}
|
|
1745
|
+
json(graph_op("Centrality", body, database), "centrality")
|
|
1746
|
+
end
|
|
1747
|
+
|
|
1748
|
+
# Communities by Louvain modularity optimisation, direction ignored, largest first,
|
|
1749
|
+
# with the partition's `modularity`.
|
|
1750
|
+
# @return [Hash]
|
|
1751
|
+
def graph_communities(graph, label: nil, iterations: nil, limit: nil, database: @database)
|
|
1752
|
+
body = { "graph" => graph, "label" => label, "iterations" => iterations, "limit" => limit }
|
|
1753
|
+
json(graph_op("Communities", body, database), "communities")
|
|
1754
|
+
end
|
|
1755
|
+
|
|
1756
|
+
# Triangles and clustering coefficients, direction ignored.
|
|
1757
|
+
# @return [Hash]
|
|
1758
|
+
def graph_triangle_count(graph, label: nil, limit: nil, database: @database)
|
|
1759
|
+
body = { "graph" => graph, "label" => label, "limit" => limit }
|
|
1760
|
+
json(graph_op("TriangleCount", body, database), "triangleCount")
|
|
1761
|
+
end
|
|
1762
|
+
|
|
1763
|
+
# The nodes whose neighbour sets most resemble a node's, under `results`.
|
|
1764
|
+
# @return [Hash]
|
|
1765
|
+
def graph_node_similarity(graph, node,
|
|
1766
|
+
direction: "both", label: nil, metric: "jaccard", limit: nil, database: @database)
|
|
1767
|
+
body = {
|
|
1768
|
+
"graph" => graph,
|
|
1769
|
+
"node" => node,
|
|
1770
|
+
"direction" => direction.to_s,
|
|
1771
|
+
"label" => label,
|
|
1772
|
+
"metric" => metric.to_s,
|
|
1773
|
+
"limit" => limit
|
|
1774
|
+
}
|
|
1775
|
+
json(graph_op("NodeSimilarity", body, database), "nodeSimilarity")
|
|
1776
|
+
end
|
|
1777
|
+
|
|
1778
|
+
# Check a graph's stored records against each other and against the resident mirror:
|
|
1779
|
+
# dangling edges, undecodable records, misfiled records and any disagreement between
|
|
1780
|
+
# mirror and storage.
|
|
1781
|
+
# @return [Hash]
|
|
1782
|
+
def graph_check_consistency(graph, database: @database)
|
|
1783
|
+
body = { "graph" => graph }
|
|
1784
|
+
json(graph_op("CheckConsistency", body, database), "checkConsistency")
|
|
1785
|
+
end
|
|
1786
|
+
|
|
1787
|
+
# Index node properties for finding nodes. With one property it is a property index,
|
|
1788
|
+
# with several a composite one; `label` limits it to nodes carrying that label.
|
|
1789
|
+
def graph_create_node_index(graph, name, properties, label: nil, database: @database)
|
|
1790
|
+
body = { "graph" => graph, "name" => name, "properties" => Array(properties).map(&:to_s), "label" => label }
|
|
1791
|
+
graph_op("CreateNodeIndex", body, database)
|
|
1792
|
+
nil
|
|
1793
|
+
end
|
|
1794
|
+
|
|
1795
|
+
# Enforce a rule on a property of every node (or, with target `edge`, every
|
|
1796
|
+
# relationship) carrying a label. Refused when a stored record already breaks it;
|
|
1797
|
+
# afterwards every write that would break it is refused.
|
|
1798
|
+
def graph_create_constraint(graph, name, label, property, kind, target: "node", database: @database)
|
|
1799
|
+
body = {
|
|
1800
|
+
"graph" => graph,
|
|
1801
|
+
"name" => name,
|
|
1802
|
+
"label" => label,
|
|
1803
|
+
"property" => property,
|
|
1804
|
+
"kind" => kind.to_s,
|
|
1805
|
+
"target" => target.to_s
|
|
1806
|
+
}
|
|
1807
|
+
graph_op("CreateConstraint", body, database)
|
|
1808
|
+
nil
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
# Drop an index or a constraint by name.
|
|
1812
|
+
def graph_drop_schema_object(graph, name, database: @database)
|
|
1813
|
+
body = { "graph" => graph, "name" => name }
|
|
1814
|
+
graph_op("DropSchemaObject", body, database)
|
|
1815
|
+
nil
|
|
1816
|
+
end
|
|
1817
|
+
|
|
1818
|
+
# A graph's `indexes` and `constraints`.
|
|
1819
|
+
# @return [Hash]
|
|
1820
|
+
def graph_list_schema_objects(graph, database: @database)
|
|
1821
|
+
body = { "graph" => graph }
|
|
1822
|
+
json(graph_op("ListSchemaObjects", body, database), "listSchemaObjects")
|
|
1823
|
+
end
|
|
1824
|
+
|
|
1825
|
+
# Nodes with a label (when given) whose properties equal the given values, in id
|
|
1826
|
+
# order. `plan` says whether an index, the label index or a scan answered.
|
|
1827
|
+
# @return [Hash]
|
|
1828
|
+
def graph_find_nodes(graph, label: nil, properties: {}, limit: nil, database: @database)
|
|
1829
|
+
body = { "graph" => graph, "label" => label, "properties" => properties || {}, "limit" => limit }
|
|
1830
|
+
json(graph_op("FindNodes", body, database), "findNodes")
|
|
1831
|
+
end
|
|
1832
|
+
|
|
1833
|
+
# Import nodes and edges from CSV text as one atomic batch, so constraints apply.
|
|
1834
|
+
# Answers `nodes` and `edges`.
|
|
1835
|
+
# @return [Hash]
|
|
1836
|
+
def graph_import_csv(graph, nodes: nil, edges: nil, database: @database)
|
|
1837
|
+
body = { "graph" => graph, "nodes" => nodes, "edges" => edges }
|
|
1838
|
+
json(graph_op("ImportCsv", body, database), "importCsv")
|
|
1839
|
+
end
|
|
1840
|
+
|
|
1841
|
+
# PageRank over edge direction: the top `limit` nodes (default 20) under `ranks`.
|
|
1842
|
+
# `damping` defaults to 0.85.
|
|
1843
|
+
# @return [Hash]
|
|
1844
|
+
def graph_page_rank(graph,
|
|
1845
|
+
damping: nil, iterations: nil, tolerance: nil, label: nil, limit: nil, database: @database)
|
|
1846
|
+
body = {
|
|
1847
|
+
"graph" => graph,
|
|
1848
|
+
"damping" => damping,
|
|
1849
|
+
"iterations" => iterations,
|
|
1850
|
+
"tolerance" => tolerance,
|
|
1851
|
+
"label" => label,
|
|
1852
|
+
"limit" => limit
|
|
1853
|
+
}
|
|
1854
|
+
json(graph_op("PageRank", body, database), "pageRank")
|
|
1855
|
+
end
|
|
1856
|
+
|
|
1857
|
+
# Run a Cypher write: `CREATE`, `MERGE`, `SET`, `REMOVE` or `[DETACH] DELETE`,
|
|
1858
|
+
# optionally after `MATCH ... [WHERE ...]`. Given a transaction id as `txn`, the
|
|
1859
|
+
# statement is queued rather than applied.
|
|
1860
|
+
# @return [Hash]
|
|
1861
|
+
def graph_execute(graph, cypher, txn: nil, database: @database)
|
|
1862
|
+
body = { "graph" => graph, "cypher" => cypher }
|
|
1863
|
+
json(graph_op("Execute", body, database, txn), "execute")
|
|
1864
|
+
end
|
|
1865
|
+
|
|
1866
|
+
# Open a transaction on a graph, returning its id. An execute given that id as `txn`
|
|
1867
|
+
# is queued until the transaction commits.
|
|
1868
|
+
# @return [String]
|
|
1869
|
+
def graph_begin_transaction(graph, database: @database)
|
|
1870
|
+
body = { "graph" => graph }
|
|
1871
|
+
json(graph_op("BeginTransaction", body, database), "beginTransaction")["transaction"]
|
|
1872
|
+
end
|
|
1873
|
+
|
|
1874
|
+
# Apply every statement queued in a graph transaction as one: all of them or none.
|
|
1875
|
+
# @return [Hash]
|
|
1876
|
+
def graph_commit_transaction(graph, txn, database: @database)
|
|
1877
|
+
body = { "graph" => graph }
|
|
1878
|
+
json(graph_op("CommitTransaction", body, database, txn), "commitTransaction")
|
|
1879
|
+
end
|
|
1880
|
+
|
|
1881
|
+
# Discard a graph transaction and its queued statements.
|
|
1882
|
+
def graph_rollback_transaction(graph, txn, database: @database)
|
|
1883
|
+
body = { "graph" => graph }
|
|
1884
|
+
graph_op("RollbackTransaction", body, database, txn)
|
|
1885
|
+
nil
|
|
1886
|
+
end
|
|
1887
|
+
|
|
746
1888
|
# ---- llm ---------------------------------------------------------------------
|
|
747
1889
|
|
|
748
1890
|
# Export the schema catalogue.
|
|
@@ -809,6 +1951,72 @@ module TriCoreDB
|
|
|
809
1951
|
|
|
810
1952
|
private
|
|
811
1953
|
|
|
1954
|
+
def remember_connect_options(**options)
|
|
1955
|
+
@connect_options = options
|
|
1956
|
+
@address = join_address(options[:host], options[:port])
|
|
1957
|
+
end
|
|
1958
|
+
|
|
1959
|
+
def send_request(payload)
|
|
1960
|
+
tag, body = exchange(Frame::REQUEST, payload)
|
|
1961
|
+
raise ServerError.new(error_text(body), code: error_code(body), frame: true) if tag == Frame::ERROR
|
|
1962
|
+
fail_stream(ProtocolError.new("expected RESPONSE, got #{Frame.name(tag)}")) unless tag == Frame::RESPONSE
|
|
1963
|
+
|
|
1964
|
+
Response.new(body)
|
|
1965
|
+
end
|
|
1966
|
+
|
|
1967
|
+
# Replace this connection with one to `target`, opened the way this one was.
|
|
1968
|
+
# @return [Boolean] false, leaving this connection untouched, when that fails
|
|
1969
|
+
def move_to(target)
|
|
1970
|
+
return false if @connect_options.nil?
|
|
1971
|
+
|
|
1972
|
+
host, port = split_address(target)
|
|
1973
|
+
return false if host.nil?
|
|
1974
|
+
|
|
1975
|
+
options = @connect_options.merge(
|
|
1976
|
+
host: host, port: port,
|
|
1977
|
+
connect_timeout: [@connect_options[:connect_timeout] || FOLLOW_CONNECT_TIMEOUT, FOLLOW_CONNECT_TIMEOUT].min,
|
|
1978
|
+
tls: leader_tls(@connect_options[:tls], host),
|
|
1979
|
+
follow_leader: false
|
|
1980
|
+
)
|
|
1981
|
+
fresh = begin
|
|
1982
|
+
Client.connect(**options)
|
|
1983
|
+
rescue Error, SystemCallError, IOError
|
|
1984
|
+
return false
|
|
1985
|
+
end
|
|
1986
|
+
old = @transport
|
|
1987
|
+
@transport = fresh.send(:detach_transport)
|
|
1988
|
+
@session_id = fresh.session_id
|
|
1989
|
+
@granted_features = fresh.granted_features
|
|
1990
|
+
@address = target
|
|
1991
|
+
old.close
|
|
1992
|
+
true
|
|
1993
|
+
end
|
|
1994
|
+
|
|
1995
|
+
# The leader's certificate is checked against the name the hint gives.
|
|
1996
|
+
def leader_tls(tls, host)
|
|
1997
|
+
return tls if tls.nil? || tls == false
|
|
1998
|
+
|
|
1999
|
+
(tls == true ? {} : tls.transform_keys(&:to_sym)).merge(server_name: host)
|
|
2000
|
+
end
|
|
2001
|
+
|
|
2002
|
+
def detach_transport
|
|
2003
|
+
transport = @transport
|
|
2004
|
+
@transport = Transport.new(nil)
|
|
2005
|
+
transport
|
|
2006
|
+
end
|
|
2007
|
+
|
|
2008
|
+
def split_address(address)
|
|
2009
|
+
match = /\A\[(?<host>[^\]]+)\]:(?<port>\d+)\z/.match(address) || /\A(?<host>[^:]+):(?<port>\d+)\z/.match(address)
|
|
2010
|
+
return nil if match.nil?
|
|
2011
|
+
|
|
2012
|
+
port = Integer(match[:port], 10)
|
|
2013
|
+
port.between?(1, 65_535) ? [match[:host], port] : nil
|
|
2014
|
+
end
|
|
2015
|
+
|
|
2016
|
+
def join_address(host, port)
|
|
2017
|
+
host.to_s.include?(":") ? "[#{host}]:#{port}" : "#{host}:#{port}"
|
|
2018
|
+
end
|
|
2019
|
+
|
|
812
2020
|
def fail_stream(error)
|
|
813
2021
|
@transport.close
|
|
814
2022
|
@txn_open = false
|
|
@@ -853,24 +2061,27 @@ module TriCoreDB
|
|
|
853
2061
|
@txn_open = false unless keyword == "BEGIN"
|
|
854
2062
|
raise
|
|
855
2063
|
end
|
|
856
|
-
|
|
857
|
-
|
|
2064
|
+
outcome = json(resp, keyword)
|
|
2065
|
+
# A BEGIN inside an open transaction opens a nested block, and the COMMIT or
|
|
2066
|
+
# ROLLBACK that ends it answers with `nested_depth`: the outer one is still open.
|
|
2067
|
+
@txn_open = keyword == "BEGIN" || (outcome.is_a?(Hash) && outcome.key?("nested_depth"))
|
|
2068
|
+
outcome
|
|
858
2069
|
end
|
|
859
2070
|
|
|
860
2071
|
def cache(variant, body, database)
|
|
861
2072
|
request({ "Cache" => { variant => body } }, database: database)
|
|
862
2073
|
end
|
|
863
2074
|
|
|
864
|
-
def document(variant, body, database)
|
|
865
|
-
request({ "Document" => { variant => body } }, database: database)
|
|
2075
|
+
def document(variant, body, database, txn = nil)
|
|
2076
|
+
request({ "Document" => { variant => body } }, database: database, txn: txn)
|
|
866
2077
|
end
|
|
867
2078
|
|
|
868
2079
|
def vector(variant, body, database)
|
|
869
2080
|
request({ "Vector" => { variant => body } }, database: database)
|
|
870
2081
|
end
|
|
871
2082
|
|
|
872
|
-
def graph_op(variant, body, database)
|
|
873
|
-
request({ "Graph" => { variant => body } }, database: database)
|
|
2083
|
+
def graph_op(variant, body, database, txn = nil)
|
|
2084
|
+
request({ "Graph" => { variant => body } }, database: database, txn: txn)
|
|
874
2085
|
end
|
|
875
2086
|
|
|
876
2087
|
def nk(namespace, key)
|
|
@@ -966,6 +2177,72 @@ module TriCoreDB
|
|
|
966
2177
|
"include_schema" => include_schema ? true : false }
|
|
967
2178
|
end
|
|
968
2179
|
|
|
2180
|
+
def cache_values(resp, what)
|
|
2181
|
+
raise ProtocolError, "expected CacheValues for #{what}, got #{resp.kind}" unless resp.arm?("CacheValues")
|
|
2182
|
+
|
|
2183
|
+
(resp.arm("CacheValues") || []).map { |v| v.nil? ? nil : v.pack("C*") }
|
|
2184
|
+
end
|
|
2185
|
+
|
|
2186
|
+
# A blocking pop answers `[key, value]`, or `[nil, nil]` when the wait ran out.
|
|
2187
|
+
def popped(resp, what)
|
|
2188
|
+
key, value = cache_values(resp, what)
|
|
2189
|
+
key.nil? || value.nil? ? nil : [key.force_encoding(Encoding::UTF_8), value]
|
|
2190
|
+
end
|
|
2191
|
+
|
|
2192
|
+
# A value that is valid UTF-8 arrives as text under `value`; any other arrives
|
|
2193
|
+
# as hex under `valueHex`. Either way the caller gets the bytes under `value`.
|
|
2194
|
+
def fresh_read(json)
|
|
2195
|
+
out = json.dup
|
|
2196
|
+
hex = out.delete("valueHex")
|
|
2197
|
+
out["value"] = out["value"].b if out["value"].is_a?(String)
|
|
2198
|
+
out["value"] = [hex].pack("H*") if hex.is_a?(String)
|
|
2199
|
+
out
|
|
2200
|
+
end
|
|
2201
|
+
|
|
2202
|
+
# A range read with scores answers `{member, score}` objects; without, the members alone.
|
|
2203
|
+
def sorted_members(json)
|
|
2204
|
+
(json["members"] || []).map do |m|
|
|
2205
|
+
m.is_a?(Hash) ? [m["member"].pack("C*"), m["score"]] : m.pack("C*")
|
|
2206
|
+
end
|
|
2207
|
+
end
|
|
2208
|
+
|
|
2209
|
+
def cache_entries(entries)
|
|
2210
|
+
list = entries.is_a?(Hash) ? entries.to_a : entries
|
|
2211
|
+
raise ArgumentError, "entries must be a non-empty Hash or Array of pairs" unless list.is_a?(Array) && !list.empty?
|
|
2212
|
+
|
|
2213
|
+
list.map { |key, value| { "key" => key.to_s, "value" => bytes(value, "entries value") } }
|
|
2214
|
+
end
|
|
2215
|
+
|
|
2216
|
+
def scored_members(members)
|
|
2217
|
+
list = members.is_a?(Hash) ? members.to_a : members
|
|
2218
|
+
raise ArgumentError, "members must be a non-empty Hash or Array of pairs" unless list.is_a?(Array) && !list.empty?
|
|
2219
|
+
|
|
2220
|
+
list.map do |member, score|
|
|
2221
|
+
raise ArgumentError, "a score must be a finite number" unless score.is_a?(Numeric) && score.to_f.finite?
|
|
2222
|
+
|
|
2223
|
+
{ "score" => score.to_f, "member" => bytes(member.is_a?(Symbol) ? member.to_s : member, "member") }
|
|
2224
|
+
end
|
|
2225
|
+
end
|
|
2226
|
+
|
|
2227
|
+
def score_bound(bound)
|
|
2228
|
+
return ScoreBound.inclusive(bound) if bound.is_a?(Numeric)
|
|
2229
|
+
return bound if bound.is_a?(Hash)
|
|
2230
|
+
|
|
2231
|
+
raise ArgumentError, "a score bound must be a number or come from TriCoreDB::ScoreBound, got #{bound.class}"
|
|
2232
|
+
end
|
|
2233
|
+
|
|
2234
|
+
def int_pair(pair)
|
|
2235
|
+
return nil if pair.nil?
|
|
2236
|
+
raise ArgumentError, "expected a pair of Integers" unless pair.is_a?(Array) && pair.size == 2
|
|
2237
|
+
|
|
2238
|
+
[Integer(pair[0]), Integer(pair[1])]
|
|
2239
|
+
end
|
|
2240
|
+
|
|
2241
|
+
# Only the options given are sent; the server's defaults apply to the rest.
|
|
2242
|
+
def search_options(options)
|
|
2243
|
+
(options || {}).to_h { |k, v| [k.to_s, v] }
|
|
2244
|
+
end
|
|
2245
|
+
|
|
969
2246
|
def error_text(body)
|
|
970
2247
|
return body["message"] || body["error"] || JSON.generate(body) if body.is_a?(Hash)
|
|
971
2248
|
|