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.
@@ -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
- tag, body = exchange(Frame::REQUEST, payload)
218
- raise ServerError.new(error_text(body), code: error_code(body), frame: true) if tag == Frame::ERROR
219
- fail_stream(ProtocolError.new("expected RESPONSE, got #{Frame.name(tag)}")) unless tag == Frame::RESPONSE
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
- response = Response.new(body)
222
- raise response.to_error(@txn_open) unless response.ok?
264
+ retried += 1
265
+ hint = response.leader_hint
266
+ next if hint && hint != @address && move_to(hint)
223
267
 
224
- response
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
- @txn_open = keyword == "BEGIN"
857
- json(resp, keyword)
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