Work with RecordView and KeyValueView
Master both Table API view types: CRUD operations, bulk processing, cache-aside patterns, and the unified storage model that eliminates separate caching infrastructure.
Introduction
In the previous tutorial, you connected a Java application to the cluster and used RecordView and KeyValueView for basic reads and writes. You saw that both views access the same underlying data. Now you learn the full operation set and discover something more significant: KeyValueView gives you cache-like semantics backed directly by the system of record.
In a traditional architecture, you maintain a cache (Redis, Memcached) alongside your database and write invalidation logic to keep them synchronized. That synchronization is a source of bugs, staleness, and operational complexity.
With Ignite, there is no separate cache. A put through KeyValueView writes to the same distributed storage that SQL queries read. There is nothing to invalidate.
This tutorial starts with the API mechanics (operations, conditionals, bulk processing), moves to multi-column tables and partial POJO mapping, then pivots to the caching patterns that make this architecture practical.
This tutorial works with both Apache Ignite 3 and GridGain 9. The Java API is identical; select your product version in the tabs where Maven coordinates differ.
Prerequisites
- A running 3-node cluster with the Music Store dataset from Start Your Local Ignite 3 Development Cluster
- Completed Connect with the Java Thin Client (familiarity with the thin client, Maven project setup, and RecordView basics)
- Java 17 or later
- Maven 3.8 or later
Returning to these tutorials? Verify your environment.
Check that the cluster is running and the Music Store data is loaded:
- Apache Ignite 3
- GridGain 9
docker exec ignite3-node1 /opt/ignite3cli/bin/ignite3 sql \
"SELECT COUNT(*) AS tracks FROM Track;"
docker exec gridgain9-node1 /opt/gridgain9cli/bin/gridgain9 sql \
"SELECT COUNT(*) AS tracks FROM Track;"
Expected result: 3503. If the query succeeds, your environment is ready.
If the containers are stopped, restart them from the directory containing your docker-compose.yml:
docker compose up -d
Data persists across restarts. Wait 15-30 seconds for the nodes to rejoin, then re-run the check above.
If the cluster was destroyed (docker compose down), start the containers and re-initialize:
- Apache Ignite 3
- GridGain 9
docker compose up -d
Wait 10 seconds for the nodes to start, then initialize the cluster:
curl -X POST http://localhost:10300/management/v1/cluster/init \
-H "Content-Type: application/json" \
-d '{"metaStorageNodes":["node1","node2","node3"],"cmgNodes":[],"clusterName":"my-cluster"}'
Download the schema and data files:
curl -sO /assets/dataset/music-store-schema.sql
curl -sO /assets/dataset/music-store-data.sql
Copy the files into the container and load them:
docker cp music-store-schema.sql ignite3-node1:/tmp/
docker cp music-store-data.sql ignite3-node1:/tmp/
docker exec ignite3-node1 /opt/ignite3cli/bin/ignite3 sql --file /tmp/music-store-schema.sql
docker exec ignite3-node1 /opt/ignite3cli/bin/ignite3 sql --file /tmp/music-store-data.sql
The schema loader prints "Updated 0 rows" for each DDL statement. The data loader prints row counts per batch. Both commands emit jline reflection warnings that are cosmetic and safe to ignore.
docker compose up -d
Wait 10 seconds for the nodes to start, then load the license and initialize the cluster:
LICENSE=$(jq -Rs . gridgain-license.json)
curl -X POST http://localhost:10300/management/v1/cluster/init \
-H "Content-Type: application/json" \
-d '{"metaStorageNodes":["node1","node2","node3"],"cmgNodes":[],"clusterName":"my-cluster","license":'"$LICENSE"'}'
Download the schema and data files:
curl -sO /assets/dataset/music-store-schema.sql
curl -sO /assets/dataset/music-store-data.sql
Copy the files into the container and load them:
docker cp music-store-schema.sql gridgain9-node1:/tmp/
docker cp music-store-data.sql gridgain9-node1:/tmp/
docker exec gridgain9-node1 /opt/gridgain9cli/bin/gridgain9 sql --file /tmp/music-store-schema.sql
docker exec gridgain9-node1 /opt/gridgain9cli/bin/gridgain9 sql --file /tmp/music-store-data.sql
The schema loader prints "Updated 0 rows" for each DDL statement. The data loader prints row counts per batch. Both commands emit jline reflection warnings that are cosmetic and safe to ignore.
Re-run the check above to verify 3503 tracks are loaded.
What You Will Learn
- How to perform single-record and bulk operations with RecordView
- How to access tables as key-value pairs with KeyValueView
- How both views share the same underlying data
- How to map partial POJOs to wide tables for efficient access
- How to use KeyValueView as a cache backed by the system of record
- When to choose RecordView vs KeyValueView for your use case
What You Will Build
A structured Java project with domain model classes and a series of exploration classes that exercise the full RecordView and KeyValueView operation set against the Music Store dataset. The model package holds 3 stable POJO classes that map to cluster tables. Each step creates a new Java class file in the main package, so your previous code stays in the project for reference. You end up with 8 exploration classes plus the 3 model classes.
The progression starts with API mechanics on the 2-column Artist table. It then moves to partial POJO mapping on the 13-column Customer table. The final steps cover cache-aside patterns that show why Ignite's unified model eliminates the need for a separate caching layer. All write operations use Artist IDs above the existing dataset range and clean up after themselves.
Set Up the Project
Create a new project directory and set up a Maven project with the Ignite thin client dependency. The structure is the same as the previous tutorial.
mkdir t04-table-api && cd t04-table-api
- Apache Ignite 3
- GridGain 9
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>t04-table-api</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<exec.mainClass>com.example.musicstore.ConnectAndVerify</exec.mainClass>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.ignite</groupId>
<artifactId>ignite-client</artifactId>
<version>3.1.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<mainClass>${exec.mainClass}</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>t04-table-api</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<exec.mainClass>com.example.musicstore.ConnectAndVerify</exec.mainClass>
</properties>
<repositories>
<repository>
<id>gridgain-external</id>
<url>https://www.gridgainsystems.com/nexus/content/repositories/external</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.gridgain</groupId>
<artifactId>ignite-client</artifactId>
<version>9.1.8</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.4.1</version>
<configuration>
<mainClass>${exec.mainClass}</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
The group ID is org.gridgain, but the artifact name keeps the ignite- prefix. All Java packages remain org.apache.ignite.*. Your import statements and application code are identical between Apache Ignite 3 and GridGain 9.
GridGain 9 artifacts are hosted on the GridGain Nexus repository, not Maven Central. The <repositories> block in the pom.xml above is required. Without it, Maven will fail with Could not find artifact org.gridgain:ignite-client.
Create the source directories:
mkdir -p src/main/java/com/example/musicstore/model
package com.example.musicstore.model;
/**
* Domain model for the Music Store Artist table (2 columns).
*
* Ignite maps POJO fields to table columns by name (case-insensitive):
* artistId -> ARTISTID (primary key, INT32)
* name -> NAME (STRING / VARCHAR)
*
* No @Table or @Id annotations are needed because the Artist table already
* exists in the cluster (created by the Music Store DDL in the start local cluster tutorial). Annotations
* are only required when creating a table from code with the Catalog API.
*
* POJO requirements for Ignite table mapping:
* - No-arg constructor (Ignite calls this when deserializing rows from the cluster)
* - Getter/setter pair for each mapped field
* - Field names must match column names (case-insensitive)
* - Fields not present in the table are silently ignored
*
* For key-based lookups, only the primary key field needs a value.
* new Artist(1, null) tells Ignite: find ARTISTID=1, fill in
* NAME from storage. The client hashes the key to determine which
* cluster node owns that partition and routes the request directly.
*/
public class Artist {
private Integer artistId;
private String name;
/** Required: Ignite calls this when deserializing rows from the cluster. */
public Artist() {}
/**
* Convenience constructor for creating lookup keys and test data.
*
* @param artistId primary key (required for all operations)
* @param name artist name (null for key-only lookups)
*/
public Artist(Integer artistId, String name) {
this.artistId = artistId;
this.name = name;
}
public Integer getArtistId() { return artistId; }
public void setArtistId(Integer artistId) { this.artistId = artistId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
@Override
public String toString() {
return "Artist{id=" + artistId + ", name='" + name + "'}";
}
}
The model package holds your domain classes. These are stable artifacts that don't change as you explore different API operations. The Artist class maps to the existing Artist table in the cluster. Field names match column names (case-insensitive), and the no-arg constructor is required for Ignite to deserialize rows from the cluster.
Now create ConnectAndVerify.java:
package com.example.musicstore;
import com.example.musicstore.model.Artist;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.Transaction;
import java.util.logging.Level;
import java.util.logging.Logger;
public class ConnectAndVerify {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);
// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {
System.out.println("Connected to the cluster.");
// Navigate the Table package: client.tables() -> table("Artist") -> recordView()
// The Artist POJO in model/ maps fields to columns by name. RecordView<Artist>
// gives typed access: each row becomes an Artist object.
Table artistTable = client.tables().table("Artist");
RecordView<Artist> artists = artistTable.recordView(Artist.class);
// Key-based read: only the primary key field needs a value.
// new Artist(1, null) tells Ignite: find ARTISTID=1, fill in NAME from storage.
// The client hashes the key to route directly to the owning partition.
Artist acdc = artists.get((Transaction) null, new Artist(1, null));
System.out.println("Artist 1: " + acdc);
}
// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}
The project has two source files: the Artist model in the model package and ConnectAndVerify as the first exploration class. The model classes are stable artifacts that map your domain to the cluster. Each subsequent step creates a new Java class in the main package, so your previous code stays in the project for reference.
Run the application:
mvn compile exec:java -Dexec.mainClass=com.example.musicstore.ConnectAndVerify -q
Connected to the cluster.
Artist 1: Artist{id=1, name='AC/DC'}
RecordView Single-Record Operations
You can read an existing artist by key. Now work through the full set of single-record write operations. Each operation has different semantics for handling existing and missing records, and the distinction matters when multiple threads or services access the same data.
All write operations in this step use ArtistId values starting at 10001, safely above the 275 existing Music Store artists.
Create RecordViewOps.java in the same package:
package com.example.musicstore;
import com.example.musicstore.model.Artist;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.Transaction;
import java.util.logging.Level;
import java.util.logging.Logger;
public class RecordViewOps {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);
// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {
System.out.println("Connected to the cluster.");
// Navigate the Table package: client.tables() -> table() -> recordView()
Table artistTable = client.tables().table("Artist");
RecordView<Artist> artists = artistTable.recordView(Artist.class);
// RecordView offers four categories of single-record write operations:
//
// upsert - insert or update (idempotent, always succeeds)
// insert - create only, returns false if key exists (lost-update prevention)
// replace - update only, returns false if key missing (guards against phantom writes)
// delete - remove by key, returns true if found
// getAndDelete - atomic read-and-remove in one round-trip
//
// Each has async (CompletableFuture) and bulk variants.
// --- get: key-based read routes directly to the partition that owns ArtistId=1 ---
// Only the primary key field needs a value; non-key fields are null in the lookup key
Artist existing = artists.get((Transaction) null, new Artist(1, null));
System.out.println("get(1): " + existing);
// --- upsert: idempotent insert-or-update ---
// Always succeeds: creates the row if missing, overwrites if present.
// Safe for retries and idempotent event handlers.
artists.upsert((Transaction) null, new Artist(10001, "Tutorial Artist"));
Artist upserted = artists.get((Transaction) null, new Artist(10001, null));
System.out.println("After upsert(10001): " + upserted);
// Upsert again with a different name: the existing row is silently overwritten
artists.upsert((Transaction) null, new Artist(10001, "Updated Tutorial Artist"));
Artist updated = artists.get((Transaction) null, new Artist(10001, null));
System.out.println("After upsert update: " + updated);
// --- insert: create-only with lost-update prevention ---
// Returns true on success, false if the key already exists.
// The existing row is never touched. In concurrent systems, insert
// prevents one thread from silently overwriting data written by another.
boolean inserted = artists.insert((Transaction) null,
new Artist(10002, "Insert Test Artist"));
System.out.println("insert(10002): " + inserted);
// Second insert for the same key: returns false, original row unchanged
boolean insertAgain = artists.insert((Transaction) null,
new Artist(10002, "Duplicate Insert"));
System.out.println("insert(10002) again: " + insertAgain);
// --- replace: update-only, guards against phantom writes ---
// Returns true when the key exists and the row is updated.
// Returns false when the key is missing; no row is created.
// Use replace when updating a record that should already exist:
// a false return means something unexpected happened upstream.
boolean replaced = artists.replace((Transaction) null,
new Artist(10001, "Replaced Artist"));
System.out.println("replace(10001): " + replaced);
boolean replaceGhost = artists.replace((Transaction) null,
new Artist(10099, "Ghost Artist"));
System.out.println("replace(10099 non-existent): " + replaceGhost);
Artist afterReplace = artists.get((Transaction) null, new Artist(10001, null));
System.out.println("After replace: " + afterReplace);
// --- delete: remove by key ---
// Returns true if the key existed and was removed, false if absent
boolean deleted = artists.delete((Transaction) null, new Artist(10002, null));
System.out.println("delete(10002): " + deleted);
// Second delete for the same key: returns false (already removed)
boolean deleteAgain = artists.delete((Transaction) null, new Artist(10002, null));
System.out.println("delete(10002) again: " + deleteAgain);
// --- getAndDelete: atomic read-and-remove in one network round-trip ---
// Returns the complete record as it existed immediately before deletion.
// After this call, get() for the same key returns null.
// Useful for queue-like patterns where you need the data one last time.
artists.insert((Transaction) null, new Artist(10003, "GetAndDelete Artist"));
Artist gotAndDeleted = artists.getAndDelete((Transaction) null,
new Artist(10003, null));
System.out.println("getAndDelete(10003): " + gotAndDeleted);
Artist afterGetAndDelete = artists.get((Transaction) null,
new Artist(10003, null));
System.out.println("get(10003) after getAndDelete: " + afterGetAndDelete);
// Clean up remaining test data
artists.delete((Transaction) null, new Artist(10001, null));
System.out.println("\nCleaned up test data.");
}
// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}
Run the application:
mvn compile exec:java -Dexec.mainClass=com.example.musicstore.RecordViewOps -q
Each step creates a new class and uses the -Dexec.mainClass flag to run it. Your previous step's code remains in the project for reference.
Connected to the cluster.
get(1): Artist{id=1, name='AC/DC'}
After upsert(10001): Artist{id=10001, name='Tutorial Artist'}
After upsert update: Artist{id=10001, name='Updated Tutorial Artist'}
insert(10002): true
insert(10002) again: false
replace(10001): true
replace(10099 non-existent): false
After replace: Artist{id=10001, name='Replaced Artist'}
delete(10002): true
delete(10002) again: false
getAndDelete(10003): Artist{id=10003, name='GetAndDelete Artist'}
get(10003) after getAndDelete: null
Cleaned up test data.
Three distinct write strategies emerged from that output:
upsert always succeeds. It inserts a new row or overwrites an existing one. This is the right choice when you want idempotent writes and don't care whether the record existed before.
insert is the create-only counterpart. It returns true when it creates a new row and false when the key already exists. The existing row is never touched. In concurrent systems, insert prevents one thread from silently overwriting data written by another.
replace is the update-only counterpart. It returns true when it updates an existing row and false when the key is missing. It never creates a new row. Use replace when updating a record that should already exist: a missing row means something unexpected happened.
getAndDelete retrieves and removes a record in a single atomic operation. The returned Artist contains the data as it existed immediately before deletion. After the call, get() for the same key returns null. This is useful for queue-like patterns where you need the data one last time before removing it.
All operations pass (Transaction) null for the transaction parameter. The explicit (Transaction) cast ensures the code compiles across all Ignite 3 and GridGain 9 versions. The null value means each operation runs in its own implicit transaction. Explicit transactions are covered in the next tutorial.
RecordView Bulk Operations
Single-record operations make one network round-trip per call. When you need to process multiple records, bulk operations send all of them in a single request. The cluster handles routing each record to the correct partition internally.
Create BulkOps.java:
package com.example.musicstore;
import com.example.musicstore.model.Artist;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.Transaction;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
public class BulkOps {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);
// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {
System.out.println("Connected to the cluster.");
Table artistTable = client.tables().table("Artist");
RecordView<Artist> artists = artistTable.recordView(Artist.class);
// Bulk operations send all records in one network round-trip.
// The client partitions the batch internally and routes each record
// to the node that owns its partition. Compared to N individual calls,
// a bulk operation is one round-trip instead of N.
//
// Bulk variants:
// upsertAll - batch insert-or-update (idempotent)
// insertAll - batch create-only, returns records whose keys already existed
// getAll - batch read by key, returns null for missing keys
// deleteAll - batch remove, returns records that were not found
// --- upsertAll: write 5 records in a single network round-trip ---
List<Artist> batch = List.of(
new Artist(10001, "Bulk Artist 1"),
new Artist(10002, "Bulk Artist 2"),
new Artist(10003, "Bulk Artist 3"),
new Artist(10004, "Bulk Artist 4"),
new Artist(10005, "Bulk Artist 5"));
artists.upsertAll((Transaction) null, batch);
System.out.println("upsertAll: inserted " + batch.size() + " artists.");
// --- getAll: retrieve multiple records by key in one round-trip ---
// The list includes ArtistId 10099, which does not exist
List<Artist> keys = List.of(
new Artist(10001, null), new Artist(10002, null),
new Artist(10003, null), new Artist(10004, null),
new Artist(10005, null), new Artist(10099, null));
Collection<Artist> results = artists.getAll((Transaction) null, keys);
// getAll returns one entry per input key, including null for missing keys.
// This differs from KeyValueView's getAll, which returns a Map that
// simply omits missing keys. Filter nulls to find the records that exist.
List<Artist> found = results.stream()
.filter(Objects::nonNull)
.toList();
System.out.println("getAll (6 keys, 5 exist): found "
+ found.size() + " records");
for (Artist a : found) {
System.out.println(" " + a);
}
// --- deleteAll: remove multiple records in one round-trip ---
// Returns a collection of records that were NOT deleted (not found).
// An empty return means every key was found and removed.
List<Artist> deleteKeys = List.of(
new Artist(10001, null), new Artist(10002, null),
new Artist(10003, null), new Artist(10004, null),
new Artist(10005, null));
Collection<Artist> notDeleted = artists.deleteAll(
(Transaction) null, deleteKeys);
System.out.println("deleteAll: not-deleted count = " + notDeleted.size());
// Verify: all test artists are gone
Collection<Artist> verify = artists.getAll(
(Transaction) null, deleteKeys);
long remaining = verify.stream()
.filter(Objects::nonNull)
.count();
System.out.println("Verification: " + remaining
+ " test artists remain.");
}
// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}
Run the application:
mvn compile exec:java -Dexec.mainClass=com.example.musicstore.BulkOps -q
Connected to the cluster.
upsertAll: inserted 5 artists.
getAll (6 keys, 5 exist): found 5 records
Artist{id=10001, name='Bulk Artist 1'}
Artist{id=10002, name='Bulk Artist 2'}
Artist{id=10003, name='Bulk Artist 3'}
Artist{id=10004, name='Bulk Artist 4'}
Artist{id=10005, name='Bulk Artist 5'}
deleteAll: not-deleted count = 0
Verification: 0 test artists remain.
Key details in this code:
-
upsertAllwrites all 5 records in a single network request. The cluster routes each record to the node that owns its partition. Compared to 5 individualupsertcalls, this is one round-trip instead of five. -
getAllreturns a collection with one entry per input key. Keys that exist produce their correspondingArtistobject; keys that do not exist producenull. The code filters nulls to count only the records that were found. This behavior differs from KeyValueView'sgetAll, which you see in the next step. -
deleteAllreturns a collection of records that were not deleted (because they did not exist). An empty return collection means every key was found and removed.
KeyValueView Operations
RecordView maps each table row to a complete POJO. KeyValueView offers an alternative: it splits each row into a separate key type and value type, providing a Map-like interface to the same underlying data.
For the Artist table with its two columns (ArtistId and Name), KeyValueView<Integer, String> maps the primary key column to Integer and the single non-key column to String. The table is the same; only the API shape changes.
Create KeyValueViewOps.java:
package com.example.musicstore;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.table.KeyValueView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.Transaction;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
public class KeyValueViewOps {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);
// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {
System.out.println("Connected to the cluster.");
Table artistTable = client.tables().table("Artist");
// KeyValueView splits each row into a key type and a value type.
// For Artist (2 columns): Integer maps to the ARTISTID primary key column,
// String maps to the single non-key column NAME.
// The table is the same as RecordView; only the API shape changes.
KeyValueView<Integer, String> kvView =
artistTable.keyValueView(Integer.class, String.class);
// KeyValueView offers the same four categories as RecordView,
// with Map-like naming:
//
// put - insert or update (equivalent to RecordView.upsert)
// putIfAbsent - create only, prevents concurrent overwrites (equivalent to insert)
// replace - update only, guards against phantom writes
// remove - delete by key (equivalent to delete)
// getAndRemove - atomic read-and-remove (equivalent to getAndDelete)
//
// The behavioral semantics are identical to RecordView counterparts.
// --- get: returns just the value column, not a full POJO ---
// The client routes directly to the partition that owns ArtistId=1
String name = kvView.get((Transaction) null, 1);
System.out.println("get(1): " + name);
// --- put: idempotent insert-or-update (same as RecordView.upsert) ---
kvView.put((Transaction) null, 10001, "KV Artist");
String afterPut = kvView.get((Transaction) null, 10001);
System.out.println("After put(10001): " + afterPut);
// --- putIfAbsent: create-only with lost-update prevention ---
// Returns false if the key already exists; the existing value is untouched.
// Equivalent to RecordView.insert. Prevents concurrent overwrites.
boolean absent = kvView.putIfAbsent((Transaction) null,
10001, "Should Not Overwrite");
System.out.println("putIfAbsent(10001): " + absent);
String afterPutIfAbsent = kvView.get((Transaction) null, 10001);
System.out.println("Value after putIfAbsent: " + afterPutIfAbsent);
// --- replace: update-only (fails if key is missing) ---
boolean replaced = kvView.replace((Transaction) null,
10001, "Replaced KV Artist");
System.out.println("replace(10001): " + replaced);
boolean replaceGhost = kvView.replace((Transaction) null,
10099, "Ghost");
System.out.println("replace(10099 non-existent): " + replaceGhost);
// --- remove: delete by key, returns true if found ---
boolean removed = kvView.remove((Transaction) null, 10001);
System.out.println("remove(10001): " + removed);
// --- Bulk operations: same round-trip optimization as RecordView ---
System.out.println("\n--- Bulk Operations ---");
// putAll: batch write in one round-trip (same as RecordView.upsertAll)
Map<Integer, String> batch = Map.of(
10001, "KV Bulk 1",
10002, "KV Bulk 2",
10003, "KV Bulk 3");
kvView.putAll((Transaction) null, batch);
System.out.println("putAll: stored " + batch.size() + " entries.");
// getAll returns a Map containing only keys that were found.
// Missing keys are simply absent from the result.
// This differs from RecordView's getAll, which returns null entries
// for missing keys. Check Map.containsKey instead of filtering nulls.
Set<Integer> lookupKeys = Set.of(10001, 10002, 10003, 10099);
Map<Integer, String> found = kvView.getAll(
(Transaction) null, lookupKeys);
System.out.println("getAll (4 keys, 3 exist): found "
+ found.size() + " entries");
found.forEach((k, v) -> System.out.println(" " + k + " -> " + v));
// removeAll returns keys that were NOT found (not removed).
// An empty return means every key was found and removed.
Collection<Integer> notRemoved = kvView.removeAll(
(Transaction) null, Set.of(10001, 10002, 10003));
System.out.println("removeAll: not-removed count = "
+ notRemoved.size());
// Final verification: original Music Store data is untouched
String finalCheck = kvView.get((Transaction) null, 1);
System.out.println("\nFinal check - Artist 1: " + finalCheck);
}
// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}
Run the application:
mvn compile exec:java -Dexec.mainClass=com.example.musicstore.KeyValueViewOps -q
Connected to the cluster.
get(1): AC/DC
After put(10001): KV Artist
putIfAbsent(10001): false
Value after putIfAbsent: KV Artist
replace(10001): true
replace(10099 non-existent): false
remove(10001): true
--- Bulk Operations ---
putAll: stored 3 entries.
getAll (4 keys, 3 exist): found 3 entries
10001 -> KV Bulk 1
10002 -> KV Bulk 2
10003 -> KV Bulk 3
removeAll: not-removed count = 0
Final check - Artist 1: AC/DC
The KeyValueView operation names differ from RecordView, but the semantics are identical:
| KeyValueView | RecordView equivalent | Behavior |
|---|---|---|
put | upsert | Insert or update (idempotent) |
putIfAbsent | insert | Create only, returns false if key exists |
replace | replace | Update only, returns false if key missing |
remove | delete | Remove by key, returns true if found |
putAll | upsertAll | Batch insert or update |
getAll | getAll | Batch read by keys |
removeAll | deleteAll | Batch remove by keys |
Notice how get(1) returned the string "AC/DC" directly, not an Artist POJO. KeyValueView strips away the object wrapper and gives you just the value column.
One behavioral difference to note between the two getAll calls:
KeyValueView.getAllreturns aMapcontaining only the keys that were found. The missing key (10099) is absent from the result.RecordView.getAllreturns a collection that includesnullentries for missing keys, in the same order as the input keys.
To detect missing records, filter nulls from the RecordView collection or call Map.containsKey on the KeyValueView result.
Both Views, Same Data
The previous steps used RecordView and KeyValueView in separate applications. In practice, you use both views in the same application, sometimes on the same table within the same method. A write through one view is immediately visible through the other because both are wrappers over the same distributed storage.
Create CrossView.java:
package com.example.musicstore;
import com.example.musicstore.model.Artist;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.table.KeyValueView;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.Transaction;
import java.util.logging.Level;
import java.util.logging.Logger;
public class CrossView {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);
// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {
System.out.println("Connected to the cluster.");
Table artistTable = client.tables().table("Artist");
// Both views are client-side wrappers over the same distributed storage.
// recordView() and keyValueView() do not create separate data structures;
// they provide different API shapes for the same underlying table rows.
RecordView<Artist> recordView = artistTable.recordView(Artist.class);
KeyValueView<Integer, String> kvView =
artistTable.keyValueView(Integer.class, String.class);
// Write through RecordView, read through KeyValueView:
// the upsert writes to distributed storage, and kvView.get reads
// from the same storage. There is no replication lag or cache sync.
recordView.upsert((Transaction) null,
new Artist(10001, "Cross-View Artist"));
String viaKv = kvView.get((Transaction) null, 10001);
System.out.println("Written via RecordView, read via KeyValueView: "
+ viaKv);
// Write through KeyValueView, read through RecordView:
// the put writes to the same storage that RecordView reads from.
// A service can use RecordView for writes (full POJO is natural)
// and KeyValueView for reads (only the value column needed).
kvView.put((Transaction) null, 10001, "Updated via KV");
Artist viaRecord = recordView.get((Transaction) null,
new Artist(10001, null));
System.out.println("Written via KeyValueView, read via RecordView: "
+ viaRecord);
// Clean up test data
recordView.delete((Transaction) null, new Artist(10001, null));
// Verify original Music Store data is untouched
String acdc = kvView.get((Transaction) null, 1);
System.out.println("Artist 1 still intact: " + acdc);
}
// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}
Run the application:
mvn compile exec:java -Dexec.mainClass=com.example.musicstore.CrossView -q
Connected to the cluster.
Written via RecordView, read via KeyValueView: Cross-View Artist
Written via KeyValueView, read via RecordView: Artist{id=10001, name='Updated via KV'}
Artist 1 still intact: AC/DC
Both views return a reference to the same table from client.tables().table("Artist"). The recordView() and keyValueView() calls create client-side wrappers over the same underlying data. A row upserted through RecordView is immediately readable through KeyValueView, and a value written through KeyValueView appears in the next RecordView read.
This has a practical consequence: you can use RecordView for writes (where the full POJO is natural) and KeyValueView for reads (where you only need one column), mixing both in the same service without synchronization.
Multi-Column Tables with Partial POJOs
The Artist table has only two columns, so KeyValueView<Integer, String> maps one-to-one. Real applications have tables with many columns. The Customer table has 13 columns, including address, phone, email, and support representative fields. This is where the two views diverge meaningfully.
Just as you created Artist.java in the model package in Step 1, add two new model classes for the Customer table. The first maps all 13 columns; the second maps only 5 contact-related columns.
Create model/Customer.java:
package com.example.musicstore.model;
/**
* Full domain model for the Music Store Customer table (13 columns).
*
* Ignite maps all 13 fields to their corresponding table columns by name:
* customerId -> CUSTOMERID (primary key, INT32)
* firstName -> FIRSTNAME (STRING)
* lastName -> LASTNAME (STRING)
* company -> COMPANY (STRING, nullable)
* address -> ADDRESS (STRING)
* city -> CITY (STRING)
* state -> STATE (STRING, nullable)
* country -> COUNTRY (STRING)
* postalCode -> POSTALCODE (STRING, nullable)
* phone -> PHONE (STRING, nullable)
* fax -> FAX (STRING, nullable)
* email -> EMAIL (STRING)
* supportRepId -> SUPPORTREPID (INT32)
*
* Use with RecordView to read and write complete customer records.
* For services that only need a subset of columns, see
* {@link CustomerContact} which maps only 5 contact-related fields.
*/
public class Customer {
private Integer customerId;
private String firstName;
private String lastName;
private String company;
private String address;
private String city;
private String state;
private String country;
private String postalCode;
private String phone;
private String fax;
private String email;
private Integer supportRepId;
/** Required: Ignite calls this when deserializing rows from the cluster. */
public Customer() {}
/**
* Key-only constructor for lookups by primary key.
* Non-key fields are left null; Ignite fills them from storage.
*/
public Customer(Integer customerId) {
this.customerId = customerId;
}
public Integer getCustomerId() { return customerId; }
public void setCustomerId(Integer id) { this.customerId = id; }
public String getFirstName() { return firstName; }
public void setFirstName(String v) { this.firstName = v; }
public String getLastName() { return lastName; }
public void setLastName(String v) { this.lastName = v; }
public String getCompany() { return company; }
public void setCompany(String v) { this.company = v; }
public String getAddress() { return address; }
public void setAddress(String v) { this.address = v; }
public String getCity() { return city; }
public void setCity(String v) { this.city = v; }
public String getState() { return state; }
public void setState(String v) { this.state = v; }
public String getCountry() { return country; }
public void setCountry(String v) { this.country = v; }
public String getPostalCode() { return postalCode; }
public void setPostalCode(String v) { this.postalCode = v; }
public String getPhone() { return phone; }
public void setPhone(String v) { this.phone = v; }
public String getFax() { return fax; }
public void setFax(String v) { this.fax = v; }
public String getEmail() { return email; }
public void setEmail(String v) { this.email = v; }
public Integer getSupportRepId() { return supportRepId; }
public void setSupportRepId(Integer v) { this.supportRepId = v; }
@Override
public String toString() {
return "Customer{id=" + customerId
+ ", name='" + firstName + " " + lastName + "'"
+ ", email='" + email + "'"
+ ", city='" + city + "'"
+ ", country='" + country + "'}";
}
}
Create model/CustomerContact.java for the partial 5-column mapping:
package com.example.musicstore.model;
/**
* Partial value POJO: maps only 5 of the 12 non-key Customer columns.
*
* Ignite matches fields to table columns by name. Columns not represented
* in this POJO are silently ignored on reads and left unchanged on writes:
*
* Mapped (5): firstName, lastName, email, city, country
* Unmapped (7): company, address, state, postalCode, phone, fax, supportRepId
*
* Use with KeyValueView<Integer, CustomerContact> to read only the
* columns your code path needs. The Integer key type maps to the CUSTOMERID
* primary key column. The smaller value object reduces memory per record and
* makes the code's intent explicit: this service path cares about contact
* data, not the full customer profile.
*
* This is "partial POJO mapping" - one of the key advantages of KeyValueView
* on wide tables. Different service paths can define different value POJOs
* for the same table, each mapping only the columns they need.
*/
public class CustomerContact {
private String firstName;
private String lastName;
private String email;
private String city;
private String country;
/** Required: Ignite calls this when deserializing rows from the cluster. */
public CustomerContact() {}
public String getFirstName() { return firstName; }
public void setFirstName(String v) { this.firstName = v; }
public String getLastName() { return lastName; }
public void setLastName(String v) { this.lastName = v; }
public String getEmail() { return email; }
public void setEmail(String v) { this.email = v; }
public String getCity() { return city; }
public void setCity(String v) { this.city = v; }
public String getCountry() { return country; }
public void setCountry(String v) { this.country = v; }
@Override
public String toString() {
return "CustomerContact{name='" + firstName + " " + lastName + "'"
+ ", email='" + email + "'"
+ ", city='" + city + "'"
+ ", country='" + country + "'}";
}
}
Now create PartialPojos.java:
package com.example.musicstore;
import com.example.musicstore.model.Customer;
import com.example.musicstore.model.CustomerContact;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.table.KeyValueView;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.Transaction;
import java.util.logging.Level;
import java.util.logging.Logger;
public class PartialPojos {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);
// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {
System.out.println("Connected to the cluster.");
Table customerTable = client.tables().table("Customer");
// RecordView with full POJO: all 13 columns deserialized into one object.
// The key (customerId) is part of the POJO itself.
RecordView<Customer> recordView =
customerTable.recordView(Customer.class);
Customer cust1 = recordView.get(
(Transaction) null, new Customer(1));
System.out.println("RecordView (full): " + cust1);
// KeyValueView with partial value POJO: the key type (Integer) maps to
// the CUSTOMERID primary key column; the value type (CustomerContact)
// maps to only 5 of the 12 remaining columns. Unmapped columns
// (company, address, state, etc.) are ignored by the client.
KeyValueView<Integer, CustomerContact> kvView =
customerTable.keyValueView(
Integer.class, CustomerContact.class);
CustomerContact contact1 = kvView.get(
(Transaction) null, 1);
System.out.println("KeyValueView (partial): " + contact1);
}
// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}
Run the application:
mvn compile exec:java -Dexec.mainClass=com.example.musicstore.PartialPojos -q
Connected to the cluster.
RecordView (full): Customer{id=1, name='Luís Gonçalves', email='luisg@embraer.com.br', city='São José dos Campos', country='Brazil'}
KeyValueView (partial): CustomerContact{name='Luís Gonçalves', email='luisg@embraer.com.br', city='São José dos Campos', country='Brazil'}
Both views return the same Customer 1 row, but the code structure reveals two different approaches:
-
RecordView uses a single
CustomerPOJO with all 13 fields.customerIdis part of the object. This is natural when your code works with complete customer records. -
KeyValueView separates the key (
Integerfor CustomerId) from the value (CustomerContactPOJO). TheCustomerContactclass maps only 5 of the 12 non-key columns:firstName,lastName,email,city, andcountry. Ignite matches POJO fields to table columns by name. Columns not represented in the POJO (company,address,state,postalCode,phone,fax,supportRepId) are simply not mapped.
This partial mapping is the key advantage of KeyValueView on wide tables. A service that only needs contact information defines a 5-field POJO instead of mapping all 13 columns. The smaller object reduces memory overhead per record and makes the code's intent explicit: this code path cares about contact data, not the full customer record.
Cache-Aside with KeyValueView
You have been using KeyValueView as a table access API. Now look at it from a different angle: KeyValueView is also a cache interface backed by the system of record.
In a traditional architecture, the cache-aside pattern works like this: check the cache (Redis), miss, query the database, write the result to the cache, serve the response. Two systems, two networks, and an invalidation strategy to keep them synchronized. Every write must update both the database and the cache, or stale data creeps in.
With Ignite, the "cache" and the "database" are the same storage:
kvView.get()reads a row from distributed storage by primary key.kvView.put()writes a row to that same distributed storage.- SQL queries read the rows written through either view.
There is no separate layer to synchronize and nothing to invalidate.
Create CacheAside.java:
package com.example.musicstore;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.table.KeyValueView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.Transaction;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
public class CacheAside {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);
// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {
System.out.println("Connected to the cluster.");
Table artistTable = client.tables().table("Artist");
KeyValueView<Integer, String> kvView =
artistTable.keyValueView(Integer.class, String.class);
// --- Cache read: get() reads from the system of record ---
// There is no separate cache to check first. The distributed store
// IS the cache. A null return means the data does not exist anywhere
// in the system, not just "cache miss, try the database next."
String artist1 = kvView.get((Transaction) null, 1);
System.out.println("Cache read - Artist 1: " + artist1);
String missing = kvView.get((Transaction) null, 99999);
System.out.println("Cache read - Artist 99999: " + missing);
// --- Batch warm-up: pre-load hot keys in one round-trip ---
// In a Redis architecture, this would be a startup script that queries
// the database and populates the cache. Here, getAll confirms which
// keys exist and returns their values directly from the system of record.
Set<Integer> hotKeys = Set.of(1, 2, 3, 4, 5);
Map<Integer, String> warmCache = kvView.getAll(
(Transaction) null, hotKeys);
System.out.println("Batch warm-up (" + hotKeys.size()
+ " keys): loaded " + warmCache.size() + " entries");
warmCache.forEach((k, v) ->
System.out.println(" " + k + " -> " + v));
// --- Conditional population: putIfAbsent prevents concurrent overwrites ---
// Two threads racing to populate the same key: the first succeeds,
// the second gets false and the original value is untouched.
// Equivalent to Redis SETNX but with ACID guarantees.
boolean populated = kvView.putIfAbsent(
(Transaction) null, 10001, "Cache Populated Artist");
System.out.println("putIfAbsent(10001): " + populated);
boolean populatedAgain = kvView.putIfAbsent(
(Transaction) null, 10001, "Should Not Overwrite");
System.out.println("putIfAbsent(10001) again: " + populatedAgain);
String afterPopulate = kvView.get((Transaction) null, 10001);
System.out.println("Value after two putIfAbsent calls: "
+ afterPopulate);
// --- Unified storage proof: SQL reads the same data ---
// Data written through KeyValueView is immediately visible to SQL
// because both access the same distributed storage. There is no
// cache-to-database synchronization because there is no separation.
try (var result = client.sql().execute((Transaction) null,
"SELECT ArtistId, Name FROM Artist WHERE ArtistId = 10001")) {
if (result.hasNext()) {
var row = result.next();
System.out.println("SQL reads the same row: ArtistId="
+ row.intValue("ArtistId") + ", Name="
+ row.stringValue("Name"));
}
}
// --- Existence check: contains() avoids deserializing the value ---
// Useful for membership testing (does this session exist? is this
// user active?) without the overhead of fetching the full record.
boolean exists1 = kvView.contains((Transaction) null, 1);
boolean exists99999 = kvView.contains((Transaction) null, 99999);
System.out.println("contains(1): " + exists1);
System.out.println("contains(99999): " + exists99999);
// Clean up test data
kvView.remove((Transaction) null, 10001);
System.out.println("\nCleaned up test data.");
}
// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}
Run the application:
mvn compile exec:java -Dexec.mainClass=com.example.musicstore.CacheAside -q
Connected to the cluster.
Cache read - Artist 1: AC/DC
Cache read - Artist 99999: null
Batch warm-up (5 keys): loaded 5 entries
1 -> AC/DC
2 -> Accept
3 -> Aerosmith
4 -> Alanis Morissette
5 -> Alice In Chains
putIfAbsent(10001): true
putIfAbsent(10001) again: false
Value after two putIfAbsent calls: Cache Populated Artist
SQL reads the same row: ArtistId=10001, Name=Cache Populated Artist
contains(1): true
contains(99999): false
Cleaned up test data.
The SQL query at ArtistId 10001 returned "Cache Populated Artist", the exact value written through putIfAbsent. This is the architectural point: there is no cache-to-database synchronization because there is no separation. The KeyValueView write and the SQL read access the same distributed storage.
Key operations for cache patterns:
-
get()is the cache read.nullmeans the data does not exist in the system, not just in the cache. There is no "cache miss" that requires a fallback to a separate database. -
getAll()is the batch warm-up. Pre-load a set of hot keys at application startup in one network round-trip. In a Redis-based architecture, this would be aMGETfollowed by conditionalSETcalls for misses. Here, the data is already in the system of record. -
putIfAbsent()is the conditional population. It writes only if the key does not exist, which prevents concurrent threads from overwriting each other. This is equivalent to RedisSETNXbut with ACID guarantees and no separate cache layer. -
contains()checks whether a key exists without deserializing the value. This is useful for membership testing (does this session exist? is this user active?) without the overhead of fetching the full record.
Production Caching Patterns
The previous step demonstrated the cache-aside pattern against existing Music Store data. This step shows three production patterns that replace infrastructure you would otherwise build with a separate caching system.
Create CachePatterns.java:
package com.example.musicstore;
import com.example.musicstore.model.Artist;
import org.apache.ignite.client.IgniteClient;
import org.apache.ignite.table.KeyValueView;
import org.apache.ignite.table.RecordView;
import org.apache.ignite.table.Table;
import org.apache.ignite.tx.Transaction;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
public class CachePatterns {
public static void main(String[] args) {
// Suppress Ignite's internal partition-assignment log messages
Logger.getLogger("org.apache.ignite").setLevel(Level.WARNING);
// Connect to all three cluster nodes for partition awareness
try (IgniteClient client = IgniteClient.builder()
.addresses("localhost:10800", "localhost:10801", "localhost:10802")
.build()) {
System.out.println("Connected to the cluster.");
Table artistTable = client.tables().table("Artist");
KeyValueView<Integer, String> kvView =
artistTable.keyValueView(Integer.class, String.class);
RecordView<Artist> recordView = artistTable.recordView(Artist.class);
// --- Pattern 1: Batch warm-up (replaces Redis cache-warming scripts) ---
// Load frequently accessed records at application startup in one
// network round-trip. In a Redis architecture, a warm-up script queries
// the database and writes results to Redis. Here, getAll reads directly
// from the system of record. No separate cache to populate.
Map<Integer, String> startup = kvView.getAll((Transaction) null,
Set.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
System.out.println("Startup warm-up: loaded " + startup.size()
+ " artists in one round-trip");
// --- Pattern 2: Atomic read-and-remove (replaces Redis LPOP) ---
// Process a record exactly once: read it and remove it in a single
// atomic operation. No race condition between the read and the delete.
// In Redis, LPOP on a list provides similar semantics but without
// ACID guarantees or persistence. getAndDelete is fully durable.
recordView.insert((Transaction) null,
new Artist(10001, "Queued Artist"));
Artist dequeued = recordView.getAndDelete((Transaction) null,
new Artist(10001, null));
System.out.println("Dequeued: " + dequeued);
// Confirm removal: get returns null after atomic read-and-remove
Artist afterDequeue = recordView.get((Transaction) null,
new Artist(10001, null));
System.out.println("After dequeue: " + afterDequeue);
// --- Pattern 3: Multi-get batch resolution (replaces Redis MGET) ---
// Resolve a list of IDs to full objects in one round-trip.
// The cluster routes each key to its owning partition internally.
// RecordView.getAll returns full POJOs; KeyValueView.getAll returns
// a Map. Choose based on the shape your code needs.
List<Artist> lookupKeys = List.of(
new Artist(1, null),
new Artist(50, null),
new Artist(100, null),
new Artist(200, null),
new Artist(275, null));
Collection<Artist> resolved = recordView.getAll(
(Transaction) null, lookupKeys);
long found = resolved.stream()
.filter(Objects::nonNull)
.count();
System.out.println("Multi-get (5 IDs): resolved " + found
+ " artists in one round-trip");
resolved.stream()
.filter(Objects::nonNull)
.forEach(a -> System.out.println(" " + a));
// Verify original Music Store data is untouched
String finalCheck = kvView.get((Transaction) null, 1);
System.out.println("\nFinal check - Artist 1: " + finalCheck);
}
// Force JVM exit so Maven does not wait for Ignite's background threads
System.exit(0);
}
}
Run the application:
mvn compile exec:java -Dexec.mainClass=com.example.musicstore.CachePatterns -q
Connected to the cluster.
Startup warm-up: loaded 10 artists in one round-trip
Dequeued: Artist{id=10001, name='Queued Artist'}
After dequeue: null
Multi-get (5 IDs): resolved 5 artists in one round-trip
Artist{id=1, name='AC/DC'}
Artist{id=50, name='Metallica'}
Artist{id=100, name='Lenny Kravitz'}
Artist{id=200, name='The Posies'}
Artist{id=275, name='Philip Glass Ensemble'}
Final check - Artist 1: AC/DC
Each pattern replaces a piece of caching infrastructure:
-
Batch warm-up (
getAll) replaces the startup script that queries your database and populates Redis. The data is already in the distributed store.getAllis a single round-trip that confirms which keys are available and returns their values. -
Atomic read-and-remove (
getAndDelete) replaces RedisLPOPfor queue patterns. The record is read and removed in one atomic operation. No race condition between the read and the delete. -
Multi-get (
getAllon RecordView or KeyValueView) replaces RedisMGET. Resolve a batch of IDs to full objects or key-value pairs in one round-trip. The cluster routes each key to the correct node internally.
In all three patterns, the data is authoritative. There is no stale cache to worry about, no TTL to tune, and no invalidation logic to debug.
GridGain 9 Near-Cache
The patterns in the previous two steps eliminate the need for a separate caching system. Every get() call still makes a network round-trip to the cluster. For hot data that your application reads thousands of times per second, that round-trip adds up.
- Apache Ignite 3
- GridGain 9
Apache Ignite 3 provides the cache-aside patterns you used in the previous steps: get, getAll, putIfAbsent, and contains operate on the distributed system of record with no separate cache to manage. Every read is a network call to the cluster.
For most applications, this is the right model. The network round-trip is measured in low milliseconds on a local cluster and single-digit milliseconds in a production data center. The consistency guarantee (every read returns the current value) is worth the round-trip.
If your access pattern involves reading the same small set of keys at very high frequency (tens of thousands of reads per second on the same data), consider GridGain 9's near-cache feature, which adds client-side caching with configurable TTL on top of this same API.
GridGain 9 adds NearCacheOptions to both RecordView and KeyValueView. The near-cache stores recently accessed records in the client JVM's memory. Repeated reads of the same key serve from local memory without a network round-trip.
import org.apache.ignite.table.NearCacheOptions;
import org.apache.ignite.table.TableViewOptions;
import org.apache.ignite.table.mapper.Mapper;
// Near-cache configuration: store up to 1000 recently accessed entries
// in the client JVM's heap. Entries expire 10 seconds after last access.
// This eliminates network round-trips for hot reads while the cluster
// remains the authoritative system of record.
NearCacheOptions nearCache = NearCacheOptions.builder()
.maxEntries(1000) // LRU eviction when limit reached
.expireAfterAccess(10_000) // TTL in ms, resets on each read
.build();
TableViewOptions options = TableViewOptions.builder()
.nearCacheOptions(nearCache)
.build();
// The options overload requires Mapper.of() wrappers instead of bare
// Class parameters. The view behaves identically to a standard
// KeyValueView; the near-cache is transparent to application code.
KeyValueView<Integer, String> kvView = artistTable.keyValueView(
Mapper.of(Integer.class),
Mapper.of(String.class),
options);
// First get: network round-trip to the cluster, result cached locally
String name = kvView.get((Transaction) null, 1);
// Second get within 10 seconds: served from client JVM memory (no network)
String nameAgain = kvView.get((Transaction) null, 1);
Key configuration options:
maxEntrieslimits the number of cached records. When the limit is reached, the least recently accessed entry is evicted. Default: 0 (no limit).expireAfterAccesssets the TTL in milliseconds after the last read. Entries not accessed within this window are evicted. Default: 5000ms (5 seconds).expireAfterUpdatesets the TTL after a write. Use this when you want writes to refresh the TTL. Default: 5000ms (5 seconds).
The defaults matter: a near-cache created without explicit TTL values expires entries after 5 seconds. The code example above sets expireAfterAccess to 10 seconds to keep hot entries longer.
The near-cache is a read-through cache: when a key is not in the local cache, the get() call fetches from the cluster and populates the cache automatically. Writes through the same view update both the cluster and the local cache.
Near-cache also works on RecordView. The same Mapper.of() pattern applies:
// Near-cache works on RecordView too. Same Mapper.of() pattern applies.
RecordView<Artist> cachedView = artistTable.recordView(
Mapper.of(Artist.class), options);
NearCacheOptions is a GridGain 9 feature available in all editions, including Community Edition. It is not available in Apache Ignite 3. The TableViewOptions overload requires Mapper.of() wrappers instead of bare Class parameters.
Choose the Right View
Both views access the same underlying data. The choice depends on your access pattern, not your data model.
| Scenario | Recommended view | Why |
|---|---|---|
| CRUD on domain objects (all columns) | RecordView | Type-safe POJOs, natural object-oriented code |
| Lookup by key, return a subset of columns | KeyValueView | Partial POJO maps only the columns you need |
| Cache-like patterns (has key? get value) | KeyValueView | Map-like API: get, put, contains |
| Complex records with many columns | RecordView | One POJO maps the full row |
| High-frequency lookups on hot data | KeyValueView + near-cache (GG9) | Client-side cache eliminates network round-trips |
| Batch loading or warm-up | Either (bulk ops) | RecordView upsertAll for full rows; KeyValueView putAll for key-value pairs |
| Queue-like processing | RecordView | getAndDelete provides atomic read-and-remove |
Production applications commonly use both views on the same table. A service might use RecordView for full CRUD operations during data ingestion and KeyValueView for fast lookups in the request path. The views are complementary, not competing.
The operational semantics are identical across both views. An insert through RecordView and a putIfAbsent through KeyValueView enforce the same create-only guarantee. A deleteAll through RecordView and a removeAll through KeyValueView return the same information (keys that were not found). Choose based on the shape of data your code works with: full objects or key-value pairs.
Summary
You exercised the full RecordView and KeyValueView operation set across two Music Store tables: Artist (2 columns) for API mechanics and Customer (13 columns) for partial POJO mapping. Three write strategies (idempotent, create-only, update-only), bulk operations for throughput, and cross-view data sharing established the API foundation.
The caching steps revealed the architectural advantage: KeyValueView provides cache-like semantics (get, put, putIfAbsent, contains, getAll) backed directly by the system of record. There is no separate cache layer to synchronize, no invalidation logic to write, and no stale data to debug. A putIfAbsent through KeyValueView and a SELECT through SQL read from the same distributed storage. GridGain 9's near-cache adds client-side caching with TTL for hot read paths.
All operations in this tutorial used implicit transactions (the null transaction parameter). The next tutorial introduces explicit transactions: grouping multiple operations into an atomic unit so they succeed or fail together. (Coming soon.)
Related
- Start Your Local Ignite 3 Development Cluster for the cluster setup and dataset this tutorial connects to
- Connect with the Java Thin Client for the thin client connection patterns and Table package hierarchy
- How to Configure Logging for the Java Thin Client for controlling log output in your application