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    /** Ignite thin client (shared across all adapter instances). Lazily initialized. */
64    private static volatile IgniteClient sharedClient;
65  
66    /** This adapter's Ignite client. */
67    private final IgniteClient client;
68  
69    /** Key-value view for this cache's table. */
70    private final KeyValueView<Tuple, Tuple> cache;
71  
72    /** Default Ignite 3 thin client port. */
73    static final String DEFAULT_ADDRESSES = "127.0.0.1:10800";
74  
75    /** Ignite client configuration file path. */
76    static final String CFG_PATH = "config/default-config.properties";
77  
78    /** Table key column name. */
79    static final String KEY_COL = "key";
80  
81    /** Table value column name. */
82    static final String VAL_COL = "val";
83  
84    /**
85     * Returns the shared {@link IgniteClient}, creating it lazily on first call.
86     */
87    private static IgniteClient getOrCreateIgniteClient() {
88      if (sharedClient == null) {
89        synchronized (IgniteCacheAdapter.class) {
90          if (sharedClient == null) {
91            sharedClient = createIgniteClient();
92          }
93        }
94      }
95      return sharedClient;
96    }
97  
98    /**
99     * Creates a new {@link IgniteClient} from the configuration file or defaults.
100    */
101   static IgniteClient createIgniteClient() {
102     String addresses = DEFAULT_ADDRESSES;
103     Properties props = new Properties();
104     try (InputStream is = Files.newInputStream(Path.of(CFG_PATH))) {
105       props.load(is);
106       addresses = props.getProperty("ignite.addresses", DEFAULT_ADDRESSES);
107     } catch (IOException e) {
108       log.debug("Ignite config file not found at '" + CFG_PATH + "', using defaults.");
109       log.trace("" + e);
110     }
111     return IgniteClient.builder().addresses(addresses.split(",")).build();
112   }
113 
114   /**
115    * Constructor.
116    *
117    * @param id
118    *          Cache id.
119    */
120   public IgniteCacheAdapter(String id) {
121     this(requireNonNullId(id), getOrCreateIgniteClient());
122   }
123 
124   private static String requireNonNullId(String id) {
125     if (id == null) {
126       throw new IllegalArgumentException("Cache instances require an ID");
127     }
128     return id;
129   }
130 
131   /**
132    * Package-private constructor for testing: allows injection of a mock {@link IgniteClient} without requiring a
133    * running Ignite cluster.
134    *
135    * @param id
136    *          Cache id.
137    * @param igniteClient
138    *          The {@link IgniteClient} to use.
139    */
140   IgniteCacheAdapter(String id, IgniteClient igniteClient) {
141     this.id = requireNonNullId(id);
142     this.tableName = toTableName(id);
143     this.client = igniteClient;
144 
145     igniteClient.catalog().createTable(
146         TableDefinition.builder(tableName).ifNotExists().columns(ColumnDefinition.column(KEY_COL, ColumnType.VARBINARY),
147             ColumnDefinition.column(VAL_COL, ColumnType.VARBINARY)).primaryKey(KEY_COL).build());
148 
149     cache = igniteClient.tables().table(tableName).keyValueView();
150   }
151 
152   @Override
153   public String getId() {
154     return this.id;
155   }
156 
157   @Override
158   public void putObject(Object key, Object value) {
159     cache.put(null, Tuple.create().set(KEY_COL, serialize(key)), Tuple.create().set(VAL_COL, serialize(value)));
160   }
161 
162   @Override
163   public Object getObject(Object key) {
164     Tuple valueTuple = cache.get(null, Tuple.create().set(KEY_COL, serialize(key)));
165     return valueTuple != null ? deserialize(valueTuple.bytesValue(VAL_COL)) : null;
166   }
167 
168   @Override
169   public Object removeObject(Object key) {
170     Tuple valueTuple = cache.getAndRemove(null, Tuple.create().set(KEY_COL, serialize(key)));
171     return valueTuple != null ? deserialize(valueTuple.bytesValue(VAL_COL)) : null;
172   }
173 
174   @Override
175   public void clear() {
176     cache.removeAll(null);
177   }
178 
179   @Override
180   public int getSize() {
181     try (ResultSet<SqlRow> rs = client.sql().execute(null, "SELECT COUNT(*) FROM " + tableName)) {
182       return rs.hasNext() ? (int) rs.next().longValue(0) : 0;
183     }
184   }
185 
186   @Override
187   public ReadWriteLock getReadWriteLock() {
188     return readWriteLock;
189   }
190 
191   static String toTableName(String id) {
192     // Sanitize to alphanumeric and underscore only, ensuring safe use in SQL identifiers.
193     return id.replaceAll("[^a-zA-Z0-9_]", "_").toUpperCase();
194   }
195 
196   static byte[] serialize(Object obj) {
197     try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
198         ObjectOutputStream oos = new ObjectOutputStream(baos)) {
199       oos.writeObject(obj);
200       return baos.toByteArray();
201     } catch (IOException e) {
202       throw new IllegalArgumentException("Cannot serialize object of type " + obj.getClass().getName(), e);
203     }
204   }
205 
206   static Object deserialize(byte[] bytes) {
207     if (bytes == null) {
208       return null;
209     }
210     try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
211         ObjectInputStream ois = new ObjectInputStream(bais)) {
212       return ois.readObject();
213     } catch (IOException | ClassNotFoundException e) {
214       throw new IllegalStateException("Cannot deserialize cache object", e);
215     }
216   }
217 }