torque-http 0.5.0__py3-none-any.whl
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.
- torque_http/__init__.py +121 -0
- torque_http/client.py +1471 -0
- torque_http/exceptions.py +96 -0
- torque_http/models.py +302 -0
- torque_http/py.typed +0 -0
- torque_http/tcp.py +575 -0
- torque_http-0.5.0.dist-info/METADATA +132 -0
- torque_http-0.5.0.dist-info/RECORD +10 -0
- torque_http-0.5.0.dist-info/WHEEL +4 -0
- torque_http-0.5.0.dist-info/licenses/LICENSE +21 -0
torque_http/tcp.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""Torque TCP binary ingest client and TQBF writer.
|
|
2
|
+
|
|
3
|
+
Uses only Python stdlib: socket, struct, zlib, io. Zero external dependencies.
|
|
4
|
+
|
|
5
|
+
Wire format matches the .NET SDK's TorqueIngestClient and the Rust server's
|
|
6
|
+
ingest protocol (src/ingest/protocol.rs). All multi-byte integers are big-endian.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import socket
|
|
12
|
+
import struct
|
|
13
|
+
import zlib
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Protocol Constants ──────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
# Client -> Server message types
|
|
22
|
+
MSG_START_INGEST = 0x01
|
|
23
|
+
MSG_DOC_BATCH = 0x02
|
|
24
|
+
MSG_COMMIT = 0x03
|
|
25
|
+
MSG_ABORT = 0x04
|
|
26
|
+
|
|
27
|
+
# Server -> Client message types
|
|
28
|
+
RESP_ACK = 0x10
|
|
29
|
+
RESP_COMMIT_RESULT = 0x11
|
|
30
|
+
RESP_ERROR = 0x12
|
|
31
|
+
RESP_BACKPRESSURE = 0x13
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Response Models ─────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BatchAck:
|
|
38
|
+
batch_seq: int
|
|
39
|
+
docs_received: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CommitResult:
|
|
44
|
+
success: bool
|
|
45
|
+
docs_indexed: int
|
|
46
|
+
build_ms: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Exceptions ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
class TorqueProtocolError(Exception):
|
|
52
|
+
def __init__(self, code: int, message: str) -> None:
|
|
53
|
+
super().__init__(f"[code={code}] {message}")
|
|
54
|
+
self.code = code
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TorqueConnectionError(Exception):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Frame Codec ─────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _write_frame(sock: socket.socket, payload: bytes) -> None:
|
|
64
|
+
header = struct.pack("!I", len(payload))
|
|
65
|
+
sock.sendall(header + payload)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_exact(sock: socket.socket, n: int) -> bytes:
|
|
69
|
+
buf = bytearray()
|
|
70
|
+
while len(buf) < n:
|
|
71
|
+
chunk = sock.recv(n - len(buf))
|
|
72
|
+
if not chunk:
|
|
73
|
+
raise TorqueConnectionError("Connection closed while reading frame")
|
|
74
|
+
buf.extend(chunk)
|
|
75
|
+
return bytes(buf)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _read_frame(sock: socket.socket) -> bytes:
|
|
79
|
+
header = _read_exact(sock, 4)
|
|
80
|
+
length = struct.unpack("!I", header)[0]
|
|
81
|
+
if length == 0:
|
|
82
|
+
return b""
|
|
83
|
+
return _read_exact(sock, length)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Response Decoder ────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def _decode_response(data: bytes) -> BatchAck | CommitResult:
|
|
89
|
+
if not data:
|
|
90
|
+
raise TorqueProtocolError(0, "Empty server message")
|
|
91
|
+
|
|
92
|
+
msg_type = data[0]
|
|
93
|
+
payload = data[1:]
|
|
94
|
+
|
|
95
|
+
if msg_type == RESP_ACK:
|
|
96
|
+
if len(payload) < 16:
|
|
97
|
+
raise TorqueProtocolError(0, "Ack message too short")
|
|
98
|
+
batch_seq, docs_received = struct.unpack_from("!QQ", payload)
|
|
99
|
+
return BatchAck(batch_seq=batch_seq, docs_received=docs_received)
|
|
100
|
+
|
|
101
|
+
elif msg_type == RESP_COMMIT_RESULT:
|
|
102
|
+
if len(payload) < 17:
|
|
103
|
+
raise TorqueProtocolError(0, "CommitResult message too short")
|
|
104
|
+
success = payload[0] != 0
|
|
105
|
+
docs_indexed, build_ms = struct.unpack_from("!QQ", payload, 1)
|
|
106
|
+
return CommitResult(success=success, docs_indexed=docs_indexed, build_ms=build_ms)
|
|
107
|
+
|
|
108
|
+
elif msg_type == RESP_ERROR:
|
|
109
|
+
if len(payload) < 6:
|
|
110
|
+
raise TorqueProtocolError(0, "Error message too short")
|
|
111
|
+
code = struct.unpack_from("!H", payload, 0)[0]
|
|
112
|
+
msg_len = struct.unpack_from("!I", payload, 2)[0]
|
|
113
|
+
message = "Unknown error"
|
|
114
|
+
if msg_len > 0 and msg_len <= len(payload) - 6:
|
|
115
|
+
message = payload[6:6 + msg_len].decode("utf-8", errors="replace")
|
|
116
|
+
raise TorqueProtocolError(code, message)
|
|
117
|
+
|
|
118
|
+
elif msg_type == RESP_BACKPRESSURE:
|
|
119
|
+
if len(payload) < 4:
|
|
120
|
+
raise TorqueProtocolError(0, "Backpressure message too short")
|
|
121
|
+
retry_after_ms = struct.unpack_from("!I", payload, 0)[0]
|
|
122
|
+
raise TorqueProtocolError(
|
|
123
|
+
8, f"Server backpressure, retry after {retry_after_ms}ms"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
else:
|
|
127
|
+
raise TorqueProtocolError(0, f"Unknown server message type: 0x{msg_type:02X}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── Document Flattener ─────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def flatten_document(doc: dict[str, Any]) -> dict[str, Any]:
|
|
133
|
+
"""Flatten nested objects/arrays in a document to dotted keys.
|
|
134
|
+
|
|
135
|
+
Matches the Rust server's ``flatten_json_object`` logic:
|
|
136
|
+
- Nested objects become dotted keys: ``{"a": {"b": 1}}`` -> ``{"a.b": 1}``
|
|
137
|
+
- Arrays of objects are pivoted: ``{"t": [{"n": "x"}, {"n": "y"}]}``
|
|
138
|
+
-> ``{"t.n": ["x", "y"]}`` with null-padding for missing fields.
|
|
139
|
+
- Scalars and flat arrays pass through unchanged.
|
|
140
|
+
"""
|
|
141
|
+
return _flatten_inner(doc, 0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _flatten_inner(obj: dict[str, Any], depth: int) -> dict[str, Any]:
|
|
145
|
+
|
|
146
|
+
result: dict[str, Any] = {}
|
|
147
|
+
for key, value in obj.items():
|
|
148
|
+
if isinstance(value, dict):
|
|
149
|
+
flat_inner = _flatten_inner(value, depth + 1)
|
|
150
|
+
for inner_key, inner_val in flat_inner.items():
|
|
151
|
+
result[f"{key}.{inner_key}"] = inner_val
|
|
152
|
+
elif (
|
|
153
|
+
isinstance(value, list)
|
|
154
|
+
and len(value) > 0
|
|
155
|
+
and isinstance(value[0], dict)
|
|
156
|
+
):
|
|
157
|
+
# Collect all field names across all elements (preserving order)
|
|
158
|
+
seen: set[str] = set()
|
|
159
|
+
all_field_names: list[str] = []
|
|
160
|
+
for item in value:
|
|
161
|
+
if isinstance(item, dict):
|
|
162
|
+
for field_name in item:
|
|
163
|
+
if field_name not in seen:
|
|
164
|
+
seen.add(field_name)
|
|
165
|
+
all_field_names.append(field_name)
|
|
166
|
+
# Build arrays with null-padding for missing fields
|
|
167
|
+
for field_name in all_field_names:
|
|
168
|
+
arr_key = f"{key}.{field_name}"
|
|
169
|
+
values = []
|
|
170
|
+
for item in value:
|
|
171
|
+
if isinstance(item, dict):
|
|
172
|
+
values.append(item.get(field_name))
|
|
173
|
+
else:
|
|
174
|
+
values.append(None)
|
|
175
|
+
result[arr_key] = values
|
|
176
|
+
else:
|
|
177
|
+
result[key] = value
|
|
178
|
+
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── Document Encoder ────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
# Type name -> encoder key mapping
|
|
185
|
+
_TYPE_MAP = {
|
|
186
|
+
"string": "string",
|
|
187
|
+
"int32": "int32",
|
|
188
|
+
"int64": "int64",
|
|
189
|
+
"float": "float",
|
|
190
|
+
"bool": "bool",
|
|
191
|
+
"geopoint": "geopoint",
|
|
192
|
+
"string[]": "string[]",
|
|
193
|
+
"int32[]": "int32[]",
|
|
194
|
+
"int64[]": "int64[]",
|
|
195
|
+
"float[]": "float[]",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class DocumentEncoder:
|
|
200
|
+
"""Encodes Python dicts into Torque binary wire format.
|
|
201
|
+
|
|
202
|
+
Wire format per document:
|
|
203
|
+
[u16 BE] id_byte_length
|
|
204
|
+
[bytes] id (UTF-8)
|
|
205
|
+
[u8] language (0 = English)
|
|
206
|
+
[u16 BE] field_count
|
|
207
|
+
For each field in schema order:
|
|
208
|
+
[u8] presence (0 = null, 1 = present)
|
|
209
|
+
If present: encoded value (type-specific)
|
|
210
|
+
|
|
211
|
+
Field encoding:
|
|
212
|
+
string: [u32 BE length][UTF-8 bytes]
|
|
213
|
+
int32: [i32 BE]
|
|
214
|
+
int64: [i64 BE]
|
|
215
|
+
float: [f64 BE] (8 bytes double)
|
|
216
|
+
bool: [u8] (0 or 1)
|
|
217
|
+
geopoint: [f64 BE lat][f64 BE lng]
|
|
218
|
+
string[]: [u16 BE count] then per element: [u8 null_flag] if present: [u32 BE len][UTF-8]
|
|
219
|
+
int32[]: [u16 BE count] then per element: [u8 null_flag] if present: [i32 BE]
|
|
220
|
+
int64[]: [u16 BE count] then per element: [u8 null_flag] if present: [i64 BE]
|
|
221
|
+
float[]: [u16 BE count] then per element: [f32 BE] (4 bytes, no null flag)
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
# Language codes matching Rust enum
|
|
225
|
+
LANG_ENGLISH = 0
|
|
226
|
+
LANG_SWEDISH = 1
|
|
227
|
+
LANG_NORWEGIAN = 2
|
|
228
|
+
LANG_DANISH = 3
|
|
229
|
+
LANG_FINNISH = 4
|
|
230
|
+
LANG_GERMAN = 5
|
|
231
|
+
LANG_FRENCH = 6
|
|
232
|
+
LANG_SPANISH = 7
|
|
233
|
+
LANG_PORTUGUESE = 8
|
|
234
|
+
LANG_ITALIAN = 9
|
|
235
|
+
LANG_DUTCH = 10
|
|
236
|
+
LANG_RUSSIAN = 11
|
|
237
|
+
LANG_ARABIC = 12
|
|
238
|
+
LANG_GREEK = 13
|
|
239
|
+
LANG_HUNGARIAN = 14
|
|
240
|
+
LANG_ROMANIAN = 15
|
|
241
|
+
LANG_TURKISH = 16
|
|
242
|
+
|
|
243
|
+
LANG_MAP = {
|
|
244
|
+
"en": LANG_ENGLISH,
|
|
245
|
+
"sv": LANG_SWEDISH,
|
|
246
|
+
"no": LANG_NORWEGIAN,
|
|
247
|
+
"da": LANG_DANISH,
|
|
248
|
+
"fi": LANG_FINNISH,
|
|
249
|
+
"de": LANG_GERMAN,
|
|
250
|
+
"fr": LANG_FRENCH,
|
|
251
|
+
"es": LANG_SPANISH,
|
|
252
|
+
"pt": LANG_PORTUGUESE,
|
|
253
|
+
"it": LANG_ITALIAN,
|
|
254
|
+
"nl": LANG_DUTCH,
|
|
255
|
+
"ru": LANG_RUSSIAN,
|
|
256
|
+
"ar": LANG_ARABIC,
|
|
257
|
+
"el": LANG_GREEK,
|
|
258
|
+
"hu": LANG_HUNGARIAN,
|
|
259
|
+
"ro": LANG_ROMANIAN,
|
|
260
|
+
"tr": LANG_TURKISH,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def __init__(self, schema: dict[str, Any]) -> None:
|
|
264
|
+
self._fields: list[tuple[str, str]] = []
|
|
265
|
+
for f in schema.get("fields", []):
|
|
266
|
+
name = f["name"]
|
|
267
|
+
ftype = f["type"]
|
|
268
|
+
if ftype not in _TYPE_MAP:
|
|
269
|
+
raise ValueError(f"Unsupported field type: {ftype!r} for field {name!r}")
|
|
270
|
+
self._fields.append((name, _TYPE_MAP[ftype]))
|
|
271
|
+
|
|
272
|
+
def encode(self, doc: dict[str, Any], language: str = "en") -> bytes:
|
|
273
|
+
doc = flatten_document(doc)
|
|
274
|
+
buf = BytesIO()
|
|
275
|
+
|
|
276
|
+
# External ID
|
|
277
|
+
doc_id = str(doc.get("id", ""))
|
|
278
|
+
id_bytes = doc_id.encode("utf-8")
|
|
279
|
+
buf.write(struct.pack("!H", len(id_bytes)))
|
|
280
|
+
buf.write(id_bytes)
|
|
281
|
+
|
|
282
|
+
# Language
|
|
283
|
+
lang_code = self.LANG_MAP.get(language, self.LANG_ENGLISH)
|
|
284
|
+
buf.write(struct.pack("B", lang_code))
|
|
285
|
+
|
|
286
|
+
# Field count
|
|
287
|
+
buf.write(struct.pack("!H", len(self._fields)))
|
|
288
|
+
|
|
289
|
+
# Fields in schema order
|
|
290
|
+
for field_name, field_type in self._fields:
|
|
291
|
+
value = doc.get(field_name)
|
|
292
|
+
if value is None:
|
|
293
|
+
buf.write(b"\x00") # null
|
|
294
|
+
else:
|
|
295
|
+
buf.write(b"\x01") # present
|
|
296
|
+
self._encode_value(buf, value, field_type)
|
|
297
|
+
|
|
298
|
+
return buf.getvalue()
|
|
299
|
+
|
|
300
|
+
def encode_batch(self, docs: list[dict[str, Any]], language: str = "en") -> bytes:
|
|
301
|
+
buf = BytesIO()
|
|
302
|
+
for doc in docs:
|
|
303
|
+
buf.write(self.encode(doc, language))
|
|
304
|
+
return buf.getvalue()
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _encode_value(buf: BytesIO, value: Any, field_type: str) -> None:
|
|
308
|
+
if field_type == "string":
|
|
309
|
+
s = str(value).encode("utf-8")
|
|
310
|
+
buf.write(struct.pack("!I", len(s)))
|
|
311
|
+
buf.write(s)
|
|
312
|
+
|
|
313
|
+
elif field_type == "int32":
|
|
314
|
+
buf.write(struct.pack("!i", int(value)))
|
|
315
|
+
|
|
316
|
+
elif field_type == "int64":
|
|
317
|
+
buf.write(struct.pack("!q", int(value)))
|
|
318
|
+
|
|
319
|
+
elif field_type == "float":
|
|
320
|
+
buf.write(struct.pack("!d", float(value)))
|
|
321
|
+
|
|
322
|
+
elif field_type == "bool":
|
|
323
|
+
buf.write(b"\x01" if value else b"\x00")
|
|
324
|
+
|
|
325
|
+
elif field_type == "geopoint":
|
|
326
|
+
# Accepts [lat, lng] or (lat, lng)
|
|
327
|
+
lat, lng = float(value[0]), float(value[1])
|
|
328
|
+
buf.write(struct.pack("!dd", lat, lng))
|
|
329
|
+
|
|
330
|
+
elif field_type == "string[]":
|
|
331
|
+
arr = list(value)
|
|
332
|
+
buf.write(struct.pack("!H", len(arr)))
|
|
333
|
+
for item in arr:
|
|
334
|
+
if item is None:
|
|
335
|
+
buf.write(b"\x00")
|
|
336
|
+
else:
|
|
337
|
+
buf.write(b"\x01")
|
|
338
|
+
s = str(item).encode("utf-8")
|
|
339
|
+
buf.write(struct.pack("!I", len(s)))
|
|
340
|
+
buf.write(s)
|
|
341
|
+
|
|
342
|
+
elif field_type == "int32[]":
|
|
343
|
+
arr = list(value)
|
|
344
|
+
buf.write(struct.pack("!H", len(arr)))
|
|
345
|
+
for item in arr:
|
|
346
|
+
if item is None:
|
|
347
|
+
buf.write(b"\x00")
|
|
348
|
+
else:
|
|
349
|
+
buf.write(b"\x01")
|
|
350
|
+
buf.write(struct.pack("!i", int(item)))
|
|
351
|
+
|
|
352
|
+
elif field_type == "int64[]":
|
|
353
|
+
arr = list(value)
|
|
354
|
+
buf.write(struct.pack("!H", len(arr)))
|
|
355
|
+
for item in arr:
|
|
356
|
+
if item is None:
|
|
357
|
+
buf.write(b"\x00")
|
|
358
|
+
else:
|
|
359
|
+
buf.write(b"\x01")
|
|
360
|
+
buf.write(struct.pack("!q", int(item)))
|
|
361
|
+
|
|
362
|
+
elif field_type == "float[]":
|
|
363
|
+
arr = list(value)
|
|
364
|
+
buf.write(struct.pack("!H", len(arr)))
|
|
365
|
+
for item in arr:
|
|
366
|
+
buf.write(struct.pack("!f", float(item)))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ── TCP Ingest Client ───────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
class TorqueIngestClient:
|
|
372
|
+
"""TCP ingest client for streaming documents to Torque via the binary protocol.
|
|
373
|
+
|
|
374
|
+
Usage:
|
|
375
|
+
encoder = DocumentEncoder(schema)
|
|
376
|
+
with TorqueIngestClient() as client:
|
|
377
|
+
client.connect("localhost", 8119)
|
|
378
|
+
client.start_ingest("my_collection")
|
|
379
|
+
client.send_batch(documents, encoder)
|
|
380
|
+
result = client.commit()
|
|
381
|
+
print(f"Indexed {result.docs_indexed} docs in {result.build_ms}ms")
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
def __init__(self, *, recv_buf_size: int = 4096) -> None:
|
|
385
|
+
self._sock: socket.socket | None = None
|
|
386
|
+
self._recv_buf_size = recv_buf_size
|
|
387
|
+
|
|
388
|
+
def __enter__(self) -> TorqueIngestClient:
|
|
389
|
+
return self
|
|
390
|
+
|
|
391
|
+
def __exit__(self, *args: Any) -> None:
|
|
392
|
+
self.close()
|
|
393
|
+
|
|
394
|
+
def connect(self, host: str, port: int, *, timeout: float = 30.0) -> None:
|
|
395
|
+
if self._sock is not None:
|
|
396
|
+
raise RuntimeError("Already connected")
|
|
397
|
+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
398
|
+
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
399
|
+
self._sock.settimeout(timeout)
|
|
400
|
+
try:
|
|
401
|
+
self._sock.connect((host, port))
|
|
402
|
+
except OSError as e:
|
|
403
|
+
self._sock.close()
|
|
404
|
+
self._sock = None
|
|
405
|
+
raise TorqueConnectionError(
|
|
406
|
+
f"Failed to connect to {host}:{port}: {e}"
|
|
407
|
+
) from e
|
|
408
|
+
|
|
409
|
+
def start_ingest(self, collection: str, *, mode: int = 0) -> BatchAck:
|
|
410
|
+
self._ensure_connected()
|
|
411
|
+
# Encode: [0x01][u32 BE coll_len][coll UTF-8][u8 mode]
|
|
412
|
+
coll_bytes = collection.encode("utf-8")
|
|
413
|
+
payload = (
|
|
414
|
+
struct.pack("B", MSG_START_INGEST)
|
|
415
|
+
+ struct.pack("!I", len(coll_bytes))
|
|
416
|
+
+ coll_bytes
|
|
417
|
+
+ struct.pack("B", mode)
|
|
418
|
+
)
|
|
419
|
+
_write_frame(self._sock, payload)
|
|
420
|
+
data = _read_frame(self._sock)
|
|
421
|
+
result = _decode_response(data)
|
|
422
|
+
if not isinstance(result, BatchAck):
|
|
423
|
+
raise TorqueProtocolError(0, f"Expected Ack, got {type(result).__name__}")
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
def send_batch(
|
|
427
|
+
self,
|
|
428
|
+
documents: list[dict[str, Any]],
|
|
429
|
+
encoder: DocumentEncoder,
|
|
430
|
+
*,
|
|
431
|
+
language: str = "en",
|
|
432
|
+
) -> BatchAck:
|
|
433
|
+
self._ensure_connected()
|
|
434
|
+
# Encode: [0x02][u32 BE doc_count][encoded docs...]
|
|
435
|
+
doc_payload = encoder.encode_batch(documents, language)
|
|
436
|
+
header = (
|
|
437
|
+
struct.pack("B", MSG_DOC_BATCH)
|
|
438
|
+
+ struct.pack("!I", len(documents))
|
|
439
|
+
)
|
|
440
|
+
payload = header + doc_payload
|
|
441
|
+
_write_frame(self._sock, payload)
|
|
442
|
+
data = _read_frame(self._sock)
|
|
443
|
+
result = _decode_response(data)
|
|
444
|
+
if not isinstance(result, BatchAck):
|
|
445
|
+
raise TorqueProtocolError(0, f"Expected Ack, got {type(result).__name__}")
|
|
446
|
+
return result
|
|
447
|
+
|
|
448
|
+
def commit(self) -> BatchAck:
|
|
449
|
+
"""Send Commit. Server responds with Ack (index builds async)."""
|
|
450
|
+
self._ensure_connected()
|
|
451
|
+
payload = struct.pack("B", MSG_COMMIT)
|
|
452
|
+
_write_frame(self._sock, payload)
|
|
453
|
+
data = _read_frame(self._sock)
|
|
454
|
+
result = _decode_response(data)
|
|
455
|
+
if isinstance(result, (BatchAck, CommitResult)):
|
|
456
|
+
return BatchAck(batch_seq=getattr(result, 'batch_seq', 0),
|
|
457
|
+
docs_received=getattr(result, 'docs_received', getattr(result, 'docs_indexed', 0)))
|
|
458
|
+
raise TorqueProtocolError(0, f"Expected Ack, got {type(result).__name__}")
|
|
459
|
+
|
|
460
|
+
def abort(self) -> BatchAck:
|
|
461
|
+
self._ensure_connected()
|
|
462
|
+
payload = struct.pack("B", MSG_ABORT)
|
|
463
|
+
_write_frame(self._sock, payload)
|
|
464
|
+
data = _read_frame(self._sock)
|
|
465
|
+
result = _decode_response(data)
|
|
466
|
+
if not isinstance(result, BatchAck):
|
|
467
|
+
raise TorqueProtocolError(0, f"Expected Ack, got {type(result).__name__}")
|
|
468
|
+
return result
|
|
469
|
+
|
|
470
|
+
def send_documents(
|
|
471
|
+
self,
|
|
472
|
+
documents: list[dict[str, Any]],
|
|
473
|
+
encoder: DocumentEncoder,
|
|
474
|
+
*,
|
|
475
|
+
batch_size: int = 50_000,
|
|
476
|
+
language: str = "en",
|
|
477
|
+
) -> BatchAck:
|
|
478
|
+
last_ack = None
|
|
479
|
+
for offset in range(0, len(documents), batch_size):
|
|
480
|
+
chunk = documents[offset:offset + batch_size]
|
|
481
|
+
last_ack = self.send_batch(chunk, encoder, language=language)
|
|
482
|
+
if last_ack is None:
|
|
483
|
+
raise ValueError("No documents to send")
|
|
484
|
+
return last_ack
|
|
485
|
+
|
|
486
|
+
def close(self) -> None:
|
|
487
|
+
if self._sock is not None:
|
|
488
|
+
try:
|
|
489
|
+
self._sock.close()
|
|
490
|
+
except OSError:
|
|
491
|
+
pass
|
|
492
|
+
self._sock = None
|
|
493
|
+
|
|
494
|
+
def _ensure_connected(self) -> None:
|
|
495
|
+
if self._sock is None:
|
|
496
|
+
raise RuntimeError("Not connected. Call connect() first.")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ── TQBF Binary File Writer ────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
class TorqueBinaryFileWriter:
|
|
502
|
+
"""Streaming binary file writer for TQBF format.
|
|
503
|
+
|
|
504
|
+
Encodes documents using DocumentEncoder for proper binary encoding,
|
|
505
|
+
then ZLib-compresses and writes a TQBF v2 file.
|
|
506
|
+
|
|
507
|
+
File format (TQBF v2):
|
|
508
|
+
[4 bytes] magic: "TQBF"
|
|
509
|
+
[4 bytes] version: 2 (LE)
|
|
510
|
+
[4 bytes] doc_count: u32 (LE)
|
|
511
|
+
[4 bytes] flags: 2 = ZLib compressed binary
|
|
512
|
+
[8 bytes] uncompressed_size: u64 (LE) ← v2 only
|
|
513
|
+
[N bytes] payload: ZLib-compressed concatenated binary documents
|
|
514
|
+
|
|
515
|
+
The uncompressed_size field lets the server pre-size its decompression
|
|
516
|
+
Vec exactly, avoiding doubling-realloc fragmentation that breaks the
|
|
517
|
+
silicon unikernel's contiguous bitmap allocator on large imports.
|
|
518
|
+
|
|
519
|
+
The torque server still accepts v1 files (16-byte header, no size field)
|
|
520
|
+
via the read_to_end fallback, so older clients keep working.
|
|
521
|
+
|
|
522
|
+
Usage:
|
|
523
|
+
encoder = DocumentEncoder(schema)
|
|
524
|
+
with TorqueBinaryFileWriter("data.tqbf", encoder) as writer:
|
|
525
|
+
for doc in data_source:
|
|
526
|
+
writer.write(doc)
|
|
527
|
+
# Upload via client.import_binary("collection", "data.tqbf")
|
|
528
|
+
"""
|
|
529
|
+
|
|
530
|
+
def __init__(self, file_path: str, encoder: DocumentEncoder) -> None:
|
|
531
|
+
self._file_path = file_path
|
|
532
|
+
self._encoder = encoder
|
|
533
|
+
self._buffer = BytesIO()
|
|
534
|
+
self._doc_count = 0
|
|
535
|
+
self._finalized = False
|
|
536
|
+
|
|
537
|
+
def __enter__(self) -> TorqueBinaryFileWriter:
|
|
538
|
+
return self
|
|
539
|
+
|
|
540
|
+
def __exit__(self, *args: Any) -> None:
|
|
541
|
+
self.finalize()
|
|
542
|
+
|
|
543
|
+
@property
|
|
544
|
+
def document_count(self) -> int:
|
|
545
|
+
return self._doc_count
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def raw_size(self) -> int:
|
|
549
|
+
return self._buffer.tell()
|
|
550
|
+
|
|
551
|
+
def write(self, document: dict[str, Any], language: str = "en") -> None:
|
|
552
|
+
if self._finalized:
|
|
553
|
+
raise RuntimeError("Writer has been finalized.")
|
|
554
|
+
encoded = self._encoder.encode(document, language)
|
|
555
|
+
self._buffer.write(encoded)
|
|
556
|
+
self._doc_count += 1
|
|
557
|
+
|
|
558
|
+
def finalize(self) -> str:
|
|
559
|
+
if self._finalized:
|
|
560
|
+
return self._file_path
|
|
561
|
+
self._finalized = True
|
|
562
|
+
|
|
563
|
+
raw = self._buffer.getvalue()
|
|
564
|
+
uncompressed_size = len(raw)
|
|
565
|
+
compressed = zlib.compress(raw, level=6)
|
|
566
|
+
|
|
567
|
+
with open(self._file_path, "wb") as f:
|
|
568
|
+
f.write(b"TQBF")
|
|
569
|
+
f.write(struct.pack("<I", 2)) # version: 2
|
|
570
|
+
f.write(struct.pack("<I", self._doc_count)) # doc_count
|
|
571
|
+
f.write(struct.pack("<I", 2)) # flags: 2 = ZLib binary
|
|
572
|
+
f.write(struct.pack("<Q", uncompressed_size)) # NEW: uncompressed size (u64)
|
|
573
|
+
f.write(compressed)
|
|
574
|
+
|
|
575
|
+
return self._file_path
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: torque-http
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Official Python client for Torque — Truespar's in-memory search engine with a Typesense v30.1 compatible API. Zero external dependencies.
|
|
5
|
+
Project-URL: Homepage, https://truespar.com/torque/docs
|
|
6
|
+
Project-URL: Documentation, https://truespar.com/torque/docs
|
|
7
|
+
Author: Truespar
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: search,torque,truespar,typesense,vector-search
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Database :: Front-Ends
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# torque-http
|
|
24
|
+
|
|
25
|
+
Official Python client for [Torque](https://truespar.com/torque/docs) — Truespar's in-memory search engine with a Typesense v30.1 compatible API.
|
|
26
|
+
|
|
27
|
+
- **Zero external dependencies** — uses only the Python standard library
|
|
28
|
+
- **HTTP API** for search, collection management, and document CRUD
|
|
29
|
+
- **TCP binary ingest** for high-throughput streaming from databases
|
|
30
|
+
- **TQBF file upload** for bulk import
|
|
31
|
+
- Multi-node failover with automatic retry
|
|
32
|
+
- Typed search parameters and response models
|
|
33
|
+
- Python 3.10+, MIT licensed
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install torque-http
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from torque_http import TorqueHttpClient, SearchParameters
|
|
45
|
+
|
|
46
|
+
client = TorqueHttpClient("http://localhost:8108", api_key="your-key")
|
|
47
|
+
|
|
48
|
+
# Search
|
|
49
|
+
results = client.search("products", q="laptop", query_by="title")
|
|
50
|
+
for hit in results.hits:
|
|
51
|
+
print(hit.document["title"])
|
|
52
|
+
|
|
53
|
+
# Typed parameters
|
|
54
|
+
params = SearchParameters(
|
|
55
|
+
q="laptop",
|
|
56
|
+
query_by="title,description",
|
|
57
|
+
filter_by="price:>100",
|
|
58
|
+
per_page=20,
|
|
59
|
+
)
|
|
60
|
+
results = client.search("products", params)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Multi-node with failover
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
client = TorqueHttpClient(
|
|
67
|
+
["http://node1:8108", "http://node2:8108"],
|
|
68
|
+
api_key="your-key",
|
|
69
|
+
num_retries=5,
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Collection management
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
schema = {
|
|
77
|
+
"name": "products",
|
|
78
|
+
"fields": [
|
|
79
|
+
{"name": "title", "type": "string"},
|
|
80
|
+
{"name": "price", "type": "float", "sort": True},
|
|
81
|
+
{"name": "brand", "type": "string", "facet": True},
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
client.create_collection(schema)
|
|
85
|
+
|
|
86
|
+
client.create_document("products", {
|
|
87
|
+
"id": "1", "title": "Laptop Pro", "price": 1299.99, "brand": "Acme",
|
|
88
|
+
})
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## High-throughput TCP ingest
|
|
92
|
+
|
|
93
|
+
For streaming 100K+ documents, use the binary TCP protocol:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from torque_http import TorqueIngestClient, DocumentEncoder
|
|
97
|
+
|
|
98
|
+
schema = {"fields": [
|
|
99
|
+
{"name": "title", "type": "string"},
|
|
100
|
+
{"name": "price", "type": "float"},
|
|
101
|
+
]}
|
|
102
|
+
encoder = DocumentEncoder(schema)
|
|
103
|
+
|
|
104
|
+
with TorqueIngestClient() as tcp:
|
|
105
|
+
tcp.connect("localhost", 8109)
|
|
106
|
+
tcp.start_ingest("products")
|
|
107
|
+
tcp.send_batch(documents, encoder)
|
|
108
|
+
tcp.commit()
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## TQBF bulk file import
|
|
112
|
+
|
|
113
|
+
Pre-encode a compressed binary file for maximum throughput:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from torque_http import TorqueBinaryFileWriter, DocumentEncoder
|
|
117
|
+
|
|
118
|
+
encoder = DocumentEncoder(schema)
|
|
119
|
+
with TorqueBinaryFileWriter("data.tqbf", encoder) as writer:
|
|
120
|
+
for doc in source:
|
|
121
|
+
writer.write(doc)
|
|
122
|
+
|
|
123
|
+
client.import_binary("products", "data.tqbf")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Documentation
|
|
127
|
+
|
|
128
|
+
- [Torque documentation](https://truespar.com/torque/docs)
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT — see [LICENSE](LICENSE).
|