ignite-v2-client 1.0.1

Apache Ignite v2 Client
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
# ignite-client

An async Rust thin client for **Apache Ignite 2.x**, implementing the
[Ignite Binary Client Protocol](https://ignite.apache.org/docs/latest/thin-clients/getting-started-with-thin-clients)
over TCP.

> **New in 1.0.0 — Binary objects / Rust struct mapping.** Derive
> `#[derive(IgniteBinary)]` on a plain Rust struct (or fieldless enum) to
> read and write it directly as an Ignite binary (complex) object —
> `cache.put_binary(key, &v)` / `cache.get_binary::<T>(key)` — with nested
> structs, enums, and collections supported. See
> [Binary objects / Rust struct mapping]#binary-objects--rust-struct-mapping.
>
> **Breaking change:** `cache_id()` (and therefore every cache-name lookup)
> is now case-**sensitive**, matching Apache Ignite — previously it
> upper-cased the name first. A cache created under a mixed- or lower-case
> name by pre-1.0.0 code will hash to a different id; see
> [Java string hashing]#java-string-hashing.
>
> **New in 0.4.0 — Cache entry TTL / expiry.** Attach a per-entry lifetime to
> any cache handle with `cache.with_expiry_policy(...)`: entries can expire a
> configurable time after creation, update, or access. See [Expiry / TTL]#expiry--ttl.
>
> **New in 0.3.0 — Partition awareness / affinity routing.** The client connects
> to every configured cluster node, computes each key's primary node locally,
> and sends cache operations straight to it — eliminating the server-side proxy
> hop. A pure optimization with a fail-safe fallback to the default channel, so
> results never change. See [Partition awareness]#partition-awareness.

---

## Table of Contents

- [Features]#features
- [Protocol Reference]#protocol-reference
- [Project Structure]#project-structure
- [Quick Start]#quick-start
  - [SELECT query]#select-query
  - [Per-value type inspection]#per-value-type-inspection
  - [DML]#dml
  - [Transaction]#transaction-1
  - [Transaction helper (auto-commit/rollback)]#transaction-helper-auto-commitrollback
  - [KV cache]#kv-cache
  - [Expiry / TTL]#expiry--ttl
  - [Streaming cursor]#streaming-cursor
  - [TLS]#tls
  - [Authentication]#authentication
- [Binary objects / Rust struct mapping]#binary-objects--rust-struct-mapping
  - [The `#[derive(IgniteBinary)]` macro]#the-deriveignitebinary-macro
  - [Newtypes]#newtypes
  - [Type mapping]#type-mapping
  - [put_binary / get_binary]#put_binary--get_binary
  - [Manual builder / reader API]#manual-builder--reader-api
  - [`IgniteValue::Object`]#ignitevalueobject
  - [Limitations]#limitations
- [API Reference]#api-reference
  - [IgniteClientConfig]#igniteclientconfig
  - [IgniteClient]#igniteclient
  - [Transaction]#transaction
  - [IgniteCache]#ignitecache
  - [QueryResult / Row]#queryresult--row
  - [Column and ColumnType]#column-and-columntype
  - [QueryStream]#querystream
  - [IgniteValue type system]#ignitevalue-type-system
- [Architecture]#architecture
  - [Request multiplexing]#request-multiplexing
  - [Connection pool]#connection-pool
  - [Transaction connections]#transaction-connections
  - [Pagination]#pagination
  - [Partition awareness]#partition-awareness
- [Codec Details]#codec-details
- [Comparison with Existing Rust Clients]#comparison-with-existing-rust-clients
- [Running Tests]#running-tests
  - [Local 3-node test cluster]#local-3-node-test-cluster
- [License]#license

---

## Features

| Capability | Status |
|---|---|
| Protocol 1.7.0 handshake ||
| Authentication (username/password) ||
| `OP_QUERY_SQL_FIELDS` (SELECT) ||
| Automatic cursor pagination ||
| DML: INSERT / UPDATE / DELETE ||
| Transactions (`TX_START` / `TX_END`) ||
| Drop-based rollback ||
| Async pipelining (multiple in-flight requests per connection) ||
| deadpool connection pool ||
| Full wire type coverage (Null, Bool, Byte…Long, Float, Double, String, UUID, Date, Time, Timestamp, Decimal, arrays) ||
| KV cache API (get, put, get_all, put_all, contains_key, remove, replace, …) ||
| Cache entry TTL / expiry policies (create / update / access) ||
| TLS (`rustls` + native system CA bundle) ||
| Streaming `QueryStream` cursor ||
| TCP keepalive ||
| Client-side request timeouts ||
| Multi-node connection registry (one pool per node, keyed by node UUID) ||
| Partition awareness / affinity routing (primary-node routing for KV ops) ||
| Server endpoint discovery (auto-learn nodes not in the address list) ||
| Read-from-backup / DC-aware routing for read-only ops (`DC_AWARE`, negotiated) ||
| Binary-object struct mapping (`#[derive(IgniteBinary)]`, `put_binary` / `get_binary`) ||

---

## Protocol Reference

This client implements the **Apache Ignite 2.x Thin Client Binary Protocol**.

| Document | URL |
|---|---|
| Thin Client Overview | https://ignite.apache.org/docs/latest/thin-clients/getting-started-with-thin-clients |
| Binary Client Protocol spec | https://ignite.apache.org/docs/latest/binary-client-protocol/binary-client-protocol |
| Data Format (type codes, wire encoding) | https://ignite.apache.org/docs/latest/binary-client-protocol/data-format |
| SQL and Scan Queries operations | https://ignite.apache.org/docs/latest/binary-client-protocol/sql-and-scan-queries |
| Cache operations | https://ignite.apache.org/docs/latest/binary-client-protocol/key-value-queries |
| Transaction operations | https://ignite.apache.org/docs/latest/binary-client-protocol/transactions |
| Error codes | https://ignite.apache.org/docs/latest/binary-client-protocol/error-codes |

### Protocol version

The handshake negotiates **protocol version 1.7.0** — the highest version
supported by the Apache Ignite 2.x series.  GridGain 8.x (the commercial
fork) uses the same protocol version.

### Port

Ignite thin client port defaults to **10800**.

---

## Project Structure

This is a **single crate** — no workspace members.

```
ignite-client/
├── Cargo.toml              ← package manifest (ignite-v2-client, crate: ignite_client)
├── src/
│   ├── lib.rs              ← public re-exports
│   ├── client.rs           ← IgniteClient: query, execute, begin_transaction, cache, …
│   ├── transaction.rs      ← Transaction: query, execute, commit, rollback, cache, drop
│   ├── cache.rs            ← IgniteCache: get, put, get_all, put_all, remove, … (affinity-routed); with_expiry_policy
│   ├── affinity.rs         ← partition awareness: key hashing, rendezvous partition/mask,
│   │                          CACHE_PARTITIONS codec, AffinityContext (mappings + refresh)
│   ├── channel.rs          ← ChannelRegistry: one pool per node, node-UUID→pool routing,
│   │                          round-robin fallback, lazy mapping refresh, endpoint discovery
│   ├── discovery.rs        ← server endpoint discovery codec (CLUSTER_GROUP_GET_NODE_ENDPOINTS)
│   ├── stream.rs           ← QueryStream: lazily-paged streaming cursor
│   ├── query.rs            ← QueryResult, Row, Column, ColumnType, UpdateResult
│   ├── pool.rs             ← IgniteClientConfig, deadpool manager
│   ├── error.rs            ← IgniteError
│   ├── protocol/           ← pure codec layer: no I/O, no async
│   │   ├── mod.rs
│   │   ├── types.rs        ← IgniteValue enum + column_type(), ColumnType, op/type codes
│   │   ├── codec.rs        ← encode_value / decode_value roundtrip, UUID/byte-array readers
│   │   ├── handshake.rs    ← protocol 1.7.0 handshake encoding + server node-UUID parsing
│   │   ├── error.rs        ← ProtocolError
│   │   └── messages.rs     ← SqlFieldsRequest, TxStart/End, cache ops, response header (topology version)
│   └── transport/          ← async TCP layer
│       ├── mod.rs
│       ├── connection.rs   ← IgniteConnection (pipelined, multiplexed; node UUID, topology version)
│       ├── error.rs        ← TransportError
│       └── tls.rs          ← build_tls_config (rustls + native-certs)
├── tests/
│   ├── smoke.rs            ← 35 broad end-to-end integration tests
│   ├── metadata.rs         ← 10 schema/system-view metadata tests
│   ├── functional_query.rs ←  6 SQL query behaviour tests (pagination, mixed KV+SQL, errors)
│   ├── functional_cache.rs ←  5 KV cache API lifecycle and operation tests
│   ├── transaction.rs      ←  3 concurrent transaction correctness tests
│   ├── partition_awareness.rs ← 4 affinity-routing tests (roundtrip, discovery, DC read path, parity)
│   └── expiry.rs           ←  3 TTL tests (creation / update / access expiry)
├── local-cluster/          ← scripts + config for a local 3-node test cluster
│   ├── ignite-config.xml   ← static-discovery node config (partitioned cache, thin connector)
│   ├── start.sh            ← launch 3 nodes on ports 10800/10801/10802
│   └── stop.sh             ← stop the local nodes
└── docs/
    └── partition-awareness-plan.md  ← design/port plan for the affinity feature
```

The `protocol` and `affinity` modules have no I/O dependency, so their codec and
routing-logic tests run without a live Ignite node.

---

## Quick Start

Add to `Cargo.toml`:

```toml
[dependencies]
ignite-v2-client = { path = "../ignite-client" }
tokio = { version = "1", features = ["full"] }
```

### SELECT query

```rust
use ignite_client::{IgniteClient, IgniteClientConfig, IgniteValue};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = IgniteClientConfig::new("localhost:10800");
    let client = IgniteClient::new(config);

    let result = client
        .query(
            "SELECT id, name, score FROM PUBLIC.players WHERE active = ?",
            vec![IgniteValue::Bool(true)],
        )
        .await?;

    for row in &result.rows {
        let id    = row.get(0);
        let name  = row.get_by_name("NAME");
        let score = row.get(2);
        println!("{:?}  {:?}  {:?}", id, name, score);
    }
    Ok(())
}
```

### Per-value type inspection

`QueryResult::columns` exposes result-set column names.  The Ignite 2.x
`OP_QUERY_SQL_FIELDS` protocol carries only column names in the first-page
metadata — no per-column type codes.  Type information is available per value
via `IgniteValue::column_type()`, which reads the 1-byte wire tag that
accompanies every encoded value.  The only case that returns
`ColumnType::Unknown` is `IgniteValue::Null`.

```rust,ignore
use ignite_client::{ColumnType, IgniteValue};

let result = client
    .query("SELECT id, name, score FROM PUBLIC.players WHERE id = 1", vec![])
    .await?;

if let Some(row) = result.first_row() {
    for (i, col) in result.columns.iter().enumerate() {
        let value = row.get(i).unwrap();
        let value_t = value.column_type();   // always accurate for non-NULL values
        println!("column '{}': type={:?}, value={:?}", col.name, value_t, value);
    }
}

// Dispatch on value type while iterating rows.
for row in &result.rows {
    for value in row.values() {
        match value.column_type() {
            ColumnType::Int     => { /* handle i32 */ }
            ColumnType::String  => { /* handle varchar */ }
            ColumnType::Unknown => { /* value is NULL */ }
            _                   => { /* other types */ }
        }
    }
}
```

Column names are accessible immediately on a `QueryStream` before any rows are
consumed:

```rust,ignore
let stream = client
    .query_stream("SELECT id, name FROM PUBLIC.players", vec![])
    .await?;

for col in &stream.columns {
    println!("{}", col.name);
}
```

### DML

```rust,ignore
let updated = client
    .execute(
        "UPDATE PUBLIC.players SET score = ? WHERE id = ?",
        vec![IgniteValue::Int(42), IgniteValue::Int(7)],
    )
    .await?;

println!("rows affected: {}", updated.rows_affected);
```

### Transaction

```rust,ignore
let mut tx = client.begin_transaction().await?;

tx.execute(
    "INSERT INTO PUBLIC.accounts (id, balance) VALUES (?, ?)",
    vec![IgniteValue::Int(1), IgniteValue::Double(100.0)],
).await?;

tx.execute(
    "INSERT INTO PUBLIC.accounts (id, balance) VALUES (?, ?)",
    vec![IgniteValue::Int(2), IgniteValue::Double(200.0)],
).await?;

tx.commit().await?;
// If commit() is not called, Drop triggers a fire-and-forget rollback.
```

### Transaction helper (auto-commit/rollback)

```rust,ignore
let result = client
    .with_transaction(|mut tx| async move {
        let rows = tx.query("SELECT balance FROM PUBLIC.accounts WHERE id = ?",
            vec![IgniteValue::Int(1)]).await?;
        // ... business logic ...
        Ok((tx, rows))
    })
    .await?;
```

### KV cache

```rust,ignore
let cache = client.get_or_create_cache("my-cache").await?;

cache.put(IgniteValue::Int(1), IgniteValue::String("hello".into())).await?;
let val = cache.get(IgniteValue::Int(1)).await?;
println!("{:?}", val); // String("hello")
```

### Expiry / TTL

`cache.with_expiry_policy(policy)` returns a new handle whose operations apply a
per-entry time-to-live. The policy carries three independent durations — applied
when an entry is **created**, **updated** (overwritten), and **accessed** (read)
— each one of `Eternal`, `Immediate`, `Millis(n)`, or `Unchanged` (leave the
entry's current lifetime alone).

```rust,ignore
use ignite_client::{ExpiryPolicy, ExpiryDuration};
use std::time::Duration;

let cache = client.get_or_create_cache("SESSIONS").await?;

// New entries live 30 minutes; reads/updates don't change that.
let ttl = ExpiryPolicy::new(
    ExpiryDuration::from_duration(Duration::from_secs(30 * 60)), // create
    ExpiryDuration::Unchanged,                                   // update
    ExpiryDuration::Unchanged,                                   // access
);
cache.with_expiry_policy(ttl)
    .put(IgniteValue::String("token".into()), IgniteValue::Int(42))
    .await?;

// A "sliding" session: every read extends the lifetime by 30 minutes.
let sliding = ExpiryPolicy::new(
    ExpiryDuration::Eternal,
    ExpiryDuration::Unchanged,
    ExpiryDuration::from_duration(Duration::from_secs(30 * 60)),
);
let v = cache.with_expiry_policy(sliding).get(IgniteValue::String("token".into())).await?;
```

Requires the server's `EXPIRY_POLICY` feature (protocol ≥ 1.6, always available
on the 1.7 servers this client targets). The base handle (no policy) uses the
cache's configured default.

### Streaming cursor

```rust,ignore
use futures::StreamExt;

let mut stream = client
    .query_stream("SELECT id FROM PUBLIC.big_table", vec![])
    .await?;

while let Some(row) = stream.next().await {
    let row = row?;
    println!("{:?}", row.get(0));
}
```

### TLS

```rust,ignore
let config = IgniteClientConfig::new("localhost:10800")
    .with_tls();                         // use system CA bundle
    // .with_tls_accept_invalid_certs() // for self-signed / dev certs

let client = IgniteClient::new(config);
```

### Authentication

```rust,ignore
let config = IgniteClientConfig::new("localhost:10800")
    .with_auth("ignite", "ignite")
    .with_pool_size(20);
```

---

## Binary objects / Rust struct mapping

Ignite's **binary (complex) object** format lets a whole struct be stored as
a single cache value, self-describing enough for a Java client to read it
back as a POJO (and vice versa). `#[derive(IgniteBinary)]` generates the
plumbing to read and write a plain Rust struct this way, so you don't have
to build `IgniteValue`s field by field.

### The `#[derive(IgniteBinary)]` macro

```rust,ignore
use ignite_client::binary::IgniteBinary;

#[derive(IgniteBinary, Debug, Clone, PartialEq)]
#[ignite(type_name = "com.example.Address")]
struct Address {
    street: String,
    #[ignite(rename = "zip")]
    postcode: i32,
}

#[derive(IgniteBinary, Debug, Clone, Copy, PartialEq)]
#[ignite(type_name = "com.example.Color")]
enum Color {
    Red,
    Green,
    Blue,
}

#[derive(IgniteBinary, Debug, Clone, PartialEq)]
#[ignite(type_name = "com.example.Person")]
struct Person {
    name: String,
    age: i32,
    addr: Address,                      // nested struct -> nested complex object
    favorite_color: Color,              // fieldless enum -> Ignite ENUM
    scores: ignite_client::binary::IgniteList<i32>, // Java List<Integer>
    #[ignite(skip)]
    cache_generation: u64,              // never written/read; Default::default() on read
}
```

- `#[ignite(type_name = "...")]` (required, on the struct/enum) — the
  fully-qualified Ignite type name used to derive the type id
  (`type_id()`/Java-hashcode-of-lowercase semantics) and registered in
  `binary_type()`'s metadata.
- `#[ignite(rename = "...")]` (field, optional) — use this name as the
  Ignite field name (and therefore its `field_id`) instead of the Rust
  field's own name; the Rust field identifier is unaffected.
- `#[ignite(skip)]` (field, optional) — the field is never written or read,
  and absent from the schema/metadata entirely; on read it is filled via
  `Default::default()`, so its type must implement `Default`.

A derived struct implements `WriteBinary`/`ReadBinary` (whole-object
serialisation) and, so it can itself be nested inside another derived
struct's field, `FieldWrite`/`FieldRead` as well. A derived fieldless enum
implements only `FieldWrite`/`FieldRead` — each variant maps to its
declaration position (0, 1, 2, ...) as the Ignite `ENUM` ordinal; variants
carrying data are rejected at compile time.

### Newtypes

Ignite's wire format distinguishes several things Rust has no built-in type
for, or that would otherwise collide with an existing mapping. `binary`
exposes a newtype for each:

| Newtype | Wraps | Ignite wire type |
|---|---|---|
| `IgniteChar(u16)` | a single UTF-16 code unit | CHAR (7) — distinct from `String` (VARCHAR, 9) |
| `IgniteDate(i64)` | milliseconds since the Unix epoch | DATE (11) |
| `IgniteTime(i64)` | nanoseconds since midnight | TIME (36) |
| `IgniteTimestamp(i64, i32)` | `(epoch_ms, nanosecond_fraction)` | TIMESTAMP (33) |
| `IgniteList<T>(Vec<T>)` | a Java `Collection` (`List`/`Set`) | COLLECTION (24) |

`IgniteList<T>` exists because Java's `AllTypes` fixture has both `int[]`
(a primitive array) and `List<Integer>` (a collection) — both of which would
be `Vec<i32>` in Rust with no way to tell them apart. The plain `Vec<i32>`
impl maps to Ignite's primitive `INT_ARRAY`; wrap the value in `IgniteList`
to get a Java `Collection` instead. (`FieldWrite`/`FieldRead` are currently
implemented concretely for `IgniteList<i32>`; extend with more element types
as needed.)

### Type mapping

| Rust type | Ignite wire type | Type code |
|---|---|---|
| `bool` | BOOLEAN | 8 |
| `i8` | BYTE | 1 |
| `i16` | SHORT | 2 |
| `i32` | INT | 3 |
| `i64` | LONG | 4 |
| `f32` | FLOAT | 5 |
| `f64` | DOUBLE | 6 |
| `IgniteChar` | CHAR | 7 |
| `String` | STRING (VARCHAR) | 9 |
| `uuid::Uuid` | UUID | 10 |
| `IgniteDate` | DATE | 11 |
| `IgniteTime` | TIME | 36 |
| `IgniteTimestamp` | TIMESTAMP | 33 |
| `bigdecimal::BigDecimal` | DECIMAL | 30 |
| `Option<T>` | `T`'s wire type, or NULL | 101 (null) |
| `Vec<i32>` | INT_ARRAY (primitive array) | 14 |
| `Vec<String>` | STRING_ARRAY (nullable-element) | 20 |
| `IgniteList<i32>` | COLLECTION (Java `List`/`Set`) | 24 |
| `HashMap<String, i32>` | MAP (Java `Map`) | 25 |
| a `#[derive(IgniteBinary)]` struct field | nested COMPLEX_OBJECT | 103 |
| a `#[derive(IgniteBinary)]` fieldless enum | ENUM | 28 |

### put_binary / get_binary

```rust,ignore
let cache = client.get_or_create_cache("people").await?;

let p = Person {
    name: "Ada".into(),
    age: 36,
    addr: Address { street: "Main St".into(), postcode: 90210 },
    favorite_color: Color::Blue,
    scores: ignite_client::binary::IgniteList(vec![10, 20, 30]),
    cache_generation: 0,
};

cache.put_binary("ada", &p).await?;
let got: Option<Person> = cache.get_binary("ada").await?;
assert_eq!(got, Some(p));
```

`put_binary` registers `V::binary_type()` (the derived schema) with the
cluster via `OP_BINARY_TYPE_PUT` *before* writing the value, so any other
client — including a Java thin/thick client using `withKeepBinary()` or a
plain POJO — can decode the object without out-of-band schema sharing.
Re-registering an already-known, compatible type is a safe no-op (Ignite
merges metadata idempotently). `get_binary::<T>` fetches that metadata on
demand (via `OP_BINARY_TYPE_GET`) whenever the value on the wire uses a
compact footer, decodes into `T` via `ReadBinary`, and also resolves nested
struct fields' own schemas up front so they decode too. Both methods are
only available on non-transactional cache handles.

### Manual builder / reader API

For cases the derive macro doesn't cover, `binary` also exposes the
lower-level codec directly:

```rust,ignore
use ignite_client::binary::{BinaryObjectBuilder, BinaryObjectReader};

let obj = BinaryObjectBuilder::new("com.example.Point")
    .set_i32("x", 1)
    .set_i32("y", 2)
    .build();

let r = BinaryObjectReader::new(obj.bytes)?;
let x = r.get_i32("x")?; // Some(1)
```

`BinaryObjectBuilder` has `set_value`/`set_bool`/`set_i8`.../`set_string`/
`set_object`/`set_enum`/`set_i32_array`/`set_string_array`/`set_int_list`/
`set_string_int_map` setters and a `build()` that produces a `BinaryObject`
(`{ type_id, schema_id, bytes }`). `BinaryObjectReader::new`/`with_schema`
parse a frame; `field()` returns the raw `IgniteValue`, and `get_bool`/
`get_i32`/`get_i64`/`get_string` are typed convenience getters.

### `IgniteValue::Object`

A binary (complex) object decodes into the `IgniteValue::Object(BinaryObject)`
variant — both a top-level value read back with `get_binary`/`get` +
`withKeepBinary`-style access, and a nested object embedded in another
object's field data. See [IgniteValue type system](#ignitevalue-type-system).

### Limitations

- **Non-compact footers only (v1).** `BinaryObjectBuilder` always writes a
  self-describing, non-compact `(fieldId, offset)` footer (never the compact,
  offset-only footer real Ignite peers use by default). This is
  transparent for round-tripping through this crate, or through a Java
  client reading the object generically (`BinaryObject` /
  `withKeepBinary()`), but it changes how Ignite's own POJO marshaller
  reconstructs certain Java-side field types: a Rust-written object with a
  field typed **`java.sql.Date`** on the Java side will *not* deserialize
  into that exact field — Ignite's field-type detection recognizes
  `java.sql.Date` only through the compact-footer's dynamic-object path, so
  the read falls back to a plain `java.util.Date` and then fails
  `Field.set` against the `java.sql.Date`-typed field. Use
  `java.util.Date` for the corresponding field on the Java side (the DATE
  (11) wire type is unaffected either way), or read the value back as a
  generic `BinaryObject` instead of a typed POJO. `java.sql.Time` and
  `java.sql.Timestamp` are **not** affected — Ignite's TIME/TIMESTAMP
  detection matches those classes exactly regardless of footer style.
- **Nested objects inside collections/arrays/maps are not yet
  schema-resolved on read.** A `#[derive(IgniteBinary)]` struct used
  directly as a *field* (including as another derived struct's field) is
  fully supported, including compact-footer nested objects written by a
  real Ignite peer. An object embedded *inside* a `List`/`Set`/array/`Map`
  field's elements is not schema-prefetched the same way and may fail to
  decode if it uses a compact footer.

---

## API Reference

### IgniteClientConfig

```rust,ignore
pub struct IgniteClientConfig {
    pub address: String,                  // primary "host:port" (first of `addresses`)
    pub addresses: Vec<String>,           // all cluster node addresses (for routing)
    pub partition_awareness: Option<bool>,// None = auto (on when ≥ 2 addresses); Some(b) forces it
    pub endpoint_discovery: Option<bool>, // None = auto (on); learn nodes not in `addresses`
    pub data_center_id: Option<String>,   // this client's DC, for read-from-backup routing
    pub username: Option<String>,
    pub password: Option<String>,
    pub max_pool_size: usize,             // default: 10 (per node)
    pub connect_timeout: Duration,        // default: 10 s
    pub request_timeout: Duration,        // default: 30 s
    pub page_size: usize,                 // SQL rows per round-trip, default: 1024
    pub use_tls: bool,                    // default: false
    pub tls_accept_invalid_certs: bool,   // default: false
}

impl IgniteClientConfig {
    pub fn new(address: impl Into<String>) -> Self;       // single-node convenience
    pub fn with_addresses(self, addresses: Vec<String>) -> Self;   // multi-node cluster
    pub fn with_partition_awareness(self, enabled: bool) -> Self;  // force PA on/off
    pub fn with_endpoint_discovery(self, enabled: bool) -> Self;   // force discovery on/off
    pub fn with_data_center_id(self, dc_id: impl Into<String>) -> Self;  // DC-aware reads
    pub fn with_auth(self, username, password) -> Self;
    pub fn with_pool_size(self, size: usize) -> Self;
    pub fn with_connect_timeout(self, duration: Duration) -> Self;
    pub fn with_request_timeout(self, duration: Duration) -> Self;
    pub fn with_page_size(self, page_size: usize) -> Self;
    pub fn with_tls(self) -> Self;
    pub fn with_tls_accept_invalid_certs(self) -> Self;
}
```

`connect_timeout` is also used as the deadpool `wait` and `create` timeout.
`request_timeout` is applied per-request inside `send_and_receive`.
`new()` seeds `addresses` with the single address; `with_addresses()` replaces
the list and sets `address` to the first entry. Partition awareness is enabled
automatically once two or more addresses are configured (see
[Partition awareness](#partition-awareness)).

### IgniteClient

`IgniteClient` is `Clone` — share a single instance across tasks; it wraps an
`Arc`'d per-node channel registry and a shared affinity context. Cache
operations are automatically routed to the key's owning node when partition
awareness is enabled.

```rust,ignore
impl IgniteClient {
    pub fn new(config: IgniteClientConfig) -> Self;

    /// Execute a SELECT; returns all rows (cursor automatically paginated).
    pub async fn query(&self, sql: &str, params: Vec<IgniteValue>) -> Result<QueryResult>;

    /// Execute a SELECT; returns rows lazily as a QueryStream.
    pub async fn query_stream(&self, sql: &str, params: Vec<IgniteValue>) -> Result<QueryStream>;

    /// Execute INSERT / UPDATE / DELETE.
    pub async fn execute(&self, sql: &str, params: Vec<IgniteValue>) -> Result<UpdateResult>;

    /// Begin a transaction (Pessimistic / ReadCommitted by default).
    pub async fn begin_transaction(&self) -> Result<Transaction>;

    /// Begin a transaction with explicit settings.
    pub async fn begin_transaction_with(
        &self,
        concurrency: TxConcurrency,   // Optimistic | Pessimistic
        isolation: TxIsolation,        // ReadCommitted | RepeatableRead | Serializable
        timeout_ms: i64,               // 0 = no timeout
    ) -> Result<Transaction>;

    /// Run a closure inside a managed transaction.
    pub async fn with_transaction<F, Fut, T>(&self, f: F) -> Result<T>;

    /// Return a cache handle (no network call — cache must already exist).
    pub fn cache(&self, name: &str) -> IgniteCache;

    /// Get-or-create a cache with default (ATOMIC) atomicity.
    pub async fn get_or_create_cache(&self, name: &str) -> Result<IgniteCache>;

    /// Get-or-create a cache with TRANSACTIONAL atomicity (required for KV tx).
    pub async fn get_or_create_transactional_cache(&self, name: &str) -> Result<IgniteCache>;

    /// Destroy a cache and all its data.
    pub async fn destroy_cache(&self, name: &str) -> Result<()>;

    /// List all cache names defined on the server.
    pub async fn cache_names(&self) -> Result<Vec<String>>;

    /// Pool health / sizing diagnostics.
    pub fn pool_status(&self) -> deadpool::managed::Status;
}
```

### Transaction

```rust,ignore
impl Transaction {
    pub async fn query(&mut self, sql: &str, params: Vec<IgniteValue>) -> Result<QueryResult>;
    pub async fn query_stream(&mut self, sql: &str, params: Vec<IgniteValue>) -> Result<QueryStream>;
    pub async fn execute(&mut self, sql: &str, params: Vec<IgniteValue>) -> Result<UpdateResult>;
    pub async fn commit(self) -> Result<()>;
    pub async fn rollback(self) -> Result<()>;

    /// Return a cache handle bound to this transaction.
    pub fn cache(&self, name: &str) -> IgniteCache;

    // Drop triggers a fire-and-forget rollback if not already committed/rolled back.
}
```

Transaction isolation levels map to Ignite protocol values:

| `TxIsolation` | Protocol value |
|---|---|
| `ReadCommitted` | 0 |
| `RepeatableRead` | 1 |
| `Serializable` | 2 |

Transaction concurrency modes:

| `TxConcurrency` | Protocol value |
|---|---|
| `Optimistic` | 0 |
| `Pessimistic` | 1 |

> **Note:** DML inside thin-client transactions requires the Ignite node to be
> started with `-DIGNITE_ALLOW_DML_INSIDE_TRANSACTION=true` and the table must
> use `ATOMICITY=TRANSACTIONAL`.

### IgniteCache

Obtained via `client.cache()`, `client.get_or_create_cache()`, or
`transaction.cache()`.  Cheap to clone — holds an `i32` cache ID and either the
channel registry + affinity context (non-transactional, affinity-routed) or a
transaction connection.

> **Cache names are case-sensitive**`cache_id()` hashes the exact name,
> matching Apache Ignite (a cache created as `myCache` is distinct from
> `MYCACHE`).

```rust,ignore
impl IgniteCache {
    pub async fn get(&self, key: IgniteValue) -> Result<IgniteValue>;
    pub async fn put(&self, key: IgniteValue, value: IgniteValue) -> Result<()>;
    pub async fn put_if_absent(&self, key: IgniteValue, value: IgniteValue) -> Result<bool>;
    pub async fn get_all(&self, keys: Vec<IgniteValue>) -> Result<Vec<(IgniteValue, IgniteValue)>>;
    pub async fn put_all(&self, entries: Vec<(IgniteValue, IgniteValue)>) -> Result<()>;
    pub async fn contains_key(&self, key: IgniteValue) -> Result<bool>;
    pub async fn remove(&self, key: IgniteValue) -> Result<()>;
    pub async fn replace(&self, key: IgniteValue, value: IgniteValue) -> Result<bool>;
    pub async fn get_and_put(&self, key: IgniteValue, value: IgniteValue) -> Result<IgniteValue>;
    pub async fn get_and_remove(&self, key: IgniteValue) -> Result<IgniteValue>;
    pub async fn get_and_replace(&self, key: IgniteValue, value: IgniteValue) -> Result<IgniteValue>;
    pub async fn remove_all(&self, keys: Vec<IgniteValue>) -> Result<()>;
    pub async fn get_size(&self) -> Result<i64>;

    /// New handle whose operations apply `policy` (per-entry TTL). Cheap to clone.
    pub fn with_expiry_policy(&self, policy: ExpiryPolicy) -> IgniteCache;
}
```

Expiry policy types:

```rust,ignore
/// TTL applied for one event (create / update / access).
pub enum ExpiryDuration {
    Unchanged,      // leave the entry's current expiry alone (wire -2)
    Eternal,        // never expires (wire -1)
    Immediate,      // expire at once (wire 0)
    Millis(u64),    // time-to-live in milliseconds (wire > 0)
}

impl ExpiryDuration {
    /// From a `std::time::Duration` (zero → `Immediate`).
    pub fn from_duration(d: std::time::Duration) -> Self;
}

/// Per-entry lifetime applied on create, update, and access.
pub struct ExpiryPolicy {
    pub create: ExpiryDuration,
    pub update: ExpiryDuration,
    pub access: ExpiryDuration,
}

impl ExpiryPolicy {
    pub fn new(create: ExpiryDuration, update: ExpiryDuration, access: ExpiryDuration) -> Self;
}
```

The three durations map directly to JCache's `getExpiryForCreation` /
`Update` / `Access`, but use plain Rust naming. `with_expiry_policy` mirrors the
Java thin client's `withExpirePolicy`; the policy rides in the cache-op header
(flag `0x04` + three `i64` durations).

### QueryResult / Row

```rust,ignore
pub struct QueryResult {
    pub columns: Vec<Column>,  // result-set metadata; available before any row processing
    pub rows: Vec<Row>,
}

impl QueryResult {
    pub fn row_count(&self) -> usize;
    pub fn first_row(&self) -> Option<&Row>;
}

pub struct Row { /* ... */ }

impl Row {
    pub fn len(&self) -> usize;
    pub fn get(&self, index: usize) -> Option<&IgniteValue>;
    pub fn get_by_name(&self, name: &str) -> Option<&IgniteValue>; // case-insensitive
    pub fn columns(&self) -> &[Column];
    pub fn values(&self) -> &[IgniteValue];
}

pub struct UpdateResult {
    pub rows_affected: i64,  // -1 if server did not return a count
}
```

### Column and ColumnType

```rust,ignore
pub struct Column {
    /// Column name as returned by the server (matches the SQL alias or field name).
    pub name: String,
}

pub enum ColumnType {
    Boolean,
    Byte,       // TINYINT
    Short,      // SMALLINT
    Int,        // INT
    Long,       // BIGINT
    Float,      // REAL / FLOAT
    Double,     // DOUBLE
    Char,       // CHAR (single BMP code point)
    String,     // VARCHAR
    Uuid,       // UUID
    Date,       // DATE
    Timestamp,  // TIMESTAMP
    Time,       // TIME
    Decimal,    // DECIMAL / NUMERIC
    Binary,     // BINARY / VARBINARY
    Unknown,    // NULL value — type could not be determined
}

impl ColumnType {
    /// Return the canonical SQL type name, e.g. `"INT"`, `"VARCHAR"`, `"TIMESTAMP"`.
    pub fn as_str(&self) -> &'static str;
}

impl IgniteValue {
    /// Derive the `ColumnType` from this value's own 1-byte wire type tag.
    ///
    /// Always accurate for non-NULL values.  Returns `ColumnType::Unknown`
    /// only for `IgniteValue::Null` (a NULL carries no type tag in the protocol).
    pub fn column_type(&self) -> ColumnType;
}
```

### QueryStream

A lazily-paged result stream returned by `client.query_stream()` and
`transaction.query_stream()`.  Rows are yielded one at a time; subsequent pages
are fetched from the server only when the current page is exhausted.  The
server-side cursor is closed automatically when the stream is exhausted or
dropped mid-iteration.

```rust,ignore
pub struct QueryStream {
    pub columns: Vec<Column>,
    // implements futures::Stream<Item = Result<Row>>
}

impl QueryStream {
    /// Drain the entire stream into a Vec (convenience wrapper).
    pub async fn collect_all(self) -> Result<Vec<Row>>;
}
```

Use `futures::StreamExt` to drive the stream with `.next().await`.

### IgniteValue type system

`IgniteValue` maps every Ignite wire type to a Rust variant:

```rust
pub enum IgniteValue {
    Null,
    Bool(bool),
    Byte(i8),               // TINYINT
    Short(i16),             // SMALLINT
    Int(i32),               // INT
    Long(i64),              // BIGINT
    Float(f32),             // REAL / FLOAT
    Double(f64),            // DOUBLE
    Char(u16),              // CHAR (BMP code point)
    String(String),         // VARCHAR / LONGVARCHAR
    Uuid(uuid::Uuid),       // UUID / CHAR(36)
    Date(i64),              // milliseconds from Unix epoch
    Timestamp(i64, i32),    // (epoch_ms, nanosecond_fraction)
    Time(i64),              // nanoseconds from midnight
    Decimal(BigDecimal),    // DECIMAL / NUMERIC
    ByteArray(Vec<u8>),     // BINARY / VARBINARY
    RawObject(Vec<u8>),     // payload bytes of an Ignite BINARY_OBJECT (type 27)
    Object(BinaryObject),   // a decoded binary (complex) object (type 103)
    IntArray(Vec<i32>),
    StringArray(Vec<Option<String>>),
    Collection(u8, Vec<IgniteValue>),
    Map(u8, Vec<(IgniteValue, IgniteValue)>),
    Enum { type_id: i32, ordinal: i32 },
}
```

`Object` carries a fully-decoded binary (complex) object — see
[Binary objects / Rust struct mapping](#binary-objects--rust-struct-mapping)
for the ergonomic `#[derive(IgniteBinary)]` layer built on top of it, and the
`Limitations` subsection there for what `Object`-typed elements nested
inside `Collection`/`IntArray`/`Map` currently don't support.

Wire encoding follows the spec at
https://ignite.apache.org/docs/latest/binary-client-protocol/data-format

Notable encoding details:

- **UUID**: 16 bytes big-endian (most-significant bytes first, matching Java's
  `UUID.getMostSignificantBits()` / `getLeastSignificantBits()`)
- **Decimal**: `[i32: scale][i32: byte_count][bytes: two's-complement big-endian magnitude]`
- **Timestamp**: `[i64: epoch_ms][i32: nanoseconds_fraction]`
- **Null**: type code 101 with no payload; any typed field may be null

---

## Architecture

### Request multiplexing

A single `IgniteConnection` supports many concurrent requests without
serialising them through a mutex on reads:

```
Caller A ──request(id=1)──┐               ┌──response(id=1)──▶ Caller A
                          │  TCP socket   │
Caller B ──request(id=2)──┤ ─────────────▶│
                          │               │  background
Caller C ──request(id=3)──┘               │  reader task
                                          └──response(id=3)──▶ Caller C
                                             response(id=2)──▶ Caller B
```

Requests are written to a `Mutex<SplitSink>` (contended only on write, not on
read).  Each caller registers a `oneshot::Sender` in a shared `HashMap<i64,
Sender>` keyed by `request_id`.  The background reader task peeks the first 8
bytes of each response frame, looks up the sender, and delivers the payload.

This is the same design used by `tokio-postgres` and `redis-rs`.

### Connection pool

`IgniteClient` wraps a [deadpool](https://crates.io/crates/deadpool) managed
pool of `IgniteConnection` objects.  Pool behaviour:

- `max_pool_size` connections maximum (default 10)
- Each connection is health-checked on recycle via `is_alive()` (AtomicBool)
- Connections are created on demand, not pre-warmed
- `connect_timeout` is applied as both the deadpool `wait` and `create` timeout
- TCP keepalive is applied to every socket (60 s idle, 15 s interval)

### Transaction connections

Transactions use a **dedicated TCP connection** that is not drawn from the pool.
This avoids pool exhaustion when many concurrent long-running transactions are
in flight.  The connection is closed when the `Transaction` is dropped.

### Pagination

`OP_QUERY_SQL_FIELDS` returns a first page with a `cursor_id` and a `has_more`
flag.

- `client.query()` / `transaction.query()` — automatically fetches all
  subsequent pages via `OP_QUERY_SQL_FIELDS_CURSOR_GET_PAGE` and returns a
  fully materialised `QueryResult`.
- `client.query_stream()` / `transaction.query_stream()` — returns a
  `QueryStream` that fetches pages lazily as the consumer polls the stream.
  The server-side cursor is closed when the stream is exhausted or dropped.

The `page_size` config field controls how many rows are returned per server
round-trip (default 1024).

### Partition awareness

Without partition awareness every request goes to one configured node, and the
server silently re-routes each key to its owning node — an extra network hop.
With partition awareness the client computes the owning node locally and sends
the request straight to it. The design mirrors the Java thin client
(`org.apache.ignite.internal.client.thin`):

```
        ┌──────────────── IgniteClient ─────────────────┐
        │  AffinityContext (Arc-shared)                 │
        │   • partition → primary-node UUID mappings    │
        │   • topology version + single-flight refresh  │
        └───────────────────────────────────────────────┘
                          │ affinity_node(cache_id, key)
   key ─▶ affinity_hash(key) ─▶ partition(hash, mask, parts) ─▶ node UUID
        ┌──────────── ChannelRegistry ──────────────┐
        │  pool[node-1]  pool[node-2]  pool[node-3] │  ← one deadpool pool per node
        │  node UUID → pool index (learned)         │
        └───────────────────────────────────────────┘
                          │ get(target) → owning node, else default round-robin
                  request sent to the primary node
```

How it works:

1. **Handshake** — connecting to each node learns its **node UUID** (parsed from
   the protocol 1.7 handshake success response) and keys that node's pool.
2. **Mapping fetch** — on first use of a cache, or after a topology change, the
   client issues `CACHE_PARTITIONS` (op 1101) and decodes the
   `partition → node` table. Fetches are single-flighted per cache.
3. **Routing** — for a single-key op, `affinity_hash(key)` (a faithful port of
   the JVM `hashCode()` for primitives / `String` / `UUID`) feeds the rendezvous
   `partition(...)` function; the resulting partition selects the primary node's
   pool.
4. **Topology tracking** — every response header carries the affinity topology
   version; a bump marks the held mappings stale and triggers a lazy refresh.

**Fail safe.** Partition awareness is a pure optimization. Any miss — PA
disabled, no mapping yet, unknown/unsupported key type, unknown node, or a dead
target pool — falls back to the default channel, so observable results are
always identical to the single-node path. Multi-key ops (`get_all`, `put_all`),
SQL, and transactions use the default channel.

**Endpoint discovery.** When partition awareness is enabled, on first use the
client also asks the cluster for the full set of server node endpoints
(`CLUSTER_GROUP_GET_NODE_ENDPOINTS`, op 5102) and opens channels to any node not
in the configured address list — so you can list a single bootstrap address and
still route to every node. Toggle with `with_endpoint_discovery(true|false)`.

**Read-from-backup (DC-aware).** Mirroring the Java thin client, read-only ops
(`get`, `contains_key`) are routed with `primary = false`: when the client and
server have negotiated the `DC_AWARE` feature **and** the client has a
data-center id set (`with_data_center_id("DC1")`), the server includes a
same-data-center partition map in `CACHE_PARTITIONS` responses and reads go to a
DC-local backup owner; otherwise the DC map equals the primary map and reads go
to the primary. The feature is negotiated at handshake (the client advertises
`DC_AWARE`; the DC-aware wire format is used only if the server also supports
it — `DC_AWARE` landed in Ignite 2.18), so it is a safe no-op against older
servers. Writes always route to the primary.

This is verified end to end against a 2-data-center cluster: with the client set
to `dcId = DC1` and nodes split DC1/DC1/DC2, reads of keys whose primary is the
DC2 node route to a DC1 backup instead — see the `pa_dc_aware_read_path_is_correct`
test and the [Local cluster](#local-3-node-test-cluster) DC-demo mode.

**Enablement.** Auto-on when two or more addresses are configured; override with
`with_partition_awareness(true|false)`. Configure the cluster with:

```rust,ignore
let config = IgniteClientConfig::new("node1:10800")
    .with_addresses(vec![
        "node1:10800".into(),
        "node2:10800".into(),
        "node3:10800".into(),
    ]);
let client = IgniteClient::new(config);

let cache = client.get_or_create_cache("MY_CACHE").await?;
cache.put(IgniteValue::Int(42), IgniteValue::Long(7)).await?; // routed to key 42's node
```

Scope: routing currently covers primitive, `String`, and `UUID` keys (the types
with well-defined, exactly-replicable Java hash codes). Custom affinity-key
field extraction is future work.

---

## Codec Details

The `src/protocol/` module contains the codec with no I/O dependency, making
it independently testable.

### Frame format

```
[i32 LE: payload_length]  ← length prefix handled by LengthDelimitedCodec
[payload bytes]           ← the codec layer strips the prefix before delivery
```

### Request payload format

```
[i16 LE: op_code]
[i64 LE: request_id]
[operation-specific fields …]
```

### Response payload format

```
[i64 LE: request_id]
[i32 LE: status]          ← 0 = success; non-zero = server error
if status != 0:
  [string: error_message]
if status == 0:
  [operation-specific response …]
```

### Java string hashing

Cache IDs and field IDs in the binary protocol are derived using Java's
`String.hashCode()` algorithm.  The `java_hash()` function in `types.rs`
replicates this:

```rust
pub fn java_hash(s: &str) -> i32 {
    s.chars().fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(c as i32))
}
```

The `cache_id()` helper derives the cache ID from a cache name using the same
`java_hash()` on the exact name. Cache names are **case-sensitive**, matching
Apache Ignite — a cache created as `myCache` is distinct from `MYCACHE`:

```rust
pub fn cache_id(name: &str) -> i32 {
    java_hash(name)
}
```

---

## Comparison with Existing Rust Clients

| | [vkulichenko]https://github.com/vkulichenko/ignite-rust-client | [ptupitsyn fork]https://github.com/ptupitsyn/ignite-rust-client | **this crate** |
|---|:---:|:---:|:---:|
| SQL queries ||||
| Cursor pagination ||||
| Streaming cursor ||||
| Transactions ||||
| Async I/O (tokio) ||||
| Connection pool ||||
| UUID / Date / Timestamp / Decimal ||||
| Null handling ||||
| Query parameters ||||
| TLS ||||
| KV get/put ||||
| Partition awareness / affinity routing ||||
| Last commit | 2020 | 2020 | 2026 |
| Intended use | learning exercise | abandoned fork | production |

The vkulichenko / ptupitsyn implementations are synchronous, blocking, cover
only `get` / `put` on primitives, and have not been updated since 2020.  They
are not suitable as a dependency baseline for production work.

---

## Running Tests

### Unit tests (no live node required)

```bash
cargo test --lib
```

Covers:

- `IgniteValue` codec roundtrip for every type (Null, Bool, Byte, Short, Int,
  Long, Float, Double, Char, String, UUID, Date, Timestamp, Time, Decimal,
  ByteArray, RawObject)
- Two's-complement Decimal encoding (positive, negative, zero, boundary)
- `java_hash()` against known Java reference values
- `SqlFieldsRequest` encode/decode
- Transaction start/end encoding
- Expiry policy: `ExpiryDuration` wire sentinels (-2/-1/0/ms) and the
  cache-header expiry flag + durations (ordered before `tx_id`)
- **Partition awareness** (`src/affinity.rs`, `src/channel.rs`):
  - `affinity_hash()` JVM `hashCode` golden vectors per key type (Int, Long,
    Bool, Byte/Short/Char, String, UUID) and unsupported-kind fallback
  - rendezvous `partition()` / `calculate_mask()` / `safe_abs()` golden vectors
  - `CACHE_PARTITIONS` request encoder + response decoder (against byte fixtures)
  - `AffinityContext` routing decisions, topology-version ordering, single-flight
    refresh; `PoolSelector` node→pool mapping and round-robin fallback
  - `CACHE_PARTITIONS` opcode (1101), node-UUID and feature-bitmask handshake
    parsing, and response-header topology-version capture
  - endpoint discovery (`src/discovery.rs`): `CLUSTER_GROUP_GET_NODE_ENDPOINTS`
    (opcode 5102) request/response codec and typed-string/raw-UUID readers
  - `DC_AWARE` negotiation + read-from-backup routing: handshake feature-bit
    advertisement/negotiation, DC-aware `CACHE_PARTITIONS` request/response
    codec, and `affinity_node` primary-vs-backup selection

### Integration tests (require a live Ignite 2.x node on localhost:10800)

All integration test modules connect to `localhost:10800` with no
authentication.  Start Ignite before running them — the easiest way is the
bundled [local 3-node cluster](#local-3-node-test-cluster).

DML-inside-transaction tests (used by `smoke.rs` and `transaction.rs`) require
the JVM system property that enables DML in thin-client transactions, and the
SQL `DATE` temporal test expects a UTC server timezone:

```bash
# Linux / macOS
IGNITE_HOME/bin/ignite.sh \
    -DIGNITE_ALLOW_DML_INSIDE_TRANSACTION=true \
    -Duser.timezone=UTC \
    config.xml

# Windows (example wrapper script)
C:\ignite\run-ignite.bat
```

(The `local-cluster/start.sh` script sets both of these automatically.)

The `partition_awareness.rs` module exercises affinity routing. Point it at the
whole cluster with the `IGNITE_ADDRS` environment variable (comma-separated);
without it the tests use the single default address and still exercise the full
routing pipeline against one node:

```bash
IGNITE_ADDRS=localhost:10800,localhost:10801,localhost:10802 \
  cargo test --test partition_awareness -- --nocapture --test-threads=1
```

**Always run integration tests with `--test-threads=1`.**  Parallel DDL
(CREATE / DROP TABLE) causes schema lock contention, and many concurrent
connections from parallel tests can exhaust Ignite's handshake thread pool,
producing `Unable to perform handshake within timeout` errors.

Run all integration tests across all modules in one command:

```bash
cargo test --tests -- --nocapture --test-threads=1
```

Or run individual modules:

```bash
cargo test --test smoke              -- --nocapture --test-threads=1
cargo test --test metadata           -- --nocapture --test-threads=1
cargo test --test functional_query   -- --nocapture --test-threads=1
cargo test --test functional_cache   -- --nocapture --test-threads=1
cargo test --test transaction        -- --nocapture --test-threads=1
cargo test --test partition_awareness -- --nocapture --test-threads=1
cargo test --test expiry             -- --nocapture --test-threads=1
```

> **Windows note:** If Windows Smart App Control blocks newly compiled test
> binaries, redirect the build output to AppData:
> ```
> set CARGO_TARGET_DIR=%APPDATA%\ignite-test-target
> cargo test --tests -- --nocapture --test-threads=1
> ```

---

### `tests/smoke.rs` — 35 tests

Broad end-to-end coverage of every public API surface.  Tests are prefixed
`smoke_`.

- Basic connectivity (`SELECT 1`)
- SQL query and DML (`query`, `execute`)
- Multi-page cursor pagination
- `query_stream` (lazy streaming) and early-drop cursor close
- Configurable `page_size` (forces multi-page fetch)
- `begin_transaction`, `begin_transaction_with` (explicit concurrency/isolation/timeout)
- `with_transaction` (auto-commit and rollback/Drop paths)
- `Transaction::query`, `Transaction::execute`, `Transaction::query_stream`
- `Transaction::cache` (KV ops inside a transaction — commit and rollback)
- `IgniteCache`: get, put, put_if_absent, get_all, put_all, contains_key,
  remove, replace, get_and_put, get_and_remove, get_and_replace, get_size
- `cache_names`, `get_or_create_cache`, `destroy_cache`
- `Row::get`, `get_by_name`, `len`, `is_empty`, `columns`, `values`
- `QueryResult::columns` and `row_count`
- `IgniteValue::column_type()` for per-value type accuracy
- `pool_status()`, `with_pool_size()`, `with_auth()` builder fields
- Type roundtrips: Bool, Int, Long, Double, String, Byte, Short, Float,
  ByteArray, Decimal, UUID, Null
- Date, Time, Timestamp as query parameters
- Server-side error propagation
- TLS config builder (no network), TLS graceful failure to plaintext server
- Concurrent queries on a shared client

---

### `tests/metadata.rs` — 10 tests

Rust port of selected tests from Apache Ignite's `JdbcThinMetadataSelfTest.java`,
adapted to use `SYS.*` system views via `OP_QUERY_SQL_FIELDS` rather than JDBC
`DatabaseMetaData`.

| Test | What it verifies |
|---|---|
| `metadata_result_set_column_names_and_types` | JOIN query column names and per-value `column_type()` |
| `metadata_decimal_and_date_column_types` | `ColumnType::Decimal` and `ColumnType::Date` round-trip |
| `metadata_tables_visible_in_sys_tables_view` | Created table appears in `SYS.TABLES` |
| `metadata_public_tables_present_in_sys_tables_view` | Multiple tables visible together |
| `metadata_schemas_visible_in_sys_schemas_view` | `PUBLIC` and `SYS` schemas present |
| `metadata_sys_schemas_filter_returns_no_rows_for_unknown_pattern` | Empty result for unknown schema |
| `metadata_columns_visible_in_sys_table_columns_view` | Column names and PK flag in `SYS.TABLE_COLUMNS` |
| `metadata_pk_index_visible_in_sys_indexes_view` | PK and secondary index in `SYS.INDEXES` |
| `metadata_all_table_indexes_present_in_sys_indexes_view` | Indexes from multiple tables visible |
| `metadata_query_unknown_table_returns_error` | Non-existent table query returns `Err` |

---

### `tests/functional_query.rs` — 6 tests

Rust port of Apache Ignite's `FunctionalQueryTest.java` (indexing module).

| Test | Java source | What it verifies |
|---|---|---|
| `functional_query_sql_fields_pagination` | `testQueries` | 100-row insert; query 50 rows with small `page_size`; multi-page cursor |
| `functional_query_sql` | `testSql` | CREATE TABLE, INSERT, SELECT by parameter, SELECT with `page_size=1` |
| `functional_query_empty_table` | `testGettingEmptyResultWhenQueryingEmptyTable` | Empty table query returns non-error result with 0 rows |
| `functional_query_mixed_sql_and_cache` | `testMixedQueryAndCacheApiOperations` | SQL INSERT then KV `cache.put`; SELECT sees both |
| `functional_query_server_error` | `testSqlParameterValidation` | Invalid SQL / missing table returns `Err` |
| `functional_query_empty_sql` | `testEmptyQuery` | Empty SQL string returns `Err` |

---

### `tests/functional_cache.rs` — 5 tests

Rust port of selected tests from Apache Ignite's `FunctionalTest.java` (thin
client internal tests), covering the KV cache API.

| Test | Java source | What it verifies |
|---|---|---|
| `functional_cache_management` | `testCacheManagement` | Full cache lifecycle: create → `get_size` → name in `cache_names` → destroy → name absent |
| `functional_cache_put_get` | `testPutGet` | `put`, `get`, `contains_key`, overwrite, remove, multi-type keys |
| `functional_cache_atomic_put_get` | `testAtomicPutGet` | `get_and_put`, `get_and_remove`, `put_if_absent`, `get_and_replace` sequences |
| `functional_cache_batch_put_get` | `testBatchPutGet` | `put_all` / `get_all` (full + partial) / `remove_all` subset / `get_size` |
| `functional_cache_remove_replace` | `testRemoveReplace` | `replace` (true/false) and `remove` / `remove_all` on a 100-entry dataset |

---

### `tests/transaction.rs` — 3 tests

Rust port of selected tests from Apache Ignite's `BlockingTxOpsTest.java`.

| Test | Java source | What it verifies |
|---|---|---|
| `tx_sum_invariant` | `testTransactionalConsistency` (Pessimistic/RepeatableRead) | 5 concurrent tasks × 100 key-transfer iterations; sum of all values remains 0 |
| `tx_sum_invariant_optimistic` | `testTransactionalConsistency` (Optimistic/Serializable) | Same invariant with conflict-retry loop |
| `tx_all_cache_ops_inside_tx` | `testBlockingOps` | Every cache operation works correctly inside an explicit transaction: `put`, `get`, `contains_key`, `put_all`, `get_all`, `put_if_absent`, `replace`, `get_and_put`, `get_and_remove`, `get_and_replace`, `remove`, `remove_all` |

---

### `tests/partition_awareness.rs` — 4 tests

End-to-end affinity-routing tests. Set `IGNITE_ADDRS` to route across multiple
nodes; otherwise they run against the single default address.

| Test | What it verifies |
|---|---|
| `pa_put_get_roundtrip_is_correct` | A spread of keys `put`/`get` correctly with partition awareness on — the routed write/read path returns every stored value |
| `pa_endpoint_discovery_reaches_unconfigured_nodes` | Configured with one node + PA forced on, the client discovers the rest of the cluster and still serves every key |
| `pa_dc_aware_read_path_is_correct` | On a DC-demo cluster (`IGNITE_DC_DEMO=1`), exercises the DC read path with the client's `dcId = DC1` (request carries the dcId, DC partition map is decoded, reads route through it) and asserts correct values |
| `pa_on_and_off_agree` | Reading the same keys through a PA-on and a PA-off client yields identical results (the fail-safe guarantee: routing never changes observable results) |

Enable `RUST_LOG=ignite_client=debug` to see routing decisions, e.g.
`fetched cache partitions cache_id=… version=… mappings=… applicable=…`.

---

### `tests/expiry.rs` — 3 tests

Cache entry TTL behaviour against a live node (any cluster — TTL is per-operation).

| Test | What it verifies |
|---|---|
| `expiry_creation_ttl` | A freshly inserted entry is gone after its creation TTL elapses |
| `expiry_update_ttl` | An eternal entry only starts expiring once overwritten (update TTL) |
| `expiry_access_ttl` | Reading an entry through an access-TTL handle sets its lifetime |

---

### Local 3-node test cluster

The `local-cluster/` directory contains everything needed to run a partitioned
3-node Apache Ignite cluster on localhost from a local Ignite binary:

| File | Purpose |
|---|---|
| `ignite-config.xml` | Node config: static localhost discovery, a partitioned cache, thin-client connector |
| `start.sh` | Launch three nodes sequentially on thin ports 10800 / 10801 / 10802 |
| `stop.sh` | Stop the local nodes |

Usage:

```bash
# point IGNITE_HOME at your local Apache Ignite install (contains bin/ignite.sh)
export IGNITE_HOME=/path/to/apache-ignite-x.y.z-bin

./local-cluster/start.sh          # forms a 3-node cluster, waits until ready
./local-cluster/stop.sh           # tears it down
```

`start.sh` auto-selects Java 11 (via `/usr/libexec/java_home -v 11` on macOS) and
sets `-DIGNITE_ALLOW_DML_INSIDE_TRANSACTION=true` and `-Duser.timezone=UTC` so the
full integration suite — including the transaction and temporal tests — passes.
Logs are written to `/tmp/ignite-node{1,2,3}.log`.

**DC-demo mode.** Set `IGNITE_DC_DEMO=1` to start a 2-data-center cluster (nodes
1 & 2 → `DC1`, node 3 → `DC2`, via `-DIGNITE_DATA_CENTER_ID`). Combined with the
pre-defined `DC_DEMO` cache (`FULL_SYNC` + `readFromBackup`) and a client
`dcId = DC1`, this drives the DC read path in `pa_dc_aware_read_path_is_correct`
(reads of DC2-primary keys route to a DC1 backup). Requires Ignite ≥ 2.18 (when
`DC_AWARE` was added):

```bash
IGNITE_DC_DEMO=1 ./local-cluster/start.sh
cargo test --test partition_awareness pa_dc_aware_read_path_is_correct \
  -- --test-threads=1
```

Run the complete suite against it:

```bash
export IGNITE_ADDRS=localhost:10800,localhost:10801,localhost:10802
cargo test --tests -- --test-threads=1
```

---

## Cargo.lock dependency pins

All dependencies are pinned to exact versions for reproducible builds.
The workspace root `Cargo.toml` `[workspace.dependencies]` section is the
single place to update them:

| Crate | Pinned version |
|---|---|
| `tokio` | `=1.50.0` |
| `tokio-util` | `=0.7.18` |
| `bytes` | `=1.11.1` |
| `thiserror` | `=2.0.18` |
| `tracing` | `=0.1.44` |
| `futures` | `=0.3.32` |
| `bigdecimal` | `=0.4.10` |
| `uuid` | `=1.21.0` |
| `deadpool` | `=0.13.0` |
| `async-trait` | `=0.1.89` |
| `num-bigint` | `=0.4.6` |
| `socket2` | `=0.6.2` |
| `rustls` | `=0.23.37` |
| `tokio-rustls` | `=0.26.4` |
| `rustls-native-certs` | `=0.8.3` |

---

## License

Apache License 2.0.  See [LICENSE](LICENSE).

Apache Ignite is a registered trademark of The Apache Software Foundation.
This project is not affiliated with or endorsed by the Apache Software
Foundation or GridGain Systems.