View Javadoc
1   /*
2    *    Copyright 2016-2026 the original author or authors.
3    *
4    *    Licensed under the Apache License, Version 2.0 (the "License");
5    *    you may not use this file except in compliance with the License.
6    *    You may obtain a copy of the License at
7    *
8    *       https://www.apache.org/licenses/LICENSE-2.0
9    *
10   *    Unless required by applicable law or agreed to in writing, software
11   *    distributed under the License is distributed on an "AS IS" BASIS,
12   *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   *    See the License for the specific language governing permissions and
14   *    limitations under the License.
15   */
16  package org.mybatis.caches.ignite;
17  
18  import java.io.ByteArrayInputStream;
19  import java.io.ByteArrayOutputStream;
20  import java.io.IOException;
21  import java.io.InputStream;
22  import java.io.ObjectInputStream;
23  import java.io.ObjectOutputStream;
24  import java.nio.file.Files;
25  import java.nio.file.Path;
26  import java.util.Properties;
27  import java.util.concurrent.locks.ReadWriteLock;
28  
29  import org.apache.ibatis.cache.Cache;
30  import org.apache.ibatis.logging.Log;
31  import org.apache.ibatis.logging.LogFactory;
32  import org.apache.ignite.catalog.ColumnType;
33  import org.apache.ignite.catalog.definitions.ColumnDefinition;
34  import org.apache.ignite.catalog.definitions.TableDefinition;
35  import org.apache.ignite.client.IgniteClient;
36  import org.apache.ignite.sql.ResultSet;
37  import org.apache.ignite.sql.SqlRow;
38  import org.apache.ignite.table.KeyValueView;
39  import org.apache.ignite.table.Tuple;
40  
41  /**
42   * Cache adapter for Ignite 3. Connects to a running Ignite 3 cluster via thin client. The server address is read from
43   * {@value #CFG_PATH} (property {@code ignite.addresses}), otherwise the default {@value #DEFAULT_ADDRESSES} is used.
44   *
45   * @author Roman Shtykh
46   */
47  public final class IgniteCacheAdapter implements Cache {
48  
49    /** Logger. */
50    private static final Log log = LogFactory.getLog(IgniteCacheAdapter.class);
51  
52    /** Cache id. */
53    private final String id;
54  
55    /** Table name derived from the cache id. */
56    private final String tableName;
57  
58    /**
59     * {@code ReadWriteLock}.
60     */
61    private final ReadWriteLock readWriteLock = new DummyReadWriteLock();
62  
63    /** This adapter's Ignite client. */
64    private final IgniteClient client;
65  
66    /** Key-value view for this cache's table. */
67    private final KeyValueView<Tuple, Tuple> cache;
68  
69    /** Default Ignite 3 thin client port. */
70    static final String DEFAULT_ADDRESSES = "127.0.0.1:10800";
71  
72    /** Ignite client configuration file path. */
73    static final String CFG_PATH = "config/default-config.properties";
74  
75    /** Table key column name. */
76    static final String KEY_COL = "key";
77  
78    /** Table value column name. */
79    static final String VAL_COL = "val";
80  
81    /** Ignite thin client (shared across all adapter instances). Lazily initialized. */
82    private static class ClientHolder {
83      private static final IgniteClient INSTANCE = createIgniteClient();
84    }
85  
86    /**
87     * Returns the shared {@link IgniteClient}, creating it lazily on first call.
88     */
89    private static IgniteClient getOrCreateIgniteClient() {
90      return ClientHolder.INSTANCE;
91    }
92  
93    /**
94     * Creates a new {@link IgniteClient} from the configuration file or defaults.
95     */
96    static IgniteClient createIgniteClient() {
97      String addresses = DEFAULT_ADDRESSES;
98      Properties props = new Properties();
99      try (InputStream is = Files.newInputStream(Path.of(CFG_PATH))) {
100       props.load(is);
101       addresses = props.getProperty("ignite.addresses", DEFAULT_ADDRESSES);
102     } catch (IOException e) {
103       log.debug("Ignite config file not found at '" + CFG_PATH + "', using defaults.");
104       log.trace("" + e);
105     }
106     return IgniteClient.builder().addresses(addresses.split(",")).build();
107   }
108 
109   /**
110    * Constructor.
111    *
112    * @param id
113    *          Cache id.
114    */
115   public IgniteCacheAdapter(String id) {
116     this(requireNonNullId(id), getOrCreateIgniteClient());
117   }
118 
119   private static String requireNonNullId(String id) {
120     if (id == null) {
121       throw new IllegalArgumentException("Cache instances require an ID");
122     }
123     return id;
124   }
125 
126   /**
127    * Package-private constructor for testing: allows injection of a mock {@link IgniteClient} without requiring a
128    * running Ignite cluster.
129    *
130    * @param id
131    *          Cache id.
132    * @param igniteClient
133    *          The {@link IgniteClient} to use.
134    */
135   IgniteCacheAdapter(String id, IgniteClient igniteClient) {
136     this.id = requireNonNullId(id);
137     this.tableName = toTableName(id);
138     this.client = igniteClient;
139 
140     igniteClient.catalog().createTable(
141         TableDefinition.builder(tableName).ifNotExists().columns(ColumnDefinition.column(KEY_COL, ColumnType.VARBINARY),
142             ColumnDefinition.column(VAL_COL, ColumnType.VARBINARY)).primaryKey(KEY_COL).build());
143 
144     cache = igniteClient.tables().table(tableName).keyValueView();
145   }
146 
147   @Override
148   public String getId() {
149     return this.id;
150   }
151 
152   @Override
153   public void putObject(Object key, Object value) {
154     cache.put(null, Tuple.create().set(KEY_COL, serialize(key)), Tuple.create().set(VAL_COL, serialize(value)));
155   }
156 
157   @Override
158   public Object getObject(Object key) {
159     Tuple valueTuple = cache.get(null, Tuple.create().set(KEY_COL, serialize(key)));
160     return valueTuple != null ? deserialize(valueTuple.bytesValue(VAL_COL)) : null;
161   }
162 
163   @Override
164   public Object removeObject(Object key) {
165     Tuple valueTuple = cache.getAndRemove(null, Tuple.create().set(KEY_COL, serialize(key)));
166     return valueTuple != null ? deserialize(valueTuple.bytesValue(VAL_COL)) : null;
167   }
168 
169   @Override
170   public void clear() {
171     cache.removeAll(null);
172   }
173 
174   @Override
175   public int getSize() {
176     try (ResultSet<SqlRow> rs = client.sql().execute(null, "SELECT COUNT(*) FROM " + tableName)) {
177       return rs.hasNext() ? (int) rs.next().longValue(0) : 0;
178     }
179   }
180 
181   @Override
182   public ReadWriteLock getReadWriteLock() {
183     return readWriteLock;
184   }
185 
186   static String toTableName(String id) {
187     // Sanitize to alphanumeric and underscore only, ensuring safe use in SQL identifiers.
188     return id.replaceAll("[^a-zA-Z0-9_]", "_").toUpperCase();
189   }
190 
191   static byte[] serialize(Object obj) {
192     try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
193         ObjectOutputStream oos = new ObjectOutputStream(baos)) {
194       oos.writeObject(obj);
195       return baos.toByteArray();
196     } catch (IOException e) {
197       throw new IllegalArgumentException("Cannot serialize object of type " + obj.getClass().getName(), e);
198     }
199   }
200 
201   static Object deserialize(byte[] bytes) {
202     if (bytes == null) {
203       return null;
204     }
205     try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
206         ObjectInputStream ois = new ObjectInputStream(bais)) {
207       return ois.readObject();
208     } catch (IOException | ClassNotFoundException e) {
209       throw new IllegalStateException("Cannot deserialize cache object", e);
210     }
211   }
212 }