org.jsimpledb.kv.spanner.SpannerKVDatabase Maven / Gradle / Ivy
Show all versions of jsimpledb-kv-spanner Show documentation
/*
* Copyright (C) 2015 Archie L. Cobbs. All rights reserved.
*/
package org.jsimpledb.kv.spanner;
import com.google.cloud.WaitForOption;
import com.google.cloud.spanner.Database;
import com.google.cloud.spanner.DatabaseClient;
import com.google.cloud.spanner.DatabaseId;
import com.google.cloud.spanner.ErrorCode;
import com.google.cloud.spanner.Instance;
import com.google.cloud.spanner.InstanceAdminClient;
import com.google.cloud.spanner.Operation;
import com.google.cloud.spanner.Spanner;
import com.google.cloud.spanner.SpannerException;
import com.google.cloud.spanner.SpannerOptions;
import com.google.cloud.spanner.TimestampBound;
import com.google.common.base.Preconditions;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Pattern;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;
import org.jsimpledb.kv.KVDatabase;
import org.jsimpledb.util.MovingAverage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* {@link org.jsimpledb.kv.KVDatabase} implementation based on
* Google Cloud Spanner.
*
* Configuration
*
*
* A {@link SpannerKVDatabase} must be configured with a Spanner instance ID. You can create a Spanner instance
* using the Google Cloud Console or the {@code gcloud}
* command line utility.
*
*
* {@linkplain #setSpannerOptions Configure} a {@link SpannerOptions} to override the default project ID associated
* with your environment. The default database ID ({@value #DEFAULT_DATABASE_ID}) and table name ({@value #DEFAULT_TABLE_NAME})
* may also be overridden.
*
*
Caching
*
*
* Because Spanner has relatively high latency vs. throughput, instances utilize a {@link org.jsimpledb.kv.caching.CachingKVStore}
* for caching and batch loading read-ahead.
*
*
Consistency Levels
*
*
* Transactions may either have strong consistency (the default), or have some amount of staleness.
* Transactions that are not strongly consistent must be read-only.
*
*
* A {@link TimestampBound} may be passed as an option to {@link #createTransaction(Map) createTransaction()}
* under the {@value #OPTION_TIMESTAMP_BOUND} key.
* Transactions that are not strongly consistent must be read-only.
*
*
* In Spring applications, the transaction consistency level may be configured through the Spring
* {@link org.jsimpledb.spring.JSimpleDBTransactionManager} by (ab)using the transaction isolation level setting,
* for example, via the {@link org.springframework.transaction.annotation.Transactional @Transactional} annotation's
* {@link org.springframework.transaction.annotation.Transactional#isolation isolation()} property:
*
*
*
*
* Spring isolation level
* {@link SpannerKVDatabase} consistency level
*
*
* {@link org.springframework.transaction.annotation.Isolation#DEFAULT DEFAULT}
* Strong consistency
*
*
* {@link org.springframework.transaction.annotation.Isolation#SERIALIZABLE SERIALIZABLE}
* Strong consistency
*
*
* {@link org.springframework.transaction.annotation.Isolation#READ_COMMITTED READ_COMMITTED}
* Exact staleness of ten seconds
*
*
* {@link org.springframework.transaction.annotation.Isolation#REPEATABLE_READ REPEATABLE_READ}
* Exact staleness of three seconds
*
*
* {@link org.springframework.transaction.annotation.Isolation#READ_UNCOMMITTED READ_UNCOMMITTED}
* N/A
*
*
*
*
* Key Watches
*
*
* {@linkplain org.jsimpledb.kv.KVTransaction#watchKey Key watches} are not supported.
*
* @see Google Cloud Spanner
*/
@ThreadSafe
public class SpannerKVDatabase implements KVDatabase {
/**
* Option key for {@link #createTransaction(Map)}. Value should be a {@link TimestampBound} instance.
*/
public static final String OPTION_TIMESTAMP_BOUND = "TimestampBound";
/**
* Default database ID: {@value #DEFAULT_DATABASE_ID}.
*/
public static final String DEFAULT_DATABASE_ID = "jsimpledb";
/**
* Default table name: {@value #DEFAULT_TABLE_NAME}.
*/
public static final String DEFAULT_TABLE_NAME = "KV";
/**
* Default background task thread pool size.
*/
public static final int DEFAULT_THREAD_POOL_SIZE = 10;
// Patterns for validation
private static final String INSTANCE_ID_PATTERN = "[a-z][-_A-Za-z0-9]*[a-z0-9]";
private static final String DATABASE_ID_PATTERN = "[a-z][-_A-Za-z0-9]*[a-z0-9]";
private static final String TABLE_NAME_PATTERN = "[A-Za-z][_A-Za-z0-9]*";
// RTT
private static final int INITIAL_RTT_ESTIMATE_MILLIS = 50; // 50 ms
private static final double RTT_ESTIMATE_DECAY_FACTOR = 0.025;
private static final AtomicInteger THREAD_COUNTER = new AtomicInteger();
protected final Logger log = LoggerFactory.getLogger(this.getClass());
@GuardedBy("this")
private Spanner spanner;
@GuardedBy("this")
private DatabaseClient client;
@GuardedBy("this")
private ExecutorService executor;
@GuardedBy("this")
private SpannerOptions spannerOptions;
@GuardedBy("this")
private String instanceId;
@GuardedBy("this")
private String databaseId = DEFAULT_DATABASE_ID;
@GuardedBy("this")
private String tableName = DEFAULT_TABLE_NAME;
@GuardedBy("this")
private int threadPoolSize = DEFAULT_THREAD_POOL_SIZE;
@GuardedBy("this")
private MovingAverage rtt;
/**
* Configure {@link SpannerOptions}.
*
* @param spannerOptions Google spanner configuration options
* @throws IllegalStateException if this instance is {@linkplain #start already started}
* @throws IllegalArgumentException if {@code spannerOptions} is null
*/
public synchronized void setSpannerOptions(SpannerOptions spannerOptions) {
Preconditions.checkArgument(spannerOptions != null, "null spannerOptions");
Preconditions.checkState(this.client == null, "already started");
this.spannerOptions = spannerOptions;
}
/**
* Set Spanner instance ID.
*
*
* Required property.
*
* @param instanceId Spanner instance ID
* @throws IllegalStateException if this instance is {@linkplain #start already started}
* @throws IllegalArgumentException if {@code instanceId} is null
*/
public synchronized void setInstanceId(String instanceId) {
Preconditions.checkArgument(instanceId != null, "null instanceId");
Preconditions.checkArgument(Pattern.compile(INSTANCE_ID_PATTERN).matcher(instanceId).matches(), "invalid instanceId");
Preconditions.checkState(this.client == null, "already started");
this.instanceId = instanceId;
}
/**
* Set Spanner database ID.
*
*
* Default is {@value #DEFAULT_DATABASE_ID}.
*
* @param databaseId Spanner instance ID
* @throws IllegalStateException if this instance is {@linkplain #start already started}
* @throws IllegalArgumentException if {@code databaseId} is null
*/
public synchronized void setDatabaseId(String databaseId) {
Preconditions.checkArgument(databaseId != null, "null databaseId");
Preconditions.checkArgument(Pattern.compile(DATABASE_ID_PATTERN).matcher(databaseId).matches(), "invalid databaseId");
Preconditions.checkState(this.client == null, "already started");
this.databaseId = databaseId;
}
/**
* Set Spanner table name.
*
*
* Default is {@value #DEFAULT_TABLE_NAME}.
*
* @param tableName Spanner database table name
* @throws IllegalStateException if this instance is {@linkplain #start already started}
* @throws IllegalArgumentException if {@code tableName} is invalid
* @throws IllegalArgumentException if {@code tableName} is null
*/
public synchronized void setTableName(String tableName) {
Preconditions.checkArgument(tableName != null, "null tableName");
Preconditions.checkArgument(Pattern.compile(TABLE_NAME_PATTERN).matcher(tableName).matches(), "invalid tableName");
Preconditions.checkState(this.client == null, "already started");
this.tableName = tableName;
}
// Lifecycle
/**
* Start this instance.
*
*
* The configured Spanner instance, database, and table will be created automatically as needed.
*/
@Override
@PostConstruct
public synchronized void start() {
// Sanity check
if (this.spanner != null)
return;
Preconditions.checkState(this.instanceId != null, "no instance ID configured");
// Use default options if none provided
if (this.spannerOptions == null)
this.spannerOptions = SpannerOptions.newBuilder().build();
// Create Spanner access object
this.spanner = this.spannerOptions.getService();
boolean success = false;
try {
// Setup instance if needed
final Instance instance = this.setupInstance(this.spanner.getInstanceAdminClient());
// Setup database if needed
final Database database = this.setupDatabase(instance);
// Setup database table
this.setupTable(database);
// Get client
final DatabaseId did = DatabaseId.of(instance.getId(), this.databaseId);
this.client = this.spanner.getDatabaseClient(did);
// Create thread pool
this.executor = Executors.newFixedThreadPool(this.threadPoolSize, r -> {
final Thread thread = new Thread(r);
thread.setName(this.getClass().getSimpleName() + "-" + THREAD_COUNTER.incrementAndGet());
return thread;
});
// Initialize RTT
this.rtt = new MovingAverage(RTT_ESTIMATE_DECAY_FACTOR, INITIAL_RTT_ESTIMATE_MILLIS);
// Done
success = true;
} finally {
if (!success)
this.cleanup();
}
}
@Override
@PreDestroy
public synchronized void stop() {
// Already stopped?
if (this.spanner == null)
return;
// Cleanup
this.cleanup();
}
private synchronized void cleanup() {
if (this.spanner != null) {
this.spanner.closeAsync();
this.spanner = null;
}
if (this.executor != null) {
this.executor.shutdownNow();
try {
this.executor.awaitTermination(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
this.executor = null;
}
this.client = null;
}
// Accessors
/**
* Get the number of threads in the background task thread pool.
*
*
* Default value is {@value #DEFAULT_THREAD_POOL_SIZE}.
*
* @return number of threads in thread pool
*/
public synchronized int getThreadPoolSize() {
return this.threadPoolSize;
}
/**
* Set the number of threads in the background task thread pool.
*
*
* Default value is {@value #DEFAULT_THREAD_POOL_SIZE}.
*
* @param threadPoolSize number of threads in thread pool
* @throws IllegalStateException if this instance is already started
* @throws IllegalArgumentException if {@code threadPoolSize <= 0}
*/
public synchronized void setThreadPoolSize(int threadPoolSize) {
Preconditions.checkArgument(threadPoolSize > 0, "threadPoolSize <= 0");
Preconditions.checkState(this.spanner == null, "already started");
this.threadPoolSize = threadPoolSize;
}
// RTT estimate
/**
* Get the current round trip time estimate.
*
* @return current RTT estimate in nanoseconds
* @throws IllegalStateException if this instance has never {@link #start}ed
*/
public synchronized double getRttEstimate() {
Preconditions.checkState(this.rtt != null, "instance has never started");
return this.rtt.get();
}
synchronized void updateRttEstimate(double rtt) {
this.rtt.add(rtt);
}
// Setup
private Instance setupInstance(InstanceAdminClient instanceAdminClient) {
this.log.debug("finding spanner instance with ID \"" + this.instanceId + "\"");
final Instance instance = instanceAdminClient.getInstance(this.instanceId);
this.log.debug("found spanner instance with ID \"" + this.instanceId + "\"");
return instance;
}
private Database setupDatabase(Instance instance) {
// Does database already exist?
this.log.debug("finding spanner database with ID \"" + this.databaseId + "\"");
try {
final Database database = instance.getDatabase(this.databaseId);
this.log.debug("found spanner database with ID \"" + this.databaseId + "\"");
return database;
} catch (SpannerException e) {
if (!ErrorCode.NOT_FOUND.equals(e.getErrorCode()))
throw e;
this.log.debug("spanner database with ID \"" + this.databaseId + "\" not found");
}
// Create new database
this.log.info("creating new spanner database with ID \"" + this.instanceId + "\"");
return this.waitFor(instance.createDatabase(this.databaseId, Collections.singleton(this.getCreateTableDDL())));
}
private void setupTable(Database database) {
// Does table already exist?
this.log.debug("finding key/value database table with name \"" + this.tableName + "\"");
final String expectedDDL = this.normalizeDDL(this.getCreateTableDDL());
for (String statement : database.getDdl()) {
if (this.normalizeDDL(statement).equals(expectedDDL)) {
this.log.debug("found key/value database table with name \"" + this.tableName + "\"");
return;
}
}
this.log.debug("key/value database table with name \"" + this.tableName + "\" not found");
// Create new table
final String ddl = this.getCreateTableDDL();
this.log.info("creating new key/value database table with name \"" + this.tableName + "\":\n" + ddl);
this.waitFor(database.updateDdl(Collections.singleton(ddl), null));
}
private String getCreateTableDDL() {
return "CREATE TABLE " + this.tableName + " (\n"
+ " key BYTES(MAX) NOT NULL,\n"
+ " val BYTES(MAX) NOT NULL,\n"
+ ") PRIMARY KEY(key)";
}
private String normalizeDDL(String ddl) {
return ddl.trim()
.replaceAll("([^-_A-Za-z0-9])\\s+([^-_A-Za-z0-9])", "$1$2")
.replaceAll("\\s+", " ")
.toLowerCase();
}
private T waitFor(Operation operation) {
return operation.waitFor(WaitForOption.checkEvery(500, TimeUnit.MILLISECONDS)).getResult();
}
// Transactions
@Override
public SpannerKVTransaction createTransaction() {
return this.createTransaction((Map)null);
}
@Override
public SpannerKVTransaction createTransaction(Map options) {
// Get default consistency
TimestampBound consistency = TimestampBound.strong();
// Any options?
if (options != null) {
// Look for options from the JSimpleDBTransactionManager
Object isolation = options.get("org.springframework.transaction.annotation.Isolation");
if (isolation instanceof Enum)
isolation = ((Enum>)isolation).name();
if (isolation != null) {
switch (isolation.toString()) {
case "READ_COMMITTED":
consistency = TimestampBound.ofExactStaleness(10, TimeUnit.SECONDS);
break;
case "REPEATABLE_READ":
consistency = TimestampBound.ofExactStaleness(3, TimeUnit.SECONDS);
break;
case "SERIALIZABLE":
consistency = TimestampBound.strong();
break;
default:
break;
}
}
// Look for OPTION_TIMESTAMP_BOUND option
try {
final Object value = options.get(OPTION_TIMESTAMP_BOUND);
if (value instanceof TimestampBound)
consistency = (TimestampBound)value;
} catch (Exception e) {
// ignore
}
}
// Configure consistency level
return this.createTransaction(consistency);
}
protected synchronized SpannerKVTransaction createTransaction(TimestampBound consistency) {
// Sanity check
Preconditions.checkState(this.spanner != null, "instance is not started");
// Create transaction
return new SpannerKVTransaction(this, this.client, this.tableName, consistency);
}
protected synchronized ExecutorService getExecutorService() {
return this.executor;
}
// Snapshots
/**
* Create a read-only snapshot of the database with the given timestamp bound.
*
* @param consistency consistency for the snapshot
* @return read-only view of database
* @throws IllegalArgumentException if {@code consistency} is null
* @throws IllegalStateException if this instance is not {@link #start}ed
*/
public synchronized ReadOnlySpannerView snapshot(TimestampBound consistency) {
Preconditions.checkState(this.spanner != null, "instance is not started");
return new ReadOnlySpannerView(this.tableName, this.client.readOnlyTransaction(consistency));
}
}