
io.permazen.core.Transaction Maven / Gradle / Ivy
Show all versions of permazen-core Show documentation
/*
* Copyright (C) 2015 Archie L. Cobbs. All rights reserved.
*/
package io.permazen.core;
import com.google.common.base.Preconditions;
import io.permazen.core.util.ObjIdMap;
import io.permazen.core.util.ObjIdSet;
import io.permazen.kv.CloseableKVStore;
import io.permazen.kv.KVDatabase;
import io.permazen.kv.KVPair;
import io.permazen.kv.KVTransaction;
import io.permazen.kv.KVTransactionException;
import io.permazen.kv.KeyRange;
import io.permazen.kv.KeyRanges;
import io.permazen.kv.mvcc.MutableView;
import io.permazen.kv.util.CloseableForwardingKVStore;
import io.permazen.kv.util.MemoryKVStore;
import io.permazen.schema.SchemaId;
import io.permazen.schema.SchemaModel;
import io.permazen.util.ByteReader;
import io.permazen.util.ByteUtil;
import io.permazen.util.ByteWriter;
import io.permazen.util.CloseableIterator;
import io.permazen.util.ImmutableNavigableMap;
import io.permazen.util.NavigableSets;
import io.permazen.util.UnsignedIntEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.NavigableSet;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Stream;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;
import org.dellroad.stuff.util.LongMap;
import org.dellroad.stuff.util.LongSet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* A Permazen {@link Database} transaction.
*
*
* Note: this is the lower level, core API for {@link io.permazen.Permazen}. In most cases this API
* will only be used indirectly through the higher level {@link io.permazen.Permazen}, {@link io.permazen.PermazenTransaction},
* and {@link io.permazen.PermazenObject} APIs.
*
*
* Methods in this class can be divided into the following categories:
*
*
* Transaction Meta-Data
*
* - {@link #getDatabase getDatabase()} - Get the associated {@link Database}
* - {@link #getKVTransaction getKVTransaction()} - Get the underlying key/value store transaction.
* - {@link #getSchema getSchema()} - Get the {@link Schema} that will be used by this transaction
* - {@link #getUserObject} - Get user object associated with this instance
* - {@link #setUserObject setUserObject()} - Set user object associated with this instance
*
*
*
* Transaction Lifecycle
*
* - {@link #commit commit()} - Commit transaction
* - {@link #rollback rollback()} - Roll back transaction
* - {@link #isOpen isOpen()} - Test whether transaction is still open
* - {@link #setTimeout setTimeout()} - Set transaction timeout
* - {@link #setReadOnly setReadOnly()} - Set transaction to read-only
* - {@link #setRollbackOnly setRollbackOnly()} - Set transaction for rollack only
* - {@link #addCallback addCallback()} - Register a {@link Callback} on transaction completion
* - {@link #createDetachedTransaction createDetachedTransaction()} - Create a empty, in-memory transaction
* - {@link #createSnapshotTransaction createSnapshotTransaction()} - Create an in-memory transaction
* pre-populated with a snapshot of this transaction
* - {@link #isDetached} - Determine whether this transaction is a detached transaction
*
*
*
* Schema Management
*
* - {@link #getObjType(ObjId) getObjType()} - Get an object's database object type
* - {@link #migrateSchema migrateSchema()} - Migrate an object's schema to match this transaction's schema
* - {@link #addSchemaChangeListener addSchemaChangeListener()} - Receive notifications about object schema migrations
* - {@link #removeSchemaChangeListener removeSchemaChangeListener()} - Unregister a schema migration listener
* - {@link #getSchemaBundle getSchemaBundle()} - Get all {@link Schema}s registered in the database
* - {@link #addSchema addSchema()} - Register a new {@link Schema} in the database
* - {@link #removeSchema removeSchema()} - Remove a registered {@link Schema} from the database
*
*
*
* Object Lifecycle
*
* - {@link #create(String) create()} - Create a database object
* - {@link #delete delete()} - Delete a database object
* - {@link #copy copy()} - Copy an object into a (possibly different) transaction
* - {@link #addCreateListener addCreateListener()} - Register a {@link CreateListener} for notifications about new objects
* - {@link #removeCreateListener removeCreateListener()} - Unregister a {@link CreateListener}
* - {@link #addDeleteListener addDeleteListener()} - Register a {@link DeleteListener} for notifications
* about deleted objects
* - {@link #removeDeleteListener removeDeleteListener()} - Unregister a {@link DeleteListener}
*
*
*
* Object Queries
*
* - {@link #getAll getAll()} - Get all objects
* - {@link #getAll getAll(String)} - Get all objects of a specific object type
* - {@link #exists exists()} - Test whether a database object exists
*
*
*
* Index Queries
*
* - {@link #querySimpleIndex querySimpleIndex()} - Query an index on a {@link SimpleField}
* or a {@link ComplexField} sub-field
* - {@link #queryListElementIndex queryListElementIndex()}
* - Query an index on a {@link ListField}'s elements, also returning their corresponding list indexes
* - {@link #queryMapValueIndex queryMapValueIndex()}
* - Query an index on a {@link MapField}'s values, also returning their corresponding keys
* - {@link #queryCompositeIndex2 queryCompositeIndex2()} - Query a composite index on two fields
* - {@link #queryCompositeIndex3 queryCompositeIndex3()} - Query a composite index on three fields
* - {@link #queryCompositeIndex3 queryCompositeIndex4()} - Query a composite index on four fields
*
* - {@link #querySchemaIndex querySchemaIndex()} - Query the index that groups objects by their schema
*
*
*
* Field Access
*
* - {@link #readSimpleField readSimpleField()} - Read the value of a {@link SimpleField} in an object
* - {@link #writeSimpleField writeSimpleField()} - Write the value of a {@link SimpleField} in an object
* - {@link #readCounterField readCounterField()} - Read the value of a {@link CounterField} in an object
* - {@link #writeCounterField writeCounterField()} - Write the value of a {@link CounterField} in an object
* - {@link #adjustCounterField adjustCounterField()} - Adjust the value of a {@link CounterField} in an object
* - {@link #readSetField readSetField()} - Access a {@link SetField} in an object as a {@link NavigableSet}
* - {@link #readListField readListField()} - Access a {@link ListField} in an object as a {@link List}
* - {@link #readMapField readMapField()} - Access a {@link MapField} in an object as a {@link NavigableMap}
* - {@link #getKey getKey(ObjId)} - Get the {@link KVDatabase} key prefix corresponding to an object
*
*
*
* Field Change Notifications
*
* - {@link #addSimpleFieldChangeListener addSimpleFieldChangeListener()} - Register a {@link SimpleFieldChangeListener} for
* notifications of changes in a {@link SimpleField}, as seen through a path of object references
* - {@link #addSetFieldChangeListener addSetFieldChangeListener()} - Register a {@link SetFieldChangeListener} for
* notifications of changes in a {@link SetField}, as seen through a path of object references
* - {@link #addListFieldChangeListener addListFieldChangeListener()} - Register a {@link ListFieldChangeListener} for
* notifications of changes in a {@link ListField}, as seen through a path of object references
* - {@link #addMapFieldChangeListener addMapFieldChangeListener()} - Register a {@link MapFieldChangeListener} for
* notifications of changes in a {@link MapField}, as seen through a path of object references
* - {@link #removeSimpleFieldChangeListener removeSimpleFieldChangeListener()} - Unregister a previously registered
* {@link SimpleFieldChangeListener}
* - {@link #removeSetFieldChangeListener removeSetFieldChangeListener()} - Unregister a previously registered
* {@link SetFieldChangeListener}
* - {@link #removeListFieldChangeListener removeListFieldChangeListener()} - Unregister a previously registered
* {@link ListFieldChangeListener}
* - {@link #removeMapFieldChangeListener removeMapFieldChangeListener()} - Unregister a previously registered
* {@link MapFieldChangeListener}
*
*
*
* Reference Paths
*
* - {@link #followReferencePath followReferencePath()} - Find all objects referred to by any element in a given set
* of starting objects through a specified reference path
* - {@link #invertReferencePath invertReferencePath()} - Find all objects that refer to any element in a given set
* of target objects through a specified reference path
*
*
*
* Listener Sets
*
* - {@link #snapshotListeners} - Create an immutable snapshot of all registered listeners
* - {@link #setListeners setListeners()} - Bulk registration of listeners from a previously created snapshot
*
*
*
* All methods returning a set of values return a {@link NavigableSet}.
* The {@link NavigableSets} utility class provides methods for the efficient {@link NavigableSets#intersection intersection},
* {@link NavigableSets#union union}, {@link NavigableSets#difference difference}, and
* {@link NavigableSets#symmetricDifference symmetric difference} of {@link NavigableSet}s containing the same elements and
* ordering, thereby providing the equivalent of traditional database joins.
*
*
* Instances of this class are thread safe.
*/
@ThreadSafe
public class Transaction {
private static final int MAX_GENERATED_KEY_ATTEMPTS
= Integer.parseInt(System.getProperty(Transaction.class.getName() + ".MAX_GENERATED_KEY_ATTEMPTS", "64"));
private static final int MAX_OBJ_INFO_CACHE_ENTRIES
= Integer.parseInt(System.getProperty(Transaction.class.getName() + ".MAX_OBJ_INFO_CACHE_ENTRIES", "1000"));
protected final Logger log = LoggerFactory.getLogger(this.getClass());
// Database
final Database db;
// Underlying transaction
final KVTransaction kvt;
// Schema state
@GuardedBy("this")
Schema schema;
@GuardedBy("this")
SchemaBundle schemaBundle;
// TX state
@GuardedBy("this")
boolean stale;
@GuardedBy("this")
boolean ending;
@GuardedBy("this")
boolean rollbackOnly;
@GuardedBy("this")
boolean disableListenerNotifications;
// Listeners
@GuardedBy("this")
private LongMap> schemaChangeListeners; // grouped by object type storage ID
@GuardedBy("this")
private LongMap> createListeners; // grouped by object type storage ID
@GuardedBy("this")
private LongMap> deleteMonitors; // grouped by object type storage ID
@GuardedBy("this")
private NavigableMap>> fieldMonitors; // grouped by field storage ID
@GuardedBy("this")
private MonitorCache monitorCache; // quick check for monitors; only if ListenerSet installed
// Callbacks
@GuardedBy("this")
private LinkedHashSet callbacks;
// Deletion
@GuardedBy("this")
private ObjIdSet deleteNotified;
// Misc
@GuardedBy("this")
private final ThreadLocal>>> pendingFieldChangeNotifications
= new ThreadLocal<>();
@GuardedBy("this")
private final ObjIdMap objInfoCache = new ObjIdMap<>();
@GuardedBy("this")
private Object userObject;
// Recording of deleted assignments used during a copy() operation (otherwise should be null)
private ObjIdMap deletedAssignments;
// Constructors
Transaction(Database db, KVTransaction kvt, Schema schema) {
assert db != null;
assert kvt != null;
assert schema != null;
this.db = db;
this.kvt = kvt;
this.schema = schema;
this.schemaBundle = schema.getSchemaBundle();
assert this.schema.isEmpty() || this.schema == this.schemaBundle.getSchema(this.schema.getSchemaIndex());
assert this.schema.isEmpty() || this.schema == this.schemaBundle.getSchema(this.schema.getSchemaId());
}
// Transaction Meta-Data
/**
* Get the database with which this transaction is associated.
*
* @return associated database
*/
public synchronized Database getDatabase() {
return this.db;
}
/**
* Get the database's schema bundle
*
*
* This returns all of the schemas currently recorded in the database as seen by this transaction.
*
* @return database schema bundle
*/
public synchronized SchemaBundle getSchemaBundle() {
return this.schemaBundle;
}
/**
* Get the database schema associated with this transaction.
*
*
* This is the target schema for newly created and {@linkplain #migrateSchema migrated} objects.
*
* @return this transaction's schema
*/
public synchronized Schema getSchema() {
return this.schema;
}
/**
* Get the underlying key/value store transaction.
*
*
* Warning: making changes directly to the key/value store directly is not supported.
* If changes are made, future behavior is undefined.
*
* @return the associated key/value transaction
*/
public synchronized KVTransaction getKVTransaction() {
return this.kvt;
}
/**
* Manually record the given non-empty schema in the database.
*
*
* If successful, {@linkplain #getSchemaBundle this transaction's schema bundle} will be updated.
*
*
* This transaction's current schema does not change.
*
*
* This method is dangerous and should normally not be used except by low-level tools.
*
* @return true if the schema was added, false if it was already in the schema bundle
* @throws InvalidSchemaException if {@code schemaModel} is invalid (i.e., does not pass validation checks)
* @throws SchemaMismatchException if {@code schemaModel} has explicit storage ID assignments
* that conflict with storage ID assignments already recorded in the database
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalArgumentException if {@code schemaModel} is empty
* @throws IllegalArgumentException if {@code schemaModel} is null
*/
public boolean addSchema(SchemaModel schemaModel) {
// Sanity check
Preconditions.checkArgument(schemaModel != null, "null schemaModel");
if (!schemaModel.isLockedDown(true)) {
schemaModel = schemaModel.clone();
schemaModel.lockDown(true);
}
schemaModel.validate();
Preconditions.checkArgument(!schemaModel.isEmpty(), "empty schema");
// Add new schema
synchronized (this) {
// Encode new schema bundle with the schema added
final SchemaBundle.Encoded newEncoded = this.schemaBundle.withSchemaAdded(0, schemaModel);
if (newEncoded == null)
return false;
// Update our current bundle
this.updateSchemaBundle(true, newEncoded);
}
// Done
return true;
}
/**
* Manually remove the given schema from the database.
*
*
* If successful, {@linkplain #getSchemaBundle this transaction's schema bundle} will be updated.
*
*
* If the removed schema is also this transaction's current schema, then this transaction's schema reverts to the
* empty schema. The empty schema itself is never recorded in a database, so it will never be found by this method.
*
*
* This method is dangerous and should normally not be used except by low-level tools.
*
* @return true if the schema was removed, false if it was not found
* @throws IllegalArgumentException if any objects in the schema still exist
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalArgumentException if {@code schemaId} is null
*/
public boolean removeSchema(SchemaId schemaId) {
// Sanity check
Preconditions.checkArgument(schemaId != null, "null schemaId");
// Remove schema
synchronized (this) {
// Find the old schema
final Schema oldSchema = this.schemaBundle.getSchemasBySchemaId().get(schemaId);
if (oldSchema == null)
return false;
// Verify no objects in this schema still exist
final int schemaIndex = oldSchema.getSchemaIndex();
if (Layout.getSchemaIndex(this.kvt).asMap().containsKey(schemaIndex))
throw new IllegalArgumentException(String.format("one or more objects in schema \"%s\" still exist", schemaId));
// Encode new schema bundle with the schema removed
final SchemaBundle.Encoded newEncoded = this.schemaBundle.withSchemaRemoved(schemaId);
// Update our current bundle
this.updateSchemaBundle(false, newEncoded);
}
// Done
return true;
}
private synchronized void updateSchemaBundle(boolean added, SchemaBundle.Encoded encoded) {
Preconditions.checkArgument(encoded != null, "null encoded");
// Update the schemas recorded in the database
encoded.writeTo(this.kvt);
// Update this transaction's schema bundle
this.schemaBundle = new SchemaBundle(encoded, this.db.getEncodingRegistry());
// Update this transaction's schema
final SchemaModel currentSchemaModel = this.schema.getSchemaModel();
final SchemaId currentSchemaId = currentSchemaModel.getSchemaId();
final Schema newSchema = this.schemaBundle.getSchemasBySchemaId().get(currentSchemaId);
if (newSchema != null)
this.schema = newSchema;
else if (currentSchemaModel.isEmpty() || !added)
this.schema = new Schema(this.schemaBundle);
else
throw new IllegalArgumentException(String.format("internal error: current schema \"%s\" not found", currentSchemaId));
}
// Transaction Lifecycle
/**
* Commit this transaction.
*
* @throws StaleTransactionException if this transaction is no longer usable
* @throws io.permazen.kv.RetryKVTransactionException from {@link KVTransaction#commit KVTransaction.commit()}
* @throws RollbackOnlyTransactionException if this instance has been {@linkplain #setRollbackOnly marked} rollback only;
* this instance will be automatically rolled back
*/
public synchronized void commit() {
// Sanity check
if (this.stale)
throw new StaleTransactionException(this);
if (this.ending)
throw new StaleTransactionException(this, "commit() invoked re-entrantly from commit callback");
// Rollback only?
if (this.rollbackOnly) {
this.log.debug("commit() invoked on transaction {} marked rollback-only, rolling back", this);
this.rollback();
throw new RollbackOnlyTransactionException(this);
}
this.ending = true;
// Do beforeCommit() and beforeCompletion() callbacks
if (this.log.isTraceEnabled())
this.log.trace("commit() invoked on{} transaction {}", this.isReadOnly() ? " read-only" : "", this);
Callback failedCallback = null;
try {
if (this.callbacks != null) {
for (Callback callback : this.callbacks) {
failedCallback = callback;
if (this.log.isTraceEnabled())
this.log.trace("commit() invoking beforeCommit() on transaction {} callback {}", this, callback);
callback.beforeCommit(this.isReadOnly());
}
failedCallback = null;
}
} finally {
// TX operations no longer permitted
this.stale = true;
// Log the offending callback, if any
if (failedCallback != null) {
this.log.warn("error invoking beforeCommit() method on transaction "
+ this + " callback " + failedCallback + ", rolling back");
}
// Do before completion callback
this.triggerBeforeCompletion();
if (failedCallback != null) {
try {
try {
this.kvt.rollback();
} catch (KVTransactionException e) {
// ignore
}
} finally {
this.triggerAfterCompletion(false);
}
}
}
// Commit KVTransaction and trigger after completion callbacks
try {
this.kvt.commit();
if (this.callbacks != null) {
for (Callback callback : this.callbacks) {
failedCallback = callback;
if (this.log.isTraceEnabled())
this.log.trace("commit() invoking afterCommit() on transaction {} callback {}", this, callback);
callback.afterCommit();
}
failedCallback = null;
}
} finally {
// Log the offending callback, if any
if (failedCallback != null)
this.log.warn("error invoking afterCommit() method on transaction {} callback {}", this, failedCallback);
// Do after completion callback
this.triggerAfterCompletion(true);
}
}
/**
* Roll back this transaction.
*
*
* This method may be invoked at any time, even after a previous invocation of
* {@link #commit} or {@link #rollback}, in which case the invocation will be ignored.
*/
public synchronized void rollback() {
// Sanity check
if (this.stale)
return;
if (this.ending) {
this.log.warn("rollback() invoked re-entrantly from commit callback (ignoring)");
return;
}
this.ending = true;
if (this.log.isTraceEnabled())
this.log.trace("rollback() invoked on{} transaction {}", this.isReadOnly() ? " read-only" : "", this);
// Do before completion callbacks
try {
this.triggerBeforeCompletion();
} finally {
this.stale = true;
}
// Roll back KVTransaction and trigger after completion callbacks
try {
this.kvt.rollback();
} finally {
this.triggerAfterCompletion(false);
}
}
private /*synchronized*/ void triggerBeforeCompletion() {
assert Thread.holdsLock(this);
if (this.callbacks == null)
return;
for (Callback callback : this.callbacks) {
if (this.log.isTraceEnabled())
this.log.trace("invoking beforeCompletion() on transaction {} callback {}", this, callback);
try {
callback.beforeCompletion();
} catch (Throwable t) {
this.log.error("error from beforeCompletion() method of transaction "
+ this + " callback " + callback + " (ignoring)", t);
}
}
}
private /*synchronized*/ void triggerAfterCompletion(boolean committed) {
assert Thread.holdsLock(this);
if (this.callbacks == null)
return;
for (Callback callback : this.callbacks) {
if (this.log.isTraceEnabled())
this.log.trace("invoking afterCompletion() on transaction {} callback {}", this, callback);
try {
callback.afterCompletion(committed);
} catch (Throwable t) {
this.log.error("error from afterCompletion() method of transaction "
+ this + " callback " + callback + " (ignoring)", t);
}
}
}
/**
* Determine whether this transaction is still open.
*
*
* In other words, other methods in this class won't throw {@link StaleTransactionException}.
*
* @return true if this instance is still usable
*/
public synchronized boolean isOpen() {
return !this.stale;
}
/**
* Determine whether this transaction is read-only.
*
*
* This method just invokes {@link KVTransaction#isReadOnly} on the underlying key/value transaction.
*
* @return true if this instance is read-only
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized boolean isReadOnly() {
if (this.stale)
throw new StaleTransactionException(this);
return this.kvt.isReadOnly();
}
/**
* Enable or disable read-only mode.
*
*
* Read-only transactions allow mutations, but all changes are discarded on {@link #commit}.
* Registered {@link Callback}s are still processed normally.
*
*
* This method just invokes {@link KVTransaction#setReadOnly} on the underlying key/value transaction.
*
* @param readOnly read-only setting
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized void setReadOnly(boolean readOnly) {
if (this.stale)
throw new StaleTransactionException(this);
this.kvt.setReadOnly(readOnly);
}
/**
* Determine whether this transaction is marked rollback only.
*
* @return true if this instance is marked for rollback only
*/
public synchronized boolean isRollbackOnly() {
return this.rollbackOnly;
}
/**
* Mark this transaction for rollback only.
*
*
* Once a transaction is marked rollback only, any subsequent {@link #commit} attempt will throw an exception.
*/
public synchronized void setRollbackOnly() {
this.rollbackOnly = true;
}
/**
* Change the timeout for this transaction from its default value (optional operation).
*
*
* This method just invokes {@link KVTransaction#setTimeout} on the underlying key/value transaction.
*
* @param timeout transaction timeout in milliseconds, or zero for unlimited
* @throws UnsupportedOperationException if this transaction does not support timeouts
* @throws IllegalArgumentException if {@code timeout} is negative
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized void setTimeout(long timeout) {
if (this.stale)
throw new StaleTransactionException(this);
this.kvt.setTimeout(timeout);
}
/**
* Register a transaction {@link Callback} to be invoked when this transaction completes.
*
*
* Callbacks will be invoked in the order they are registered, but duplicate registrations are ignored
* (based on comparison via {@link Object#equals}).
*
*
* Note: if you are using Spring for transaction demarcation (via {@link io.permazen.spring.PermazenTransactionManager}),
* then you may also use Spring's
* {@link org.springframework.transaction.support.TransactionSynchronizationManager#registerSynchronization
* TransactionSynchronizationManager.registerSynchronization()} instead of this method.
*
* @param callback callback to invoke
* @throws IllegalArgumentException if {@code callback} is null
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized void addCallback(Callback callback) {
Preconditions.checkArgument(callback != null, "null callback");
if (this.stale || this.ending)
throw new StaleTransactionException(this);
if (this.callbacks == null)
this.callbacks = new LinkedHashSet<>();
this.callbacks.add(callback);
}
/**
* Create an in-memory detached transaction.
*
*
* The detached transaction will be initialized with the same schema meta-data as this instance but will be otherwise empty
* (i.e., contain no objects). It can be used as a destination for in-memory copies of objects made via {@link #copy copy()}.
*
*
* The returned {@link DetachedTransaction} does not support {@link #commit} or {@link #rollback}.
* It can be used indefinitely after this transaction closes, but it must be {@link DetachedTransaction#close close()}'d
* when no longer needed to release any associated resources.
*
* @return empty in-memory detached transaction with compatible schema information
* @see Database#createDetachedTransaction Database.createDetachedTransaction()
*/
public synchronized DetachedTransaction createDetachedTransaction() {
final MemoryKVStore kvstore = new MemoryKVStore();
Layout.copyMetaData(this.kvt, kvstore);
return new DetachedTransaction(this.db, kvstore, this.schema);
}
/**
* Create a detached transaction pre-populated with a snapshot of this transaction.
*
*
* The returned transaction will have the same schema meta-data and object content as this instance.
* It will be a mutable transaction, but being detached, changes can't be committed.
*
*
* This method requires the underlying key/value transaction to support {@link KVTransaction#readOnlySnapshot}.
* As with any other information extracted from this transaction, the returned content is not guaranteed to be
* valid until this transaction has been successfully committed.
*
*
* The returned {@link DetachedTransaction} does not support {@link #commit} or {@link #rollback}.
* It can be used indefinitely after this transaction closes, but it must be {@link DetachedTransaction#close close()}'d
* when no longer needed to release any associated resources.
*
* @return in-memory copy of this transaction
* @throws UnsupportedOperationException if they underlying key/value transaction doesn't support
* {@link KVTransaction#readOnlySnapshot}
*/
public synchronized DetachedTransaction createSnapshotTransaction() {
// Create a snapshot
final CloseableKVStore snapshot = this.kvt.readOnlySnapshot();
// Make it mutable
final MutableView mutableView = new MutableView(snapshot, false);
// Ensure the snapshot is closed when the detached transaction is closed
final CloseableForwardingKVStore kvstore = new CloseableForwardingKVStore(mutableView, snapshot::close);
// Create new transaction
return new DetachedTransaction(this.db, kvstore, this.schema);
}
/**
* Determine whether this instance is a {@link DetachedTransaction}.
*
* @return true if this instance is a {@link DetachedTransaction}, otherwise false
*/
public boolean isDetached() {
return false;
}
/**
* Apply weaker transaction consistency while performing the given action, if supported.
*
*
* Some key/value implementations support reads with weaker consistency guarantees, where reads generate fewer
* transaction conflicts in exchange for returning possibly out-of-date information.
*
*
* Depending on the key/value implementation, in this mode writes may not be supported; instead, they would
* generate a {@link IllegalStateException} or just be ignored.
*
*
* The weaker consistency is only applied for the current thread, and it ends when this method returns.
*
*
* This method is for experts only; inappropriate use can result in a corrupted database.
* In general, after this method returns you should not make any changes to the database that are
* based on any information read by the {@code action}.
*
* @param action the action to perform
* @throws IllegalArgumentException if {@code action} is null
*/
public void withWeakConsistency(Runnable action) {
this.kvt.withWeakConsistency(action);
}
// Object Lifecycle
/**
* Create a new object with the given object ID, if it doesn't already exist.
*
*
* If the object already exists, nothing happens.
*
*
* If the object doesn't already exist, all fields are set to their default values and the object's
* schema is set to the {@linkplain #getSchema() schema associated with this transaction}.
*
* @param id object ID
* @return true if the object did not exist and was created, false if the object already existed
* @throws UnknownTypeException if {@code id} does not correspond to an object type in this transaction's schema
* @throws IllegalArgumentException if {@code id} is null
* @throws StaleTransactionException if this transaction is no longer usable
*/
public boolean create(ObjId id) {
return this.create(id, this.getSchema().getSchemaId());
}
/**
* Create a new object with the given object ID, if it doesn't already exist. If it does exist, nothing happens.
*
*
* If the object doesn't already exist, the object's schema is set to the specified schema and all fields are set
* to their default values.
*
* @param id object ID
* @param schemaId the schema to use for the newly created object
* @return true if the object did not exist and was created, false if the object already existed
* @throws UnknownTypeException if {@code id} does not correspond to a known object type in the specified schema
* @throws InvalidSchemaException if {@code schemaId} is invalid
* @throws IllegalArgumentException if {@code id} or {@code schemaId} is null
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized boolean create(ObjId id, SchemaId schemaId) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
Preconditions.checkArgument(schemaId != null, "null schemaId");
if (this.stale)
throw new StaleTransactionException(this);
// Does object already exist?
if (this.exists(id))
return false;
// Find object type
final Schema objSchema = schemaId.equals(this.schema.getSchemaId()) ? this.schema : this.schemaBundle.getSchema(schemaId);
assert objSchema != null;
final ObjType objType = objSchema.getObjType(id.getStorageId());
// Initialize object
this.createObjectData(id, objSchema, objType);
// Done
return true;
}
/**
* Create a new object with a randomly assigned object ID and having the given type.
*
*
* All fields will be set to their default values.
* The object's schema will be set to {@linkplain #getSchema() the associated with this transaction}.
*
* @param typeName object type name
* @return object id of newly created object
* @throws UnknownTypeException if {@code typeName} does not correspond to a known object type in this transaction's schema
* @throws IllegalArgumentException if {@code typeName} is null
* @throws StaleTransactionException if this transaction is no longer usable
*/
public ObjId create(String typeName) {
return this.create(typeName, this.getSchema().getSchemaId());
}
/**
* Create a new object with a randomly assigned object ID and having the given type and schema.
*
*
* All fields will be set to their default values.
* The object's schema will be set to the specified schema.
*
* @param typeName object type name
* @param schemaId ID of the schema to use for the newly created object
* @return object id of newly created object
* @throws UnknownTypeException if {@code typeName} does not correspond to a known object type in the specified schema
* @throws InvalidSchemaException if {@code schemaId} is invalid
* @throws IllegalArgumentException if {@code id} or {@code schemaId} is null
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized ObjId create(String typeName, SchemaId schemaId) {
// Sanity check
Preconditions.checkArgument(typeName != null, "null typeName");
Preconditions.checkArgument(schemaId != null, "null schemaId");
if (this.stale)
throw new StaleTransactionException(this);
// Find object type
final Schema objSchema = schemaId.equals(this.schema.getSchemaId()) ? this.schema : this.schemaBundle.getSchema(schemaId);
assert objSchema != null;
final ObjType objType = objSchema.getObjType(typeName);
// Generate object ID
final ObjId id = this.generateIdValidated(objType.getStorageId());
// Initialize object
this.createObjectData(id, objSchema, objType);
// Done
return id;
}
/**
* Generate a random, unused {@link ObjId} for the given object type.
*
* @param typeName object type name
* @return random unassigned object id
* @throws UnknownTypeException if {@code typeName} does not correspond to any known object type
* @throws IllegalArgumentException if {@code typeName} is null
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized ObjId generateId(String typeName) {
// Sanity check
Preconditions.checkArgument(typeName != null, "null typeName");
if (this.stale)
throw new StaleTransactionException(this);
// Get storage ID
final Integer storageId = this.schemaBundle.getStorageIdsByTypeName().get(typeName);
if (storageId == null)
throw new UnknownTypeException(typeName, null);
// Generate ID
return this.generateIdValidated(storageId);
}
private /*synchronized*/ ObjId generateIdValidated(int storageId) {
assert Thread.holdsLock(this);
// Create a new, unique key
final ByteWriter keyWriter = new ByteWriter();
for (int attempts = 0; attempts < MAX_GENERATED_KEY_ATTEMPTS; attempts++) {
final ObjId id = new ObjId(storageId);
id.writeTo(keyWriter);
if (this.kvt.get(keyWriter.getBytes()) == null)
return id;
keyWriter.reset(0);
}
// Give up
throw new DatabaseException(String.format(
"could not find a new, unused object ID after %d attempts; is our source of randomness truly random?",
MAX_GENERATED_KEY_ATTEMPTS));
}
/**
* Initialize key/value pairs for a new object. The object must not already exist.
*/
private synchronized void createObjectData(ObjId id, Schema schema, ObjType objType) {
// Sanity check
if (this.stale)
throw new StaleTransactionException(this);
assert this.kvt.get(id.getBytes()) == null;
assert this.objInfoCache.get(id) == null;
// Write object meta-data and update object info cache
this.updateObjInfo(id, schema.getSchemaIndex(), schema, objType);
// Write object schema index entry
this.kvt.put(Layout.buildSchemaIndexKey(id, schema.getSchemaIndex()), ByteUtil.EMPTY);
// Initialize counters to zero
if (!objType.counterFields.isEmpty()) {
for (CounterField field : objType.counterFields.values())
this.kvt.put(field.buildKey(id), this.kvt.encodeCounter(0));
}
// Write simple field index entries
objType.indexedSimpleFields
.forEach(field -> this.kvt.put(Transaction.buildSimpleIndexEntry(field, id, null), ByteUtil.EMPTY));
// Write composite index entries
for (CompositeIndex index : objType.compositeIndexes.values())
this.kvt.put(Transaction.buildDefaultCompositeIndexEntry(id, index), ByteUtil.EMPTY);
// Notify listeners
if (!this.disableListenerNotifications && this.createListeners != null) {
final Set objTypeCreateListeners = this.createListeners.get(objType.storageId);
if (objTypeCreateListeners != null)
new ArrayList<>(objTypeCreateListeners).forEach(listener -> listener.onCreate(this, id));
}
}
/**
* Delete an object. Does nothing if object does not exist (e.g., has already been deleted).
*
*
* This method does not change the object's schema if it is different from this transaction's schema.
*
*
Notifications
*
*
* If the object exists, {@link DeleteListener}'s will be notified synchronously by this method before the object
* is actually deleted. Therefore it's possible for a {@link DeleteListener} to (perhaps indirectly) re-entrantly
* invoke this method with the same {@code id}. In such cases, false is returned matching {@link DeleteListener}s
* are not (redundantly) notified.
*
*
Secondary Deletions
*
*
* Deleting an object can trigger additional automatic secondary deletions. Specifically,
* (a) if the object contains reference fields with {@linkplain ReferenceField#forwardDelete forward delete cascade} enabled,
* any objects referred to through those fields will also be deleted, and (b) if the object is referred to by any other objects
* through fields configured for {@link DeleteAction#DELETE}, those referring objects will be deleted.
*
*
* In any case, deletions occur one at a time, and only after an object is actually deleted do any associated secondary
* deletions take place. However, the order in which secondary deletions occur is unspecified.
* For an example of where this ordering matters, consider an object {@code A} referring to objects
* {@code B} and {@code C} with delete cascading references, where B also refers to C with a {@link DeleteAction#EXCEPTION}
* reference. Then if {@code A} is deleted, it's indeterminate whether a {@link ReferencedObjectException} will be thrown,
* as that depends on whether {@code B} or {@code C} is deleted first (with the answer being, respectively, no and yes).
*
* @param id object ID of the object to delete
* @return true if object was found and deleted, false if object does not exist or this method is
* being invoked re-entrantly with the same {@code id}
* @throws ReferencedObjectException if the object is referenced by some other object
* through a reference field configured for {@link DeleteAction#EXCEPTION}
* @throws IllegalArgumentException if {@code id} is null
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized boolean delete(ObjId id) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
if (this.stale)
throw new StaleTransactionException(this);
// Track in-progess notifications to handle re-entrancy
final boolean topLevel = this.deleteNotified == null;
if (topLevel)
this.deleteNotified = new ObjIdSet();
else if (this.deleteNotified.contains(id)) // we are being invoked re-entrantly for the same ID
return false;
// Find and delete the object and notify listeners
boolean found = false;
try {
final ObjIdSet deletables = new ObjIdSet();
deletables.add(id);
do
found |= this.delete(deletables);
while (!deletables.isEmpty());
} finally {
if (topLevel)
this.deleteNotified = null;
}
// Done
return found;
}
private synchronized boolean delete(ObjIdSet deletables) {
// Get the next deletable object ID
final ObjId id = deletables.removeOne();
// Loop here to handle any mutations within delete notification listener callbacks
ObjInfo info;
while (true) {
// See if object (still) exists
if ((info = this.getObjInfoIfExists(id, false)) == null)
return false;
// Determine if any EXCEPTION reference fields refer to the object (from some other object); if so, throw exception
for (Map.Entry> entry : this.findReferrers(id, DeleteAction.EXCEPTION).entrySet()) {
final int fieldStorageId = entry.getKey();
final NavigableSet referrers = entry.getValue();
for (ObjId referrer : referrers) {
if (!referrer.equals(id)) {
final ReferenceField field = this.schemaBundle.getSchemaItem(fieldStorageId, ReferenceField.class);
throw new ReferencedObjectException(this, id, referrer, field.getName());
}
}
}
// Do we need to issue delete notifications for the object type being deleted?
if (!this.deleteNotified.add(id) || this.disableListenerNotifications)
break;
final int objTypeStorageId = info.getObjType().storageId;
if (this.monitorCache != null && !this.monitorCache.hasDeleteMonitor(objTypeStorageId))
break;
final Set objTypeDeleteMonitors = Optional.ofNullable(this.deleteMonitors)
.map(map -> map.get(objTypeStorageId))
.filter(map -> !map.isEmpty())
.orElse(null);
if (objTypeDeleteMonitors == null)
break;
// Notify delete monitors and retry
this.monitorNotify(new DeleteNotifier(id), NavigableSets.singleton(id), new ArrayList<>(objTypeDeleteMonitors));
}
// Find all objects referred to by a reference field with forwardDelete = true and add them to deletables
for (ReferenceField field : info.getObjType().referenceFieldsAndSubFields.values()) {
if (!field.forwardDelete)
continue;
final Iterable refs = field.parent != null ?
field.parent.iterateSubField(this, id, field) : Collections.singleton(field.getValue(this, id));
for (ObjId ref : refs) {
if (ref != null)
deletables.add(ref);
}
}
// Actually delete the object
this.deleteObjectData(info);
this.deleteNotified.remove(id);
// Find all NULLIFY references and nullify them, and then find all REMOVE references and remove them
for (boolean remove : new boolean[] { false, true }) {
final DeleteAction deleteAction = remove ? DeleteAction.REMOVE : DeleteAction.NULLIFY;
for (Map.Entry> entry : this.findReferrers(id, deleteAction).entrySet()) {
final int fieldStorageId = entry.getKey();
final NavigableSet referrers = entry.getValue();
final ReferenceField field = this.schemaBundle.getSchemaItem(fieldStorageId, ReferenceField.class);
field.getIndex().unreferenceAll(this, remove, id, referrers);
}
}
// Find all DELETE references and mark the containing object for deletion (caller will call us back to actually delete)
this.findReferrers(id, DeleteAction.DELETE).values()
.forEach(deletables::addAll);
// Done
return true;
}
/**
* Delete all of an object's data. The object must exist.
*
*
* This is the opposite of {@link #createObjectData}.
*/
private void deleteObjectData(ObjInfo info) {
// Sanity check
assert Thread.holdsLock(this);
assert this.kvt.get(info.getId().getBytes()) != null;
// Delete object's simple field index entries
final ObjId id = info.getId();
final ObjType type = info.getObjType();
type.indexedSimpleFields
.forEach(field -> this.kvt.remove(Transaction.buildSimpleIndexEntry(field, id, this.kvt.get(field.buildKey(id)))));
// Delete object's composite index entries
for (CompositeIndex index : type.compositeIndexes.values())
this.kvt.remove(this.buildCompositeIndexEntry(id, index));
// Delete object's complex field index entries
for (ComplexField> field : type.complexFields.values())
field.removeIndexEntries(this, id);
// Delete object meta-data and all field content
final byte[] minKey = info.getId().getBytes();
final byte[] maxKey = ByteUtil.getKeyAfterPrefix(minKey);
this.kvt.removeRange(minKey, maxKey);
// Delete object's schema index entry
this.kvt.remove(Layout.buildSchemaIndexKey(id, info.getSchemaIndex()));
// Update ObjInfo cache
this.objInfoCache.remove(id);
}
/**
* Determine if an object exists.
*
*
* This method does not change the object's schema if it exists and is different from this transaction's schema.
*
* @param id object ID of the object to find
* @return true if object was found, false if object was not found, or if {@code id} specifies an unknown object type
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalArgumentException if {@code id} is null
*/
public synchronized boolean exists(ObjId id) {
return this.getObjInfoIfExists(id, false) != null;
}
/**
* Copy an object into a (possibly different) transaction.
*
*
* This copies the object, including all of its field data, to {@code dest}. If the object already exists in {@code dest},
* the existing copy is completely replaced, otherwise it will be created automatically.
*
*
Object Schemas
*
*
* In order to perform the copy, {@code source}'s schema must also already exist in {@code dest}. If it does not,
* a {@code SchemaMismatchException} is thrown.
*
*
* But first, if {@link migrateSchema} is true, {@code source}'s schema is first migrated to match this transaction,
* if needed.
*
*
Notifications
*
*
* {@link CreateListener}s in the destination transaction will be notified if the target object must be created, and
* field change listeners in the destination transaction will be notified for non-trivial changes to the target object's
* fields as each field is copied. These notifications may be disabled by setting {@code notifyListeners} to false.
*
*
* Matching {@link SchemaChangeListener}s in this transaction are notified if/when {@code source} is migrated due
* to {@link migrateSchema} being true.
*
*
Deleted Assignments
*
*
* If a reference field configured to {@linkplain ReferenceField#isAllowDeleted disallow deleted assignments} is copied,
* but the referenced object does not exist in {@code dest}, then a {@link DeletedObjectException} is thrown and no copy
* is performed. However, this presents an impossible chicken-and-egg situation when multiple objects need to be copied
* and there are cycles in the graph of references between objects.
*
*
* To handle that situation, if {@code deletedAssignments} is non-null, then instead of triggering an exception,
* illegal references to deleted objects are collected in {@code deletedAssignments}; each entry maps a deleted object
* to (some) referring field in the copied object. This lets the caller to decide what to do about them.
*
*
Object ID Remapping
*
*
* By default, the {@link ObjId} of {@code source} is also the {@link ObjId} of the target object in {@code dest},
* and all reference fields are copied as-is. The optional {@code objectIdMap} allows the caller to remap these
* {@link ObjId}s arbitrarily, as long as the {@linkplain ObjId#getStorageId implied object types} are the same.
* If {@code objectIdMap} maps an {@link ObjId} to null, then a new, unused {@link ObjId} in {@code dest} will be
* chosen and updated in {@code objectIdMap}.
*
*
Return Value
*
*
* If {@code dest} is this instance, and the {@code source} is not remapped, no fields are changed and false is
* returned, otherwise true is returned. Even if false is returned, a schema migration can still occur
* (if {@code migrateSchema} is true), and deleted assignment checks are still applied.
*
*
Deadlock Avoidance
*
*
* If two threads attempt to copy objects between the same two transactions at the same time but in opposite directions,
* deadlock can result.
*
* @param source object ID of the source object in this transaction
* @param dest destination for the copy of {@code source} (possibly this transaction)
* @param migrateSchema whether to migrate {@code source}'s schema (if necessary) to match this transaction prior to the copy
* @param notifyListeners whether to notify {@link CreateListener}s and field change listeners in {@code dest}
* @param deletedAssignments if not null, where to collect assignments to deleted objects instead of throwing
* {@link DeletedObjectException}s; the map key is the deleted object and the map value is some referring field
* @param objectIdMap if not null, a remapping of object ID's in this transaction to object ID's in {@code dest}
* @return false if the target object already existed in {@code dest}, true if it was newly created
* @throws DeletedObjectException if no object with ID equal to {@code source} exists in this transaction
* @throws DeletedObjectException if {@code deletedAssignments} is null, and a non-null reference field in {@code source}
* that disallows deleted assignments contains a reference to an object that does not exist in {@code dest}
* @throws UnknownTypeException if {@code source} or an {@link ObjId} in {@code objectIdMap} specifies an unknown object type
* @throws IllegalArgumentException if {@code objectIdMap} maps {@code source} to a different object type
* @throws IllegalArgumentException if {@code objectIdMap} maps the value of a reference field to an incompatible object type
* @throws IllegalArgumentException if any parameter is null
* @throws StaleTransactionException if this transaction or {@code dest} is no longer usable
* @throws SchemaMismatchException if {@code source}'s schema does not exist in {@code dest}
* @throws SchemaMismatchException if the object's ID in {@code dest} does not match the assigned storage ID for its type
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
*/
public synchronized boolean copy(ObjId source, final Transaction dest, final boolean migrateSchema,
final boolean notifyListeners, final ObjIdMap deletedAssignments, final ObjIdMap objectIdMap) {
// Sanity check
Preconditions.checkArgument(source != null, "null source");
Preconditions.checkArgument(dest != null, "null dest");
if (this.stale)
throw new StaleTransactionException(this);
// Get source object info, and update schema if requested
final ObjInfo srcInfo = this.getObjInfo(source, migrateSchema);
// Do the copy while both transactions are locked
synchronized (dest) {
// Sanity check
if (dest.stale)
throw new StaleTransactionException(dest);
// Copy fields
return dest.mutateAndNotify(() -> {
final ObjIdMap previousCopyDeletedAssignments = dest.deletedAssignments;
dest.deletedAssignments = deletedAssignments;
final boolean previousDisableListenerNotifications = dest.disableListenerNotifications;
dest.disableListenerNotifications = !notifyListeners;
try {
return Transaction.doCopyFields(srcInfo, Transaction.this, dest, objectIdMap);
} finally {
dest.deletedAssignments = previousCopyDeletedAssignments;
dest.disableListenerNotifications = previousDisableListenerNotifications;
}
});
}
}
// This method assumes both transactions are locked
private static boolean doCopyFields(ObjInfo srcInfo, Transaction srcTx, Transaction dstTx, ObjIdMap objectIdMap) {
// Sanity check
assert Thread.holdsLock(srcTx);
assert Thread.holdsLock(dstTx);
// Get info
final ObjId srcId = srcInfo.getId();
final Schema srcSchema = srcInfo.getSchema();
final SchemaId schemaId = srcSchema.getSchemaId();
final ObjType srcType = srcInfo.getObjType();
final String typeName = srcType.getName();
final SchemaBundle srcSchemaBundle = srcTx.getSchemaBundle();
final SchemaBundle dstSchemaBundle = dstTx.getSchemaBundle();
// Find the same schema in the destination transaction
final Schema dstSchema;
try {
dstSchema = dstSchemaBundle.getSchema(schemaId);
} catch (IllegalArgumentException e) {
throw new SchemaMismatchException(schemaId, String.format("destination transaction has no schema \"%s\"", schemaId));
}
final ObjType dstType = dstSchema.getObjType(typeName);
// Get pre-determined destination object ID, if any
ObjId dstId = objectIdMap != null && objectIdMap.containsKey(srcId) ? objectIdMap.get(srcId) : srcId;
// Verify the destination object ID has the right storage ID
if (dstId != null && dstId.getStorageId() != dstType.getStorageId()) {
throw new SchemaMismatchException(schemaId, String.format(
"can't copy %s to %s because %s has storage ID %d but the storage ID for type \"%s\" in the"
+ " destination transaction is %d", srcId, dstId, dstId, dstId.getStorageId(), typeName, dstType.getStorageId()));
}
// Create destination object ID on-demand if needed, otherwise see if it already exists
ObjInfo dstInfo;
final boolean existed;
if (dstId == null) {
dstId = dstTx.generateIdValidated(dstType.getStorageId());
objectIdMap.put(srcId, dstId);
dstInfo = null;
existed = false;
} else {
dstInfo = dstTx.getObjInfoIfExists(dstId, false);
existed = dstInfo != null;
}
// If destination object already exists and needs schema migration, go through the normal migration process first
if (existed && !dstInfo.getSchemaId().equals(schemaId)) {
dstTx.migrateSchema(dstInfo, dstSchema);
dstInfo = dstTx.loadIntoCache(dstId);
}
// Do field-by-field copy if we have to for various reasons, otherwise do fast direct copy of key/value pairs
if (objectIdMap != null
|| srcSchema.getSchemaIndex() != dstSchema.getSchemaIndex()
|| !dstSchemaBundle.matches(srcSchemaBundle)
|| (!dstTx.disableListenerNotifications && dstTx.hasFieldMonitor(dstType))) {
// Create destination object if it does not exist yet
if (!existed)
dstTx.createObjectData(dstId, dstSchema, dstType);
// Copy fields
for (Field> field : srcType.fields.values())
field.copy(srcId, dstId, srcTx, dstTx, objectIdMap);
} else {
// Check for any deleted reference assignments
for (ReferenceField field : dstType.referenceFieldsAndSubFields.values())
field.findAnyDeletedAssignments(srcTx, dstTx, dstId);
// We can short circuit here if source and target are the same object in the same transaction
if (srcId.equals(dstId) && srcTx == dstTx)
return !existed;
// Nuke previous destination object, if any
if (dstInfo != null)
dstTx.deleteObjectData(dstInfo);
// Copy object meta-data and all field content in one key range sweep
final KeyRange srcKeyRange = KeyRange.forPrefix(srcId.getBytes());
final ByteWriter dstWriter = new ByteWriter();
dstWriter.write(dstId.getBytes());
final int dstMark = dstWriter.mark();
try (CloseableIterator i = srcTx.kvt.getRange(srcKeyRange)) {
while (i.hasNext()) {
final KVPair kv = i.next();
assert srcKeyRange.contains(kv.getKey());
final ByteReader srcReader = new ByteReader(kv.getKey());
srcReader.skip(ObjId.NUM_BYTES);
dstWriter.reset(dstMark);
dstWriter.write(srcReader);
dstTx.kvt.put(dstWriter.getBytes(), kv.getValue());
}
}
// Add schema index entry
dstTx.kvt.put(Layout.buildSchemaIndexKey(dstId, dstSchema.getSchemaIndex()), ByteUtil.EMPTY);
// Create object's simple (non-subfield) field index entries
for (SimpleField> field : dstType.indexedSimpleFields) {
final byte[] fieldValue = dstTx.kvt.get(field.buildKey(dstId)); // can be null (if field has default value)
final byte[] indexKey = Transaction.buildSimpleIndexEntry(field, dstId, fieldValue);
dstTx.kvt.put(indexKey, ByteUtil.EMPTY);
}
// Create object's composite index entries
for (CompositeIndex index : dstType.compositeIndexes.values())
dstTx.kvt.put(Transaction.buildCompositeIndexEntry(dstTx, dstId, index), ByteUtil.EMPTY);
// Create object's complex field index entries
for (ComplexField> field : dstType.complexFields.values()) {
for (SimpleField> subField : field.getSubFields()) {
if (subField.indexed)
field.addIndexEntries(dstTx, dstId, subField);
}
}
}
// Done
return !existed;
}
// CreateListener's
/**
* Add a {@link CreateListener} to this transaction.
*
* @param storageId storage ID of the object type to listen for creation
* @param listener the listener to add
* @throws UnknownTypeException if {@code storageId} specifies an unknown object type
* @throws IllegalArgumentException if {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public synchronized void addCreateListener(int storageId, CreateListener listener) {
this.validateListenerChange(listener);
this.schemaBundle.getSchemaItem(storageId, ObjType.class);
if (this.createListeners == null)
this.createListeners = new LongMap<>();
this.createListeners.computeIfAbsent((long)storageId, i -> new HashSet<>(1)).add(listener);
}
/**
* Remove an {@link CreateListener} from this transaction.
*
* @param storageId storage ID of the object type to listen for creation
* @throws UnknownTypeException if {@code storageId} specifies an unknown object type
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalArgumentException if {@code listener} is null
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public synchronized void removeCreateListener(int storageId, CreateListener listener) {
this.validateListenerChange(listener);
this.schemaBundle.getSchemaItem(storageId, ObjType.class);
this.removeFromMappedSet(this.createListeners, storageId, listener);
}
// DeleteListener's
/**
* Add a {@link DeleteListener} to this transaction.
*
* @param path path of reference fields (represented by storage IDs) through which to monitor for deletion;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on object deletion
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public synchronized void addDeleteListener(int[] path, KeyRanges[] filters, DeleteListener listener) {
this.validateListenerChange(listener, path);
final DeleteMonitor monitor = new DeleteMonitor(path, filters, listener);
if (this.deleteMonitors == null)
this.deleteMonitors = new LongMap<>();
for (int objTypeStorageId : this.schemaBundle.getObjTypeStorageIds()) {
final byte[] objTypeBytes = ObjId.getMin(objTypeStorageId).getBytes();
if (new DeleteMonitorPredicate(objTypeBytes).test(monitor))
this.deleteMonitors.computeIfAbsent((long)objTypeStorageId, i -> new HashSet<>(3)).add(monitor);
}
}
/**
* Remove a {@link DeleteListener} from this transaction.
*
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on object deletion
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public synchronized void removeDeleteListener(int[] path, KeyRanges[] filters, DeleteListener listener) {
this.validateListenerChange(listener, path);
if (this.deleteMonitors != null) {
final DeleteMonitor monitor = new DeleteMonitor(path, filters, listener);
for (int objTypeStorageId : this.schemaBundle.getObjTypeStorageIds()) {
final byte[] objTypeBytes = ObjId.getMin(objTypeStorageId).getBytes();
if (new DeleteMonitorPredicate(objTypeBytes).test(monitor))
this.removeFromMappedSet(this.deleteMonitors, objTypeStorageId, monitor);
}
}
}
private void removeFromMappedSet(LongMap> map, int storageId, M monitor) {
if (map == null)
return;
final Set set = map.get((long)storageId);
if (set != null && set.remove(monitor) && set.isEmpty())
map.remove((long)storageId);
return;
}
// Object Schemas
/**
* Get the given object's {@link ObjType}.
*
* @param id object id
* @return object's object type
* @throws DeletedObjectException if no such object exists
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalArgumentException if {@code id} is null
*/
public synchronized ObjType getObjType(ObjId id) {
// Sanity check
if (this.stale)
throw new StaleTransactionException(this);
Preconditions.checkArgument(id != null, "null id");
// Get object schema
return this.getObjInfo(id, false).getObjType();
}
/**
* Get the object type assigned to the given storage ID.
*
* @param storageId object type storage ID
* @return the corresponding object type name
* @throws UnknownTypeException if {@code storageId} specifies an unknown object type
*/
public synchronized String getTypeName(int storageId) {
final String typeName = this.schemaBundle.getTypeNamesByStorageId().get(storageId);
if (typeName == null)
throw new UnknownTypeException(String.format("storage ID %d", storageId), null);
return typeName;
}
/**
* Migrate the specified object, if necessary, to {@linkplain #getSchema() the schema associated with this transaction}.
*
*
* If a schema change occurs, any matching {@link SchemaChangeListener}s will be notified prior
* to this method returning.
*
* @param id object ID of the object to migrate
* @return true if the object's schema was migrated, false if it's schema already matched
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws IllegalArgumentException if {@code id} is null
* @throws TypeNotInSchemaException if the object's type is not defined in this transaction's schema
*/
public synchronized boolean migrateSchema(ObjId id) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
if (this.stale)
throw new StaleTransactionException(this);
// Get object info
final ObjInfo info = this.getObjInfo(id, false);
if (info.getSchemaIndex() == this.schema.getSchemaIndex())
return false;
// Migrate schema
this.mutateAndNotify(() -> this.migrateSchema(info, this.getSchema()));
// Done
return true;
}
/**
* Migrate object's schema to the specified schema and notify listeners. This assumes we are locked.
*
* @param info original object info
* @param newSchema schema to change to
* @throws TypeNotInSchemaException if {@code newSchema} does not define the object's type
*/
private synchronized void migrateSchema(ObjInfo info, final Schema newSchema) {
// Sanity check
assert Thread.holdsLock(this);
final ObjId id = info.getId();
final Schema oldSchema = info.getSchema();
Preconditions.checkArgument(newSchema != oldSchema, "object already migrated");
// Get old and new types
final ObjType oldType = info.getObjType();
final String typeName = oldType.getName();
final ObjType newType;
try {
newType = newSchema.getObjType(typeName);
} catch (UnknownTypeException e) {
throw (TypeNotInSchemaException)new TypeNotInSchemaException(id, typeName, newSchema).initCause(e);
}
// Are there any matching SchemaChangeListeners registered?
final int objTypeStorageId = oldType.storageId;
assert oldType.storageId == newType.storageId;
final Set listeners = Optional.ofNullable(this.schemaChangeListeners)
.map(map -> map.get((long)objTypeStorageId))
.filter(set -> !set.isEmpty())
.orElse(null);
// If so, we need to remember the removed fields' values so we can provide them to listerners
final NavigableMap oldValueMap = listeners != null ? new TreeMap<>() : null;
//////// Remove the index entries corresponding to removed composite indexes
// Remove index entries for composite indexes that are going away
oldType.compositeIndexes.forEach((name, oldIndex) -> {
final Index newIndex = newType.compositeIndexes.get(name);
if (newIndex == null || !newIndex.getSchemaId().equals(oldIndex.getSchemaId()))
this.kvt.remove(this.buildCompositeIndexEntry(id, oldIndex));
});
//////// Determine Field Compatibility
// Build a mapping from old field -> compatible new field or null if none
final HashMap, Field>> compatibleFieldMap = new HashMap<>(oldType.fields.size());
oldType.fields.forEach((name, oldField) -> {
final Field> newField = newType.fields.get(name);
final boolean compatible = newField != null && newField.getSchemaId().equals(oldField.getSchemaId());
compatibleFieldMap.put(oldField, compatible ? newField : null);
});
// Build a list of the new fields that are not compatible with some old field
final ArrayList> newFieldsToReset = new ArrayList<>(newType.fields.size());
newType.fields.forEach((name, newField) -> {
final Field> oldField = oldType.fields.get(name);
if (oldField == null || compatibleFieldMap.get(oldField) == null)
newFieldsToReset.add(newField);
});
//////// Process old fields
// Iterate over all the fields that existed in the old schema
for (Map.Entry, Field>> entry : compatibleFieldMap.entrySet()) {
final Field> oldField = entry.getKey();
// Grab the old field's original value for the schema change notification, if any
if (oldValueMap != null) {
oldField.visit(new FieldSwitch() {
@Override
@SuppressWarnings("shadow")
public Void caseSimpleField(SimpleField oldField) {
final byte[] key = Field.buildKey(id, oldField.storageId);
final byte[] oldValue = Transaction.this.kvt.get(key);
oldValueMap.put(oldField.name, oldValue != null ?
oldField.encoding.read(new ByteReader(oldValue)) : oldField.encoding.getDefaultValue());
return null;
}
@Override
@SuppressWarnings("shadow")
public Void caseComplexField(ComplexField oldField) {
oldValueMap.put(oldField.name, oldField.getValueReadOnlyCopy(Transaction.this, id));
return null;
}
@Override
@SuppressWarnings("shadow")
public Void caseCounterField(CounterField oldField) {
final byte[] key = Field.buildKey(id, oldField.storageId);
final byte[] oldValue = Transaction.this.kvt.get(key);
oldValueMap.put(oldField.name, oldValue != null ? Transaction.this.kvt.decodeCounter(oldValue) : 0L);
return null;
}
});
}
// Reset the field's value and add/remove index entries as needed
oldField.visit(new FieldSwitch() {
@Override
@SuppressWarnings("shadow")
public Void caseReferenceField(ReferenceField oldField) {
// We must reset a reference to an object type that is no longer allowed by the new reference field
final ReferenceField newField = (ReferenceField)entry.getValue();
if (newField != null) {
final Set xtypes = Transaction.this.findRemovedTypes(oldField, newField);
if (!xtypes.isEmpty()) {
final ObjId ref = oldField.getValue(Transaction.this, id);
if (ref != null && xtypes.contains(ref.getStorageId())) {
// Change new field to be incompatible, so it will get reset
entry.setValue(null);
newFieldsToReset.add(newField);
}
}
}
// Proceed
return this.caseSimpleField(oldField);
}
@Override
@SuppressWarnings("shadow")
public Void caseSimpleField(SimpleField oldField) {
// Reset field?
final SimpleField> newField = (SimpleField>)entry.getValue();
final boolean reset = newField == null;
// Add/remove indexes as needed
final byte[] key = Field.buildKey(id, oldField.storageId);
if (oldField.indexed && (reset || !newField.indexed)) {
final byte[] value = Transaction.this.kvt.get(key);
Transaction.this.kvt.remove(Transaction.buildSimpleIndexEntry(oldField, id, value));
}
if (newField != null && newField.indexed && (reset || !oldField.indexed)) {
final byte[] value = !reset ? Transaction.this.kvt.get(key) : null;
Transaction.this.kvt.put(Transaction.buildSimpleIndexEntry(newField, id, value), ByteUtil.EMPTY);
}
// Reset field value if needed
if (reset)
Transaction.this.kvt.remove(key);
return null;
}
@Override
@SuppressWarnings("shadow")
public Void caseComplexField(ComplexField oldField) {
// Reset field?
final ComplexField> newField = (ComplexField>)entry.getValue();
final boolean reset = newField == null;
// Add/remove index entries as needed
final List extends SimpleField>> oldSubFields = oldField.getSubFields();
final List extends SimpleField>> newSubFields = !reset ? newField.getSubFields() : null;
for (int i = 0; i < oldSubFields.size(); i++) {
final SimpleField> oldSubField = oldSubFields.get(i);
final SimpleField> newSubField = !reset ? newSubFields.get(i) : null;
// We must also reset references to object types that are no longer allowed by the new reference field
if (!reset && oldSubField instanceof ReferenceField) {
final ReferenceField oldRefField = (ReferenceField)oldSubField;
final ReferenceField newRefField = (ReferenceField)newSubField;
final Set xtypes = Transaction.this.findRemovedTypes(oldRefField, newRefField);
if (!xtypes.isEmpty())
oldField.unreferenceRemovedTypes(Transaction.this, id, oldRefField, xtypes);
}
// Add/remove sub-field indexes
if (oldSubField.indexed && (reset || !newSubField.indexed))
oldField.removeIndexEntries(Transaction.this, id, oldSubField);
if (!oldSubField.indexed && !reset && newSubField.indexed)
newField.addIndexEntries(Transaction.this, id, newSubField);
}
// Reset field value if needed. For complex fields, a "reset" is equivalent to removing the field.
if (reset)
oldField.deleteContent(Transaction.this, id);
return null;
}
@Override
@SuppressWarnings("shadow")
public Void caseCounterField(CounterField oldField) {
// Reset field value if needed
final CounterField newField = (CounterField)entry.getValue();
if (newField == null)
Transaction.this.kvt.remove(Field.buildKey(id, oldField.storageId));
return null;
}
});
}
//////// For fields that are new or were reset, initialize values and add index entries
// Iterate over the new fields that are truly new or got reset
for (Field> newField : newFieldsToReset) {
newField.visit(new FieldSwitch() {
@Override
@SuppressWarnings("shadow")
public Void caseSimpleField(SimpleField newField) {
if (newField.indexed)
Transaction.this.kvt.put(Transaction.buildSimpleIndexEntry(newField, id, null), ByteUtil.EMPTY);
return null;
}
@Override
@SuppressWarnings("shadow")
public Void caseComplexField(ComplexField newField) {
return null; // nothing to do!
}
@Override
@SuppressWarnings("shadow")
public Void caseCounterField(CounterField newField) {
final byte[] key = Field.buildKey(id, newField.storageId);
Transaction.this.kvt.put(key, Transaction.this.kvt.encodeCounter(0L));
return null;
}
});
}
//////// Add composite index entries for newly added composite indexes
// Add index entries for composite indexes that are newly added
newType.compositeIndexes.forEach((name, newIndex) -> {
final Index oldIndex = oldType.compositeIndexes.get(name);
if (oldIndex == null || !oldIndex.getSchemaId().equals(newIndex.getSchemaId()))
this.kvt.put(this.buildCompositeIndexEntry(id, newIndex), ByteUtil.EMPTY);
});
//////// Update object schema and corresponding schema index entry
// Change object schema and update object info cache
final int newSchemaIndex = newSchema.getSchemaIndex();
this.updateObjInfo(id, newSchemaIndex, newSchema, newType);
// Update object schema index entry
final int oldSchemaIndex = oldSchema.getSchemaIndex();
this.kvt.remove(Layout.buildSchemaIndexKey(id, oldSchemaIndex));
this.kvt.put(Layout.buildSchemaIndexKey(id, newSchemaIndex), ByteUtil.EMPTY);
//////// Notify listeners
// Lock down old field values map and notify listeners about schema change
if (oldValueMap != null) {
final SchemaId oldSchemaId = oldSchema.getSchemaId();
final SchemaId newSchemaId = newSchema.getSchemaId();
final NavigableMap immutableOldValueMap = Collections.unmodifiableNavigableMap(oldValueMap);
for (SchemaChangeListener listener : new ArrayList<>(listeners))
listener.onSchemaChange(this, id, oldSchemaId, newSchemaId, immutableOldValueMap);
}
}
/**
* Find storage ID's which are no longer allowed by a reference field when upgrading to the specified
* schema and therefore need to be scrubbed during the upgrade.
*
* @return set of storage ID's that are no longer allowed and should be audited on upgrade
*/
private Set findRemovedTypes(ReferenceField oldField, ReferenceField newField) {
// Check allowed storage IDs
final Set newObjectTypes = newField.getEncoding().getObjectTypeStorageIds();
if (newObjectTypes == null)
return Collections.emptySet(); // new field can refer to any type in any schema
Set oldObjectTypes = oldField.getEncoding().getObjectTypeStorageIds();
if (oldObjectTypes == null)
oldObjectTypes = this.getSchemaBundle().getObjTypeStorageIds(); // old field can refer to any type in any schema
// Identify storage IDs which are were allowed by old field but are no longer allowed by new field
final HashSet removedObjectTypes = new HashSet<>(oldObjectTypes);
removedObjectTypes.removeAll(newObjectTypes);
return removedObjectTypes;
}
/**
* Query objects by schema.
*
*
* This returns all objects in the database grouped by {@link Schema}. The keys in the returned
* map are {@linkplain Schema#getSchemaIndex schema indexes}; use {@link #getSchemaBundle} and then
* {@link SchemaBundle#getSchemasBySchemaIndex} to access the corresponding {@link Schema}s.
*
* @return read-only, real-time view of all database objects grouped by schema
* @throws StaleTransactionException if this transaction is no longer usable
*/
public synchronized CoreIndex1 querySchemaIndex() {
if (this.stale)
throw new StaleTransactionException(this);
return Layout.getSchemaIndex(this.kvt);
}
// SchemaChangeListener's
/**
* Add a {@link SchemaChangeListener} to this transaction that listens for schema migrations
* of the specified object type.
*
* @param storageId storage ID of the object type to listen to for schema changes
* @param listener the listener to add
* @throws UnknownTypeException if {@code storageId} specifies an unknown object type
* @throws IllegalArgumentException if {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public synchronized void addSchemaChangeListener(int storageId, SchemaChangeListener listener) {
this.validateListenerChange(listener);
this.schemaBundle.getSchemaItem(storageId, ObjType.class);
if (this.schemaChangeListeners == null)
this.schemaChangeListeners = new LongMap<>();
this.schemaChangeListeners.computeIfAbsent((long)storageId, i -> new HashSet<>(1)).add(listener);
}
/**
* Remove a {@link SchemaChangeListener} from this transaction previously registered via
* {@link addSchemaChangeListener addSchemaChangeListener()}.
*
* @param storageId storage ID of the object type to listen to for schema changes
* @param listener the listener to remove
* @throws UnknownTypeException if {@code storageId} specifies an unknown object type
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalArgumentException if {@code listener} is null
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public synchronized void removeSchemaChangeListener(int storageId, SchemaChangeListener listener) {
this.validateListenerChange(listener);
this.schemaBundle.getSchemaItem(storageId, ObjType.class);
this.removeFromMappedSet(this.schemaChangeListeners, storageId, listener);
}
// Object and Field Access
/**
* Get all objects in the database.
*
*
* The returned set includes objects from all schemas. Use {@link #querySchemaIndex} to access objects with a specific schema.
*
*
* The returned set is mutable, with the exception that {@link NavigableSet#add add()} is not supported.
* Deleting an element results in {@linkplain #delete deleting} the corresponding object.
*
* @return a live view of all database objects
* @throws StaleTransactionException if this transaction is no longer usable
* @see #getAll(String)
*/
public synchronized NavigableSet getAll() {
// Sanity check
if (this.stale)
throw new StaleTransactionException(this);
// Return objects
return new ObjTypeSet(this);
}
/**
* Get all objects whose object type has the specified name.
*
*
* The returned set includes objects of the specified type from all schemas in the database.
* The returned set is mutable, with the exception that {@link NavigableSet#add add()} is not supported.
* Deleting an element results in {@linkplain #delete deleting} the corresponding object.
*
* @param typeName object type name
* @return a live view of all database objects having the specified type
* @throws UnknownTypeException if {@code typeName} does not correspond to any known object type
* @throws IllegalArgumentException if {@code typeName} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @see #getAll()
*/
public synchronized NavigableSet getAll(String typeName) {
// Sanity check
Preconditions.checkArgument(typeName != null, "null typeName");
if (this.stale)
throw new StaleTransactionException(this);
// Get storage ID
final Integer storageId = this.schemaBundle.getStorageIdsByTypeName().get(typeName);
if (storageId == null)
throw new UnknownTypeException(typeName, null);
// Return objects
return new ObjTypeSet(this, storageId);
}
/**
* Read the value of a {@link SimpleField} from an object, optionally migrating the object's schema.
*
*
* If {@code migrateSchema} is true, the object will be automatically migrated to match
* {@linkplain #getSchema() the schema associated with this transaction}, if necessary, prior to reading the field.
*
* @param id object ID of the object
* @param name field name
* @param migrateSchema true to first automatically migrate the object's schema, false to not change it
* @return value of the field in the object
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws UnknownFieldException if no {@link SimpleField} corresponding to {@code name} exists in the object
* @throws IllegalArgumentException if {@code id} is null
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
*/
public synchronized Object readSimpleField(ObjId id, String name, boolean migrateSchema) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
Preconditions.checkArgument(name != null, "null name");
this.checkStaleFieldAccess(id, name);
// Get object info
final ObjInfo info = this.getObjInfo(id, migrateSchema);
// Find field
final SimpleField> field = info.getObjType().simpleFields.get(name);
if (field == null)
throw new UnknownFieldException(info.getObjType(), name, "simple field");
// Read field
final byte[] key = field.buildKey(id);
final byte[] value = this.kvt.get(key);
// Decode value
return value != null ? field.encoding.read(new ByteReader(value)) : field.encoding.getDefaultValue();
}
/**
* Change the value of a {@link SimpleField} in an object, optionally updating the object's schema.
*
*
* If {@code migrateSchema} is true, the object will be automatically migrated to match
* {@linkplain #getSchema() the schema associated with this transaction}, if necessary, prior to writing the field.
*
* @param id object ID of the object
* @param name field name
* @param value new value for the field
* @param migrateSchema true to first automatically migrate the object's schema, false to not change it
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws UnknownFieldException if no {@link SimpleField} corresponding to {@code name} exists in the object
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
* @throws IllegalArgumentException if {@code value} is not an appropriate value for the field
* @throws IllegalArgumentException if {@code id} is null
*/
public void writeSimpleField(final ObjId id, final String name, final Object value, final boolean migrateSchema) {
this.mutateAndNotify(id, () -> this.doWriteSimpleField(id, name, value, migrateSchema));
}
private synchronized void doWriteSimpleField(ObjId id, String name, final Object newObj, boolean migrateSchema) {
// Get object info
Preconditions.checkArgument(name != null, "null name");
final ObjInfo info = this.getObjInfo(id, migrateSchema);
// Find field
final SimpleField> field = info.getObjType().simpleFields.get(name);
if (field == null)
throw new UnknownFieldException(info.getObjType(), name, "simple field");
// Check for deleted assignment
if (field instanceof ReferenceField)
this.checkDeletedAssignment(id, (ReferenceField)field, (ObjId)newObj);
// Get new value
final byte[] key = field.buildKey(id);
final byte[] newValue = field.encode(newObj);
// Before setting the new value, read the old value if one of the following is true:
// - The field is being monitored -> we need to filter out "changes" that don't actually change anything
// - The field is indexed -> we need the old value so we can remove the old index entry
byte[] oldValue = null;
if (field.indexed
|| field.compositeIndexMap != null
|| (!this.disableListenerNotifications && this.hasFieldMonitor(id, field.storageId))) {
// Get old value
oldValue = this.kvt.get(key);
// Compare new to old value
if (oldValue != null ? newValue != null && Arrays.equals(oldValue, newValue) : newValue == null)
return;
}
// Update value
if (newValue != null)
this.kvt.put(key, newValue);
else
this.kvt.remove(key);
// Update simple index, if any
if (field.indexed) {
this.kvt.remove(Transaction.buildSimpleIndexEntry(field, id, oldValue));
this.kvt.put(Transaction.buildSimpleIndexEntry(field, id, newValue), ByteUtil.EMPTY);
}
// Update affected composite indexes, if any
if (field.compositeIndexMap != null) {
for (CompositeIndex index : field.compositeIndexMap.keySet()) {
// Build old composite index entry
final ByteWriter oldWriter = new ByteWriter();
UnsignedIntEncoder.write(oldWriter, index.storageId);
int fieldStart = -1;
int fieldEnd = -1;
for (SimpleField> otherField : index.fields) {
final byte[] otherValue;
if (otherField == field) {
fieldStart = oldWriter.getLength();
otherValue = oldValue;
} else
otherValue = this.kvt.get(otherField.buildKey(id)); // can be null (if field has default value)
oldWriter.write(otherValue != null ? otherValue : otherField.encoding.getDefaultValueBytes());
if (otherField == field)
fieldEnd = oldWriter.getLength();
}
assert fieldStart != -1;
assert fieldEnd != -1;
id.writeTo(oldWriter);
// Remove old composite index entry
final byte[] oldIndexEntry = oldWriter.getBytes();
this.kvt.remove(oldIndexEntry);
// Patch in new field value to create new composite index entry
final ByteWriter newWriter = new ByteWriter(oldIndexEntry.length);
newWriter.write(oldIndexEntry, 0, fieldStart);
newWriter.write(newValue != null ? newValue : field.encoding.getDefaultValueBytes());
newWriter.write(oldIndexEntry, fieldEnd, oldIndexEntry.length - fieldEnd);
// Add new composite index entry
this.kvt.put(newWriter.getBytes(), ByteUtil.EMPTY);
}
}
// Notify monitors
if (!this.disableListenerNotifications) {
final Object oldObj = oldValue != null ?
field.encoding.read(new ByteReader(oldValue)) : field.encoding.getDefaultValue();
this.addSimpleFieldChangeNotification(field, id, oldObj, newObj);
}
}
// This method exists solely to bind the generic type parameters
@SuppressWarnings("unchecked")
private void addSimpleFieldChangeNotification(SimpleField field, ObjId id, Object oldValue, Object newValue) {
this.addFieldChangeNotification(new SimpleFieldChangeNotifier<>(field, id, (V)oldValue, (V)newValue));
}
/**
* Check for an invalid assignment to the given reference field of a deleted object.
*
* @param id referring object
* @param field reference field in referring object
* @param targetId referred-to object that should exist
* @throws DeletedObjectException if assignment is invalid
*/
void checkDeletedAssignment(ObjId id, ReferenceField field, ObjId targetId) {
assert Thread.holdsLock(this);
// Allow null
if (targetId == null)
return;
// It's possible for target to be the same object during a copy of a self-referencing object; allow it
if (targetId.equals(id))
return;
// Is deleted assignment disallowed for this field?
if (this instanceof DetachedTransaction || field.allowDeleted)
return;
// Is the target a deleted object?
if (this.exists(targetId))
return;
// Are we copying? If so defer the check
if (this.deletedAssignments != null) {
this.deletedAssignments.put(targetId, field);
return;
}
// Not allowed
throw new DeletedObjectException(targetId, String.format(
"illegal assignment to %s of reference to deleted %s",
this.getFieldDescription(id, field.name), this.getObjDescription(targetId)));
}
/**
* Build a simple index entry for the given field, object ID, and field value.
*
* @param field simple field
* @param id ID of object containing the field
* @param value encoded field value, or null for default value
* @return index key
*/
private static byte[] buildSimpleIndexEntry(SimpleField> field, ObjId id, byte[] value) {
if (value == null)
value = field.encoding.getDefaultValueBytes();
final int storageId = field.storageId;
final ByteWriter writer = new ByteWriter(UnsignedIntEncoder.encodeLength(storageId) + value.length + ObjId.NUM_BYTES);
UnsignedIntEncoder.write(writer, storageId);
writer.write(value);
id.writeTo(writer);
return writer.getBytes();
}
/**
* Get a description of the given object's type.
*
* @param id object ID
* @return textual description of the specified object's type
* @throws IllegalArgumentException if {@code id} is null
*/
public String getTypeDescription(ObjId id) {
final int storageId = id.getStorageId();
final String typeName = this.getSchemaBundle().getTypeName(storageId);
return typeName != null ? "type \"" + typeName + "\"" : "unknown type #" + storageId;
}
/**
* Get a description of the given object.
*
* @param id object ID
* @return textual description of the specified object
* @throws IllegalArgumentException if {@code id} is null
*/
public String getObjDescription(ObjId id) {
Preconditions.checkArgument(id != null, "null id");
return "object " + id + " (" + this.getTypeDescription(id) + ")";
}
/**
* Get a description of the given object field.
*
* @param id object ID
* @param name field's name
* @return textual description of the specified object field
* @throws IllegalArgumentException if {@code id} or {@code name} is null
*/
public String getFieldDescription(ObjId id, String name) {
Preconditions.checkArgument(id != null, "null id");
Preconditions.checkArgument(name != null, "null name");
return "field \"" + name + "\" in " + this.getObjDescription(id);
}
private void checkStaleFieldAccess(ObjId id, String name) {
assert Thread.holdsLock(this);
if (this.stale) {
throw new StaleTransactionException(this, String.format(
"can't access %s: %s", this.getFieldDescription(id, name), StaleTransactionException.DEFAULT_MESSAGE));
}
}
/**
* Read the value of a {@link CounterField} from an object, optionally updating the object's schema.
*
*
* If {@code migrateSchema} is true, the object will be automatically migrated to match
* {@linkplain #getSchema() the schema associated with this transaction}, if necessary, prior to reading the field.
*
* @param id object ID of the object
* @param name field name
* @param migrateSchema true to first automatically migrate the object's schema, false to not change it
* @return value of the counter in the object
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws UnknownFieldException if no {@link CounterField} corresponding to {@code name} exists in the object
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
* @throws IllegalArgumentException if {@code id} is null
*/
public synchronized long readCounterField(ObjId id, String name, boolean migrateSchema) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
Preconditions.checkArgument(name != null, "null name");
this.checkStaleFieldAccess(id, name);
// Get object info
final ObjInfo info = this.getObjInfo(id, migrateSchema);
// Find field
final CounterField field = info.getObjType().counterFields.get(name);
if (field == null)
throw new UnknownFieldException(info.getObjType(), name, "counter field");
// Read field
final byte[] key = field.buildKey(id);
final byte[] value = this.kvt.get(key);
// Decode value
return value != null ? this.kvt.decodeCounter(value) : 0;
}
/**
* Set the value of a {@link CounterField} in an object, optionally updating the object's schema.
*
*
* If {@code migrateSchema} is true, the object will be automatically migrated to match
* {@linkplain #getSchema() the schema associated with this transaction}, if necessary, prior to writing the field.
*
* @param id object ID of the object
* @param name field name
* @param value new counter value
* @param migrateSchema true to first automatically migrate the object's schema, false to not change it
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws UnknownFieldException if no {@link CounterField} corresponding to {@code name} exists in the object
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
* @throws IllegalArgumentException if {@code id} is null
*/
public synchronized void writeCounterField(final ObjId id, final String name, final long value, final boolean migrateSchema) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
Preconditions.checkArgument(name != null, "null name");
this.checkStaleFieldAccess(id, name);
// Get object info
final ObjInfo info = this.getObjInfo(id, migrateSchema);
// Find field
final CounterField field = info.getObjType().counterFields.get(name);
if (field == null)
throw new UnknownFieldException(info.getObjType(), name, "counter field");
// Set value
final byte[] key = field.buildKey(id);
this.kvt.put(key, this.kvt.encodeCounter(value));
}
/**
* Adjust the value of a {@link CounterField} in an object by some amount, optionally updating the object's schema.
*
*
* If {@code migrateSchema} is true, the object will be automatically migrated to match
* {@linkplain #getSchema() the schema associated with this transaction}, if necessary, prior to adjusting the field.
*
* @param id object ID of the object
* @param name field name
* @param offset offset value to add to counter value
* @param migrateSchema true to first automatically migrate the object's schema, false to not change it
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws UnknownFieldException if no {@link CounterField} corresponding to {@code name} exists in the object
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
* @throws IllegalArgumentException if {@code id} is null
*/
public synchronized void adjustCounterField(ObjId id, String name, long offset, boolean migrateSchema) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
Preconditions.checkArgument(name != null, "null name");
this.checkStaleFieldAccess(id, name);
// Optimize away non-change
if (offset == 0)
return;
// Get object info
final ObjInfo info = this.getObjInfo(id, migrateSchema);
// Find field
final CounterField field = info.getObjType().counterFields.get(name);
if (field == null)
throw new UnknownFieldException(info.getObjType(), name, "counter field");
// Adjust counter value
this.kvt.adjustCounter(field.buildKey(id), offset);
}
/**
* Access a {@link SetField} associated with an object, optionally updating the object's schema.
*
*
* If {@code migrateSchema} is true, the object will be automatically migrated to match
* {@linkplain #getSchema() the schema associated with this transaction}, if necessary.
*
* @param id object ID of the object
* @param name field name
* @param migrateSchema true to first automatically migrate the object's schema, false to not change it
* @return set field value
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws UnknownFieldException if no {@link SetField} corresponding to {@code name} exists in the object
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
* @throws IllegalArgumentException if {@code id} is null
*/
public NavigableSet> readSetField(ObjId id, String name, boolean migrateSchema) {
return this.readComplexField(id, "set field", name, migrateSchema, SetField.class, NavigableSet.class);
}
/**
* Access a {@link ListField} associated with an object, optionally updating the object's schema.
*
*
* If {@code migrateSchema} is true, the object will be automatically migrated to match
* {@linkplain #getSchema() the schema associated with this transaction}, if necessary.
*
* @param id object ID of the object
* @param name field name
* @param migrateSchema true to first automatically migrate the object's schema, false to not change it
* @return list field value
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws UnknownFieldException if no {@link ListField} corresponding to {@code name} exists in the object
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
* @throws IllegalArgumentException if {@code id} is null
*/
public List> readListField(ObjId id, String name, boolean migrateSchema) {
return this.readComplexField(id, "list field", name, migrateSchema, ListField.class, List.class);
}
/**
* Access a {@link MapField} associated with an object, optionally updating the object's schema.
*
*
* If {@code migrateSchema} is true, the object will be automatically migrated to match
* {@linkplain #getSchema() the schema associated with this transaction}, if necessary.
*
* @param id object ID of the object
* @param name field name
* @param migrateSchema true to first automatically migrate the object's schema, false to not change it
* @return map field value
* @throws StaleTransactionException if this transaction is no longer usable
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws UnknownFieldException if no {@link MapField} corresponding to {@code name} exists in the object
* @throws TypeNotInSchemaException {@code migrateSchema} is true and the object's schema could not be migrated because
* the object's type does not exist in this transaction's schema
* @throws IllegalArgumentException if {@code id} is null
*/
public NavigableMap, ?> readMapField(ObjId id, String name, boolean migrateSchema) {
return this.readComplexField(id, "map field", name, migrateSchema, MapField.class, NavigableMap.class);
}
/**
* Get the {@code byte[]} key prefix in the underlying key/value store corresponding to the specified object.
*
*
* Notes:
*
* - This method does not check whether the object actually exists.
* - Objects utilize multiple keys; the return value is the common prefix of all such keys.
* - The {@link KVDatabase} should not be modified directly, otherwise behavior is undefined
*
*
* @param id object ID
* @return the {@link KVDatabase} key corresponding to {@code id}
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws IllegalArgumentException if {@code id} is null
* @see Field#getKey(ObjId) Field.getKey()
* @see io.permazen.PermazenTransaction#getKey(io.permazen.PermazenObject) PermazenTransaction.getKey()
*/
public synchronized byte[] getKey(ObjId id) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
this.schemaBundle.getSchemaItem(id.getStorageId(), ObjType.class);
// Done
return id.getBytes();
}
synchronized boolean hasDefaultValue(ObjId id, SimpleField> field) {
// Sanity check
if (this.stale)
throw new StaleTransactionException(this);
Preconditions.checkArgument(id != null, "null id");
// Verify object exists
if (!this.exists(id))
throw new DeletedObjectException(this, id);
// Check whether non-default value stored in field
return this.kvt.get(field.buildKey(id)) == null;
}
private synchronized V readComplexField(ObjId id, String description,
String name, boolean migrateSchema, Class fieldClass, Class valueType) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
Preconditions.checkArgument(name != null, "null name");
this.checkStaleFieldAccess(id, name);
// Get object info
final ObjInfo info = this.getObjInfo(id, migrateSchema);
// Get field
final ComplexField> field = info.getObjType().complexFields.get(name);
if (!fieldClass.isInstance(field))
throw new UnknownFieldException(info.getObjType(), name, description);
// Return view
return valueType.cast(field.getValueInternal(this, id));
}
/**
* If an object exists, read in its meta-data, also migrating its schema it in the process if requested.
*
* @param id object ID of the object
* @param update true to migrate object's schema to match this transaction, false to leave it alone
* @return object info if object exists and can be updated (if requested), otherwise null
* @throws IllegalArgumentException if {@code id} is null
*/
private ObjInfo getObjInfoIfExists(ObjId id, boolean update) {
assert Thread.holdsLock(this);
try {
return this.getObjInfo(id, update);
} catch (DeletedObjectException | UnknownTypeException e) {
return null;
}
}
/**
* Read an object's meta-data, migrating its schema in the process if requested.
*
* @param id object ID of the object
* @param update true to migrate object's schema to match this transaction, false to leave it alone
* @return object info
* @throws UnknownTypeException if {@code id} specifies an unknown object type
* @throws DeletedObjectException if no object with ID equal to {@code id} is found
* @throws IllegalArgumentException if {@code id} is null
*/
private synchronized ObjInfo getObjInfo(ObjId id, boolean update) {
// Sanity check
Preconditions.checkArgument(id != null, "null id");
// Load object info into cache, if not already there
ObjInfo info = this.objInfoCache.get(id);
if (info == null) {
// Verify that the object type encoded within the object ID is valid
this.schemaBundle.getSchemaItem(id.getStorageId(), ObjType.class);
// Load the object's info into the cache (if object doesn't exist, we'll get an exception here)
info = this.loadIntoCache(id);
}
// Is a schema update required?
if (!update || info.getSchemaIndex() == this.schema.getSchemaIndex())
return info;
// Migrate schema
final ObjInfo info2 = info;
this.mutateAndNotify(() -> this.migrateSchema(info2, this.getSchema()));
// Load (updated) object info into cache
return this.loadIntoCache(id);
}
/**
* Get the specified object's info from the object info cache, loading it if necessary.
*
* @throws DeletedObjectException if object does not exist
*/
private ObjInfo loadIntoCache(ObjId id) {
assert Thread.holdsLock(this);
ObjInfo info = this.objInfoCache.get(id);
if (info == null) {
// Create info; we'll get an exception here if object does not exist
info = new ObjInfo(this, id);
// Add object info to the cache
if (this.objInfoCache.size() >= MAX_OBJ_INFO_CACHE_ENTRIES)
this.objInfoCache.removeOne();
this.objInfoCache.put(id, info);
}
return info;
}
/**
* Update an object's meta-data in the key/value store and in the cache.
*/
private ObjInfo updateObjInfo(ObjId id, int schemaIndex, Schema schema, ObjType objType) {
assert Thread.holdsLock(this);
ObjInfo.write(this, id, schemaIndex);
if (this.objInfoCache.size() >= MAX_OBJ_INFO_CACHE_ENTRIES)
this.objInfoCache.removeOne();
final ObjInfo info = new ObjInfo(this, id, schemaIndex, schema, objType);
this.objInfoCache.put(id, info);
return info;
}
// Field Change Notifications
/**
* Monitor for changes within this transaction of the value of the given field, as seen through a path of references.
*
*
* When the specified field is changed in some object T, a listener notification will be delivered for each object R
* that refers to object T through the specified path of reference fields (if {@code path} is empty, then R = T).
* Notifications are delivered at the end of the mutation operation just prior to returning to the caller. If the listener
* method performs additional mutation(s) which are themselves being listened for, those notifications will also be delivered
* prior to the returning to the original caller. Therefore, care must be taken to avoid change notification dependency
* loops when listeners can themselves mutate fields, to avoid infinite loops.
*
*
* The {@code filters}, if any, are applied to {@link ObjId}'s at the corresponding steps in the path: {@code filters[0]}
* is applied to the starting objects R, {@code filters[1]} is applied to the objects reachable from R via {@code path[0]},
* etc., up to {@code filters[path.length]}, which applies to the target objects T. {@code filters} or any element
* therein may be null to indicate no restriction.
*
*
* A referring object R may refer to the changed object T through more than one sequence of objects matching {@code path};
* if so, R will still appear only once in the {@link NavigableSet} provided to the listener (this is of course required
* by set semantics).
*
*
* Although the reference back-tracking algorithm does consolidate multiple paths between the same two objects,
* be careful to avoid an explosion of notifications when objects in the {@code path} have a high degree of fan-in.
*
*
* When a {@link ReferenceField} in {@code path} also happens to be the field being changed, then there is ambiguity
* about how the set of referring objects is calculated: does it use the field's value before or after the change?
* This API guarantees that the answer is "after the change"; however, if another listener on the same field is
* invoked before {@code listener} and mutates any reference field(s) in {@code path}, then whether that additional
* change is also be included in the calculation is undefined.
*
*
* Therefore, for consistency, avoid changing any {@link ReferenceField} from within a listener callback when that
* field is also in some other listener's reference path, and both listeners are watching the same field.
*
*
* Permazen allows a field's type to change across schemas, therefore some schema may exist in which the field associated
* with {@code storageId} is not a {@link SimpleField}. In such cases, {@code listener} will receive notifications about
* those changes if it also happens to implement the other listener interface. In other words, this method delegates
* directly to {@link #addFieldChangeListener addFieldChangeListener()}.
*
* @param storageId storage ID of the field to monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public void addSimpleFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, SimpleFieldChangeListener listener) {
this.addFieldChangeListener(storageId, path, filters, listener);
}
/**
* Monitor for changes within this transaction to the specified {@link SetField} as seen through a path of references.
*
*
* See {@link #addSimpleFieldChangeListener addSimpleFieldChangeListener()} for details on how notifications are delivered.
*
*
* Permazen allows a field's type to change across schemas, therefore some schema may exist in which the field associated
* with {@code storageId} is not a {@link SetField}. In such cases, {@code listener} will receive notifications about
* those changes if it also happens to implement the other listener interface. In other words, this method delegates
* directly to {@link #addFieldChangeListener addFieldChangeListener()}.
*
* @param storageId storage ID of the field to monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public void addSetFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, SetFieldChangeListener listener) {
this.addFieldChangeListener(storageId, path, filters, listener);
}
/**
* Monitor for changes within this transaction to the specified {@link ListField} as seen through a path of references.
*
*
* See {@link #addSimpleFieldChangeListener addSimpleFieldChangeListener()} for details on how notifications are delivered.
*
*
* Permazen allows a field's type to change across schemas, therefore some schema may exist in which the field associated
* with {@code storageId} is not a {@link ListField}. In such cases, {@code listener} will receive notifications about
* those changes if it also happens to implement the other listener interface. In other words, this method delegates
* directly to {@link #addFieldChangeListener addFieldChangeListener()}.
*
* @param storageId storage ID of the field to monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public void addListFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, ListFieldChangeListener listener) {
this.addFieldChangeListener(storageId, path, filters, listener);
}
/**
* Monitor for changes within this transaction to the specified {@link MapField} as seen through a path of references.
*
*
* See {@link #addSimpleFieldChangeListener addSimpleFieldChangeListener()} for details on how notifications are delivered.
*
*
* Permazen allows a field's type to change across schemas, therefore some schema may exist in which the field associated
* with {@code storageId} is not a {@link MapField}. In such cases, {@code listener} will receive notifications about
* those changes if it also happens to implement the other listener interface. In other words, this method delegates
* directly to {@link #addFieldChangeListener addFieldChangeListener()}.
*
* @param storageId storage ID of the field to monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public void addMapFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, MapFieldChangeListener listener) {
this.addFieldChangeListener(storageId, path, filters, listener);
}
/**
* Monitor for changes within this transaction to the specified {@link Field} as seen through a path of references.
*
*
* See {@link #addSimpleFieldChangeListener addSimpleFieldChangeListener()} for details on how notifications are delivered.
*
*
* Permazen allows a field's type to change across schemas, therefore in different schemas the specified field may have
* different types. The {@code listener} will receive notifications about a field change if it implements the interface
* appropriate for the field's current type (i.e., {@link SimpleFieldChangeListener}, {@link ListFieldChangeListener},
* {@link SetFieldChangeListener}, or {@link MapFieldChangeListener}) at the time of the change.
*
* @param storageId storage ID of the field to monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public synchronized void addFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, Object listener) {
this.validateListenerChange(listener, path);
this.getFieldMonitorsForField(storageId, true).add(new FieldMonitor<>(storageId, path, filters, listener));
}
/**
* Remove a field monitor previously added via {@link #addSimpleFieldChangeListener addSimpleFieldChangeListener()}
* (or {@link #addFieldChangeListener addFieldChangeListener()}).
*
* @param storageId storage ID of the field to no longer monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public void removeSimpleFieldChangeListener(int storageId, int[] path, KeyRanges[] filters,
SimpleFieldChangeListener listener) {
this.removeFieldChangeListener(storageId, path, filters, listener);
}
/**
* Remove a field monitor previously added via {@link #addSetFieldChangeListener addSetFieldChangeListener()}
* (or {@link #addFieldChangeListener addFieldChangeListener()}).
*
* @param storageId storage ID of the field to no longer monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public void removeSetFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, SetFieldChangeListener listener) {
this.removeFieldChangeListener(storageId, path, filters, listener);
}
/**
* Remove a field monitor previously added via {@link #addListFieldChangeListener addListFieldChangeListener()}
* (or {@link #addFieldChangeListener addFieldChangeListener()}).
*
* @param storageId storage ID of the field to no longer monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public void removeListFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, ListFieldChangeListener listener) {
this.removeFieldChangeListener(storageId, path, filters, listener);
}
/**
* Remove a field monitor previously added via {@link #addMapFieldChangeListener addMapFieldChangeListener()}
* (or {@link #addFieldChangeListener addFieldChangeListener()}).
*
* @param storageId storage ID of the field to no longer monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public void removeMapFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, MapFieldChangeListener listener) {
this.removeFieldChangeListener(storageId, path, filters, listener);
}
/**
* Remove a field monitor previously added via {@link #addSimpleFieldChangeListener addSimpleFieldChangeListener()},
* {@link #addSetFieldChangeListener addSetFieldChangeListener()},
* {@link #addListFieldChangeListener addListFieldChangeListener()},
* {@link #addMapFieldChangeListener addMapFieldChangeListener()},
* or {@link #addFieldChangeListener addFieldChangeListener()}.
*
* @param storageId storage ID of the field to no longer monitor
* @param path path of reference fields (represented by storage IDs) through which to monitor field;
* negated values denote inverse traversal of the field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param listener callback for notifications on changes in value
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code path} or {@code listener} is null
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalStateException if {@link #setListeners setListeners()} has been invoked on this instance
*/
public synchronized void removeFieldChangeListener(int storageId, int[] path, KeyRanges[] filters, Object listener) {
this.validateListenerChange(listener, path);
final Set> monitors = this.getFieldMonitorsForField(storageId);
if (monitors != null)
monitors.remove(new FieldMonitor<>(storageId, path, filters, listener));
}
private void validateListenerChange(Object listener, int... path) {
assert Thread.holdsLock(this);
if (this.stale)
throw new StaleTransactionException(this);
Preconditions.checkArgument(path != null, "null path");
Preconditions.checkArgument(listener != null, "null listener");
Preconditions.checkState(this.monitorCache == null, "ListenerSet installed");
this.verifyReferencePath(path);
}
private Set> getFieldMonitorsForField(int storageId) {
return this.getFieldMonitorsForField(storageId, false);
}
private synchronized Set> getFieldMonitorsForField(int storageId, boolean create) {
Preconditions.checkArgument(storageId > 0, "invalid storageId");
Set> monitors;
if (this.fieldMonitors == null) {
if (!create)
return null;
this.fieldMonitors = new TreeMap<>();
monitors = null;
} else
monitors = this.fieldMonitors.get(storageId);
if (monitors == null) {
if (!create)
return null;
monitors = new HashSet<>(1);
this.fieldMonitors.put(storageId, monitors);
}
return monitors;
}
/**
* Add a pending notification for any {@link FieldMonitor}s watching the specified field in the specified object.
* This method assumes only the appropriate type of monitor is registered as a listener on the field
* and that the provided old and new values have the appropriate types.
*
*
* This method should only be invoked if this.disableListenerNotifications is false.
*/
void addFieldChangeNotification(FieldChangeNotifier, ?> notifier) {
assert Thread.holdsLock(this);
assert !this.disableListenerNotifications;
// Get info
final ObjId id = notifier.id;
final int fieldStorageId = notifier.field.storageId;
// Does anybody care?
if (!this.hasFieldMonitor(id, fieldStorageId))
return;
// Add a pending field monitor notification for the specified field
this.pendingFieldChangeNotifications.get().computeIfAbsent(fieldStorageId, i -> new ArrayList<>(2)).add(notifier);
}
/**
* Determine if there are any {@link FieldMonitor}s watching the specified field in the specified object.
*/
synchronized boolean hasFieldMonitor(ObjId id, int fieldStorageId) {
// Do quick check, if possible
if (this.fieldMonitors == null)
return false;
if (this.monitorCache != null)
return this.monitorCache.hasFieldMonitor(id.getStorageId(), fieldStorageId);
// Do slow check
final Set> monitorsForField = this.getFieldMonitorsForField(fieldStorageId);
if (monitorsForField == null)
return false;
return monitorsForField.stream().anyMatch(new FieldMonitorPredicate(id.getStorageId(), fieldStorageId));
}
/**
* Determine if there are any {@link FieldMonitor}s watching any field in the specified type.
*/
synchronized boolean hasFieldMonitor(ObjType objType) {
// Do quick check, if possible
if (this.fieldMonitors == null)
return false;
if (this.monitorCache != null)
return this.monitorCache.hasFieldMonitor(objType.storageId);
// Do slow check
final NavigableSet fieldStorageIds = NavigableSets.intersection(
objType.fieldsByStorageId.navigableKeySet(), this.fieldMonitors.navigableKeySet());
final byte[] objTypeBytes = ObjId.getMin(objType.storageId).getBytes();
for (int fieldStorageId : fieldStorageIds) {
if (this.fieldMonitors.get(fieldStorageId).stream().anyMatch(new FieldMonitorPredicate(objTypeBytes, fieldStorageId)))
return true;
}
return false;
}
/**
* Verify the given object exists before proceeding with the given mutation via {@link #mutateAndNotify(Supplier)}.
*
* @param id object containing the mutated field; will be validated
* @param mutation change to apply
* @return result of mutation
* @throws StaleTransactionException if this transaction is no longer usable
* @throws IllegalArgumentException if {@code id} is null
*/
synchronized V mutateAndNotify(ObjId id, Supplier mutation) {
// Verify object exists
Preconditions.checkArgument(id != null, "null id");
if (this.stale)
throw new StaleTransactionException(this);
if (!this.exists(id))
throw new DeletedObjectException(this, id);
// Perform mutation
return this.mutateAndNotify(mutation);
}
synchronized void mutateAndNotify(ObjId id, Runnable mutation) {
this.mutateAndNotify(id, () -> {
mutation.run();
return null;
});
}
/**
* Perform some action and, when entirely done (including re-entrant invocation), issue pending notifications to monitors.
*
* @param mutation change to apply
* @throws StaleTransactionException if this transaction is no longer usable
* @throws NullPointerException if {@code mutation} is null
*/
private synchronized V mutateAndNotify(Supplier mutation) {
// Validate transaction
if (this.stale)
throw new StaleTransactionException(this);
// If re-entrant invocation, we're already set up
if (this.pendingFieldChangeNotifications.get() != null)
return mutation.get();
// Set up pending report list, perform mutation, and then issue reports
this.pendingFieldChangeNotifications.set(new TreeMap<>());
try {
return mutation.get();
} finally {
try {
final TreeMap>> pending = this.pendingFieldChangeNotifications.get();
while (!pending.isEmpty()) {
// Get the next field with pending notifications
final Map.Entry>> entry = pending.pollFirstEntry();
final int storageId = entry.getKey();
// For all pending notifications, back-track references and notify all field monitors for the field
for (FieldChangeNotifier, ?> notifier : entry.getValue()) {
assert notifier.field.storageId == storageId;
final Set> monitors = this.getFieldMonitorsForField(storageId);
if (monitors == null || monitors.isEmpty())
continue;
this.monitorNotify(notifier, NavigableSets.singleton(notifier.id), new ArrayList<>(monitors));
}
}
} finally {
this.pendingFieldChangeNotifications.remove();
}
}
}
private synchronized void mutateAndNotify(Runnable mutation) {
this.mutateAndNotify(() -> {
mutation.run();
return null;
});
}
// For each monitor, back-track references in path and notify the monitors when we reach the beginning of the path
private void monitorNotify(Notifier> notifier, NavigableSet objects, ArrayList> monitorList) {
this.monitorNotify(notifier, objects, monitorList, 0);
}
private void monitorNotify(Notifier> notifier, NavigableSet objects, ArrayList> monitorList, int step) {
// Find the monitors for whom we have completed all the steps in their (inverse) path,
// and group the remaining monitors by their next inverted reference path step.
final HashMap>> remainingMonitorsMap = new HashMap<>();
for (Monitor> monitor : monitorList) {
// Apply the monitor's type filter on the target object, if any
if (step == 0) {
final KeyRanges filter = monitor.getTargetFilter();
if (filter != null && !filter.contains(notifier.id.getBytes()))
continue;
}
// Issue notification callback if we have back-tracked through the whole path
if (monitor.path.length == step) {
this.monitorNotify(notifier, monitor, objects);
continue;
}
// Group the unfinished monitors by their next (i.e., previous) reference field
remainingMonitorsMap.computeIfAbsent(monitor.getStorageId(step), i -> new ArrayList<>()).add(monitor);
}
// Invert references for each group of remaining monitors and recurse
for (Map.Entry>> entry : remainingMonitorsMap.entrySet()) {
final int storageId = entry.getKey();
final ArrayList> monitors = entry.getValue();
assert monitors != null;
final ArrayList> refsList = new ArrayList<>(monitors.size());
for (Monitor> monitor : monitors)
refsList.addAll(this.traverseReference(objects, -storageId, monitor.getFilter(step + 1)));
if (!refsList.isEmpty())
this.monitorNotify(notifier, NavigableSets.union(refsList), monitors, step + 1);
}
}
// Notify listener, if it has the appropriate type
private void monitorNotify(Notifier notifier, Monitor> monitor, NavigableSet objects) {
final L listener;
try {
listener = notifier.getListenerType().cast(monitor.listener);
} catch (ClassCastException e) {
return;
}
notifier.notify(this, listener, monitor.path, objects);
}
// Reference Path Queries
/**
* Find all objects referred to by any object in the given start set through the specified path of references.
*
*
* Each value in {@code path} represents a reference field traversed in the path to some target object(s); if a
* value in {@code path} is negated, then the field is traversed in the inverse direction.
*
*
* If {@code path} is empty, then the contents of {@code startObjects} is returned.
*
*
* The {@code filters}, if any, are applied to {@link ObjId}'s at the corresponding steps in the path: {@code filters[0]}
* is applied to {@code startObjects}, {@code filters[1]} is applied to the objects reachable from {@code startObjects}
* via {@code path[0]}, etc., up to {@code filters[path.length]}, which applies to the final target objects. {@code filters}
* or any element therein may be null to indicate no restriction.
*
* @param startObjects starting objects
* @param path path of zero or more reference fields (represented by storage IDs) through which to reach the target objects;
* negated values denote an inverse traversal of the corresponding reference field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @return read-only set of objects referred to by the {@code startObjects} via {@code path} restricted by {@code filters}
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code startObjects} or {@code path} is null
* @throws IllegalArgumentException if {@code filters} is not null and does not have length {@code path.length + 1}
* @throws StaleTransactionException if this transaction is no longer usable
*/
public NavigableSet followReferencePath(Stream extends ObjId> startObjects, int[] path, KeyRanges[] filters) {
// Sanity check
Preconditions.checkArgument(startObjects != null, "null startObjects");
Preconditions.checkArgument(path != null, "null path");
Preconditions.checkArgument(filters == null || filters.length == path.length + 1, "invalid filters length");
// Perform initial filtering
final ObjIdSet startIds = new ObjIdSet();
final KeyRanges firstFilter = filters != null ? filters[0] : null;
if (firstFilter != null)
startObjects = startObjects.filter(id -> firstFilter.contains(id.getBytes()));
startObjects.iterator().forEachRemaining(startIds::add);
if (path.length == 0)
return startIds.sortedSnapshot();
// Traverse each reference in the path
Set ids = startIds;
for (int i = 0; i < path.length; i++) {
final int pathId = path[i];
final KeyRanges filter = filters != null ? filters[i + 1] : null;
// Traverse reference
final ArrayList> refsList = this.traverseReference(ids, pathId, filter);
if (refsList.isEmpty())
return NavigableSets.empty(Encodings.OBJ_ID);
// Recurse on the union of the resulting object sets
ids = NavigableSets.union(refsList);
}
// Done
return (NavigableSet)ids;
}
/**
* Find all objects that refer to any object in the given target set through the specified path of references.
*
*
* Each value in {@code path} represents a reference field traversed in the path to the target object(s); if a
* value in {@code path} is negated, then the field is traversed in the inverse direction.
*
*
* If {@code path} is empty, then the contents of {@code targetObjects} is returned.
*
*
* The {@code filters}, if any, are applied to {@link ObjId}'s at the corresponding steps in the path:
* {@code filters[path.length]} is applied to {@code targetObjects}, {@code filters[path.length - 1]} is applied to the
* objects referring to {@code targetObjects} via {@code path[path.length - 1]}, etc., down to {@code filters[0]}, which
* applies to the objects at the start of the path being inverted. {@code filters} or any element therein may be null to
* indicate no restriction.
*
* @param path path of zero or more reference fields (represented by storage IDs) through which to reach the target objects;
* negated values denote an inverse traversal of the corresponding reference field
* @param filters if not null, an array of length {@code path.length + 1} containing optional filters to be applied
* to object ID's after the corresponding steps in the path
* @param targetObjects target objects
* @return read-only set of objects that refer to the {@code targetObjects} via {@code path} restricted by {@code filters}
* @throws UnknownFieldException if {@code path} contains a storage ID that does not correspond to a {@link ReferenceField}
* @throws IllegalArgumentException if {@code targetObjects} or {@code path} is null
* @throws IllegalArgumentException if {@code filters} is not null and does not have length {@code path.length + 1}
* @throws StaleTransactionException if this transaction is no longer usable
*/
public NavigableSet invertReferencePath(int[] path, KeyRanges[] filters, Stream extends ObjId> targetObjects) {
// Invert path
final int[] invertedPath = new int[path.length];
int i = 0;
int j = path.length;
while (i < path.length)
invertedPath[i++] = -path[--j];
// Invert filters
final KeyRanges[] invertedFilters;
if (filters != null) {
invertedFilters = new KeyRanges[filters.length];
i = 0;
j = invertedFilters.length;
while (i < invertedFilters.length)
invertedFilters[i++] = filters[--j];
} else
invertedFilters = null;
// Follow inverted path
return this.followReferencePath(targetObjects, invertedPath, invertedFilters);
}
private ArrayList> traverseReference(Set objects, int referenceId, KeyRanges filter) {
assert objects != null;
// Check forward vs. inverse and get storage info
final boolean inverse = referenceId < 0;
final int storageId = inverse ? -referenceId : referenceId;
final ReferenceField field = this.verifyReferenceField(storageId); // just a representative
final SimpleIndex fieldIndex = field.getIndex();
// Traverse reference from each object
final ArrayList> refsList = new ArrayList<>();
if (inverse) {
// Get index and apply filter, if any
CoreIndex1 index = fieldIndex.getIndex(this);
if (filter != null)
index = index.filter(1, filter);
final NavigableMap> indexMap = index.asMap();
// Query for each ID in the index
for (ObjId id : objects) {
final NavigableSet refs = indexMap.get(id);
if (refs != null)
refsList.add(refs);
}
} else {
final ObjIdSet refs = new ObjIdSet();
final Predicate idFilter = filter != null ? id -> filter.contains(id.getBytes()) : null;
for (ObjId id : objects)
fieldIndex.readAllNonNull(this, id, refs, idFilter);
if (!refs.isEmpty())
refsList.add(refs.sortedSnapshot());
}
// Done
return refsList;
}
// Verify all fields in the path are reference fields
private void verifyReferencePath(int[] path) {
for (int pathId : path) {
final int storageId = pathId < 0 ? -pathId : pathId;
this.verifyReferenceField(storageId);
}
}
private ReferenceField verifyReferenceField(int storageId) {
return this.getSchemaBundle().getSchemaItem(storageId, ReferenceField.class);
}
// Index Queries
/**
* Query any simple or composite index.
*
*
* The returned view will have type {@link CoreIndex1}, {@link CoreIndex2}, {@link CoreIndex3}, etc.,
* corresponding to the number of fields in the index.
*
* @param storageId the storage ID associated with the field (if simple) or composite index
* @return read-only, real-time view of the index
* @throws UnknownIndexException if no such index exists
* @throws StaleTransactionException if this transaction is no longer usable
*/
public AbstractCoreIndex queryIndex(int storageId) {
return this.findIndex(storageId, Index.class).getIndex(this);
}
/**
* Query a {@link SimpleIndex} to find all values stored in some field and, for each value,
* the set of all objects having that value in the field.
*
*
* Use this method to acquire a plain {@link CoreIndex1} on complex sub-fields.
*
* @param storageId the storage ID associated with the field
* @return read-only, real-time view of the index
* @throws UnknownIndexException if no such index exists
* @throws StaleTransactionException if this transaction is no longer usable
*/
@SuppressWarnings("unchecked")
public CoreIndex1, ObjId> querySimpleIndex(int storageId) {
return (CoreIndex1, ObjId>)this.findIndex(storageId, SimpleIndex.class).getIndex(this);
}
/**
* Query a {@link ListElementIndex} to find all values stored in some list field and, for each value,
* the set of all objects having that value as an element in the list and the corresponding list index.
*
* @param storageId the storage ID associated with the field
* @return read-only, real-time view of the index
* @throws UnknownIndexException if no such index exists
* @throws StaleTransactionException if this transaction is no longer usable
*/
@SuppressWarnings("unchecked")
public CoreIndex2, ObjId, Integer> queryListElementIndex(int storageId) {
return (CoreIndex2, ObjId, Integer>)this.findIndex(storageId, ListElementIndex.class).getElementIndex(this);
}
/**
* Query a {@link MapValueIndex} to find all values stored in some map field and, for each value,
* the set of all objects having that value as a value in the map and the corresponding key.
*
* @param storageId the storage ID associated with the field
* @return read-only, real-time view of the index
* @throws UnknownIndexException if no such index exists
* @throws StaleTransactionException if this transaction is no longer usable
*/
@SuppressWarnings("unchecked")
public CoreIndex2, ObjId, ?> queryMapValueIndex(int storageId) {
return (CoreIndex2, ObjId, ?>)this.findIndex(storageId, MapValueIndex.class).getValueIndex(this);
}
/**
* Query a {@link CompositeIndex} on two fields to find all value tuples stored in the corresponding
* field tuple and, for each value tuple, the set of all objects having those values in those fields.
*
* @param storageId the storage ID associated with the composite index
* @return read-only, real-time view of the index
* @throws UnknownIndexException if no such index exists
* @throws UnknownIndexException if the index is not on two fields
* @throws StaleTransactionException if this transaction is no longer usable
*/
@SuppressWarnings("unchecked")
public CoreIndex2, ?, ObjId> queryCompositeIndex2(int storageId) {
return (CoreIndex2, ?, ObjId>)this.findCompositeIndex(storageId, 2, CoreIndex2.class);
}
/**
* Query a {@link CompositeIndex} on three fields to find all value tuples stored in the corresponding
* field tuple and, for each value tuple, the set of all objects having those values in those fields.
*
* @param storageId the storage ID associated with the composite index
* @return read-only, real-time view of the index
* @throws UnknownIndexException if no such index exists
* @throws UnknownIndexException if the index is not on three fields
* @throws StaleTransactionException if this transaction is no longer usable
*/
@SuppressWarnings("unchecked")
public CoreIndex3, ?, ?, ObjId> queryCompositeIndex3(int storageId) {
return (CoreIndex3, ?, ?, ObjId>)this.findCompositeIndex(storageId, 3, CoreIndex3.class);
}
/**
* Query a {@link CompositeIndex} on four fields to find all value tuples stored in the corresponding
* field tuple and, for each value tuple, the set of all objects having those values in those fields.
*
* @param storageId the storage ID associated with the composite index
* @return read-only, real-time view of the index
* @throws UnknownIndexException if no such index exists
* @throws UnknownIndexException if the index is not on four fields
* @throws StaleTransactionException if this transaction is no longer usable
*/
@SuppressWarnings("unchecked")
public CoreIndex4, ?, ?, ?, ObjId> queryCompositeIndex4(int storageId) {
return (CoreIndex4, ?, ?, ?, ObjId>)this.findCompositeIndex(storageId, 4, CoreIndex4.class);
}
// COMPOSITE-INDEX
private > CI findCompositeIndex(int storageId, int numFields, Class coreIndexType) {
final CompositeIndex index = this.findIndex(storageId, CompositeIndex.class);
final AbstractCoreIndex coreIndex = index.getIndex(this);
try {
return coreIndexType.cast(coreIndex);
} catch (ClassCastException e) {
throw new UnknownIndexException(
String.format("storage ID %d", storageId),
String.format("the composite index \"%s\" is on %d != %d fields",
index.getName(), index.getFields().size(), numFields));
}
}
private synchronized I findIndex(int storageId, Class indexType) {
if (this.stale)
throw new StaleTransactionException(this);
final Index index = SimpleIndex.class.isAssignableFrom(indexType) ?
this.schemaBundle.getSchemaItem(storageId, SimpleField.class).getIndex() :
this.schemaBundle.getSchemaItem(storageId, Index.class);
try {
return indexType.cast(index);
} catch (ClassCastException e) {
throw new UnknownIndexException(String.format("storage ID %d", storageId),
String.format("%s is not a %s", index, SchemaBundle.getDescription(indexType)));
}
}
// Internal Methods
/**
* Find all objects that refer to the given target object through the/any reference field with the specified
* {@link DeleteAction}.
*
*
* Because different schemas can have different {@link DeleteAction}'s configured for the same field,
* we have to iterate through each schema separately.
*
* @param target referred-to object
* @param inverseDelete {@link DeleteAction} to match
* @return mapping from reference field storage ID to the set of objects referring to {@code target}
* through a reference field whose {@link DeleteAction} matches {@code inverseDelete}.
*/
@SuppressWarnings("unchecked")
private TreeMap> findReferrers(ObjId target, DeleteAction inverseDelete) {
assert Thread.holdsLock(this);
// Get target object type storage ID
final int targetStorageId = target.getStorageId();
// Determine which schemas actually have objects that exist; if there's only one we can slightly optimize below
final ArrayList>> schemaList = new ArrayList<>(5);
schemaList.addAll(this.querySchemaIndex().asMap().entrySet());
final boolean multipleSchemas = schemaList.size() > 1;
// Search for objects one schema at a time, and group them by reference field
final TreeMap result = new TreeMap<>();
for (Map.Entry> schemaListEntry : schemaList) {
final int schemaIndex = schemaListEntry.getKey();
final NavigableSet schemaRefs = schemaListEntry.getValue();
// Get corresponding Schema object
final Schema nextSchema = this.schemaBundle.getSchema(schemaIndex);
assert nextSchema != null;
// Iterate over reference fields in this schema that have the configured DeleteAction in some object type
nextSchema.getDeleteActionKeyRanges().get(inverseDelete).forEach((field, keyRanges) -> {
// Do a quick check to see whether this field can possibly refer to the target object
final Set targetTypes = field.getEncoding().getObjectTypeStorageIds();
if (targetTypes != null && !targetTypes.contains(targetStorageId))
return;
// Build the key prefix for the target object ID in this field's index
assert field.getIndex().storageId == field.storageId;
final int fieldStorageId = field.storageId;
final ByteWriter writer = new ByteWriter(UnsignedIntEncoder.encodeLength(fieldStorageId) + ObjId.NUM_BYTES);
UnsignedIntEncoder.write(writer, fieldStorageId);
target.writeTo(writer);
final byte[] prefix = writer.getBytes();
// Query the index to get all objects referring to the target object through this field (in any schema)
final IndexSet indexSet = new IndexSet<>(this.kvt, Encodings.OBJ_ID, true, prefix);
// Now restrict those referrers to only those object types where the field's DeleteAction matches (if necessary)
NavigableSet referrers = keyRanges != null ? indexSet.filterKeys(keyRanges) : indexSet;
// Anything there?
if (referrers.isEmpty())
return;
// Add these referrers, restricted to the current schema, to our list of referrers for this field
if (multipleSchemas) {
((ArrayList>)result
.computeIfAbsent(fieldStorageId, i -> new ArrayList>(schemaList.size())))
.add(NavigableSets.intersection(schemaRefs, referrers));
} else
result.put(fieldStorageId, referrers); // no schema restriction necessary; no list needed
});
}
// If there were multiple schemas, for each reference field, take the union of the sets from each schema
if (multipleSchemas) {
for (Map.Entry entry : result.entrySet()) {
final ArrayList> list = (ArrayList>)entry.getValue();
final NavigableSet union = list.size() == 1 ? list.get(0) : NavigableSets.union(list);
entry.setValue(union);
}
}
// Return referrer sets grouped by reference field
return (TreeMap>)(Object)result;
}
private byte[] buildCompositeIndexEntry(ObjId id, CompositeIndex index) {
return Transaction.buildCompositeIndexEntry(this, id, index);
}
private static byte[] buildDefaultCompositeIndexEntry(ObjId id, CompositeIndex index) {
return Transaction.buildCompositeIndexEntry(null, id, index);
}
private static byte[] buildCompositeIndexEntry(Transaction tx, ObjId id, CompositeIndex index) {
final ByteWriter writer = new ByteWriter();
UnsignedIntEncoder.write(writer, index.storageId);
for (SimpleField> field : index.fields) {
final byte[] value = tx != null ? tx.kvt.get(field.buildKey(id)) : null;
writer.write(value != null ? value : field.encoding.getDefaultValueBytes());
}
id.writeTo(writer);
return writer.getBytes();
}
// Listener snapshots
/**
* Create a read-only snapshot of all ({@link CreateListener}s, {@link DeleteListener}s, {@link SchemaChangeListener}s,
* {@link SimpleFieldChangeListener}s, {@link SetFieldChangeListener}s, {@link ListFieldChangeListener}s, and
* {@link MapFieldChangeListener}s currently registered on this instance.
*
*
* The snapshot can be applied to other transactions having compatible schemas via {@link #setListeners setListeners()}.
* Use of a {@link ListenerSet} also allows certain internal optimizations.
*
* @return snapshot of listeners associated with this instance
* @see #setListeners setListeners()
*/
public synchronized ListenerSet snapshotListeners() {
return new ListenerSet(this);
}
/**
* Apply a snapshot created via {@link #snapshotListeners} to this instance.
*
*
* Any currently registered listeners are unregistered and replaced by the listeners in {@code listeners}.
* This method may be invoked multiple times; however, once this method has been invoked, any subsequent
* attempts to register or unregister individual listeners will result in an {@link IllegalStateException}.
*
* @param listeners listener set created by {@link #snapshotListeners}
* @throws IllegalArgumentException if {@code listeners} was created from a transaction with an incompatible schema
* @throws IllegalArgumentException if {@code listeners} is null
*/
public synchronized void setListeners(ListenerSet listeners) {
Preconditions.checkArgument(listeners != null, "null listeners");
// Verify monitors are compatible with this transaction
if ((listeners.fieldMonitors != null || listeners.deleteMonitors != null)
&& !listeners.schemaBundle.matches(this.schemaBundle))
throw new IllegalArgumentException("listener set was created from a transaction having an incompatible schema");
// Apply listeners to this instance
this.schemaChangeListeners = listeners.schemaChangeListeners;
this.createListeners = listeners.createListeners;
this.deleteMonitors = listeners.deleteMonitors;
this.fieldMonitors = listeners.fieldMonitors;
this.monitorCache = listeners.monitorCache;
}
// User Object
/**
* Associate an arbitrary object with this instance.
*
* @param obj user object
*/
public synchronized void setUserObject(Object obj) {
this.userObject = obj;
}
/**
* Get the object with this instance by {@link #setUserObject setUserObject()}, if any.
*
* @return the associated user object, or null if none has been set
*/
public synchronized Object getUserObject() {
return this.userObject;
}
// Callback
/**
* Callback interface for notification of transaction completion events.
* Callbacks are registered with a transaction via {@link Transaction#addCallback Transaction.addCallback()},
* and are executed in the order registered, in the same thread that just committed (or rolled back) the transaction.
*
*
* Modeled after Spring's {@link org.springframework.transaction.support.TransactionSynchronization} interface.
*
* @see Transaction#addCallback Transaction.addCallback()
*/
public interface Callback {
/**
* Invoked before transaction commit (and before {@link #beforeCompletion}).
* This method is invoked when a transaction is intended to be committed; it may however still be rolled back.
*
*
* Any exceptions thrown will result in a transaction rollback and be propagated to the caller.
*
* @param readOnly true if the transaction {@linkplain Transaction#isReadOnly is marked read-only}
*/
void beforeCommit(boolean readOnly);
/**
* Invoked before transaction completion in any case (but after any {@link #beforeCommit beforeCommit()}).
* This method is invoked whether the transaction is going to be committed or rolled back,
* and is invoked even if {@link #beforeCommit beforeCommit()} throws an exception.
* Typically used to clean up resources before transaction completion.
*
*
* Any exceptions thrown will be logged but will not propagate to the caller.
*/
void beforeCompletion();
/**
* Invoked after successful transaction commit (and before {@link #afterCompletion afterCompletion()}).
*
*
* Any exceptions thrown will propagate to the caller.
*/
void afterCommit();
/**
* Invoked after transaction completion (but after any {@link #afterCommit}).
* This method is invoked in any case, whether the transaction was committed or rolled back.
* Typically used to clean up resources after transaction completion.
*
*
* Any exceptions thrown will be logged but will not propagate to the caller.
*
* @param committed true if transaction was commited, false if transaction was rolled back
*/
void afterCompletion(boolean committed);
}
/**
* Adapter class for {@link Callback}.
*
*
* All the implementations in this class do nothing.
*/
public static class CallbackAdapter implements Callback {
@Override
public void beforeCommit(boolean readOnly) {
}
@Override
public void beforeCompletion() {
}
@Override
public void afterCommit() {
}
@Override
public void afterCompletion(boolean committed) {
}
}
// Listeners
/**
* A fixed collection of listeners ({@link CreateListener}s, {@link DeleteListener}s, {@link SchemaChangeListener}s,
* {@link SimpleFieldChangeListener}s, {@link SetFieldChangeListener}s, {@link ListFieldChangeListener}s, and
* {@link MapFieldChangeListener}s) that can be efficiently registered on a {@link Transaction} all at once.
*
*
* To create an instance of this class, use {@link Transaction#snapshotListeners} after registering the desired
* set of listeners. Once created, the instance can be used repeatedly to configure the same set of listeners
* on any other compatible {@link Transaction} via {@link Transaction#setListeners Transaction.setListeners()},
* where "compatible" means having the same {@link SchemaBundle}.
*/
public static final class ListenerSet {
final LongMap> schemaChangeListeners;
final LongMap> createListeners;
final LongMap> deleteMonitors;
final NavigableMap>> fieldMonitors;
final MonitorCache monitorCache;
final SchemaBundle schemaBundle;
private ListenerSet(Transaction tx) {
assert Thread.holdsLock(tx);
this.schemaChangeListeners = this.deepCopyReadOnly(tx.schemaChangeListeners);
this.createListeners = this.deepCopyReadOnly(tx.createListeners);
this.deleteMonitors = this.deepCopyReadOnly(tx.deleteMonitors);
this.fieldMonitors = this.deepCopyReadOnly(tx.fieldMonitors);
this.monitorCache = tx.buildMonitorCache();
this.schemaBundle = tx.schemaBundle;
}
private NavigableMap> deepCopyReadOnly(NavigableMap> map) {
if (map == null)
return null;
final TreeMap> copy = new TreeMap<>(map.comparator());
for (Map.Entry> entry : map.entrySet())
copy.put(entry.getKey(), this.deepCopyReadOnly(entry.getValue()));
return new ImmutableNavigableMap<>(copy);
}
private LongMap> deepCopyReadOnly(LongMap> map) {
if (map == null)
return null;
final LongMap> copy = map.clone();
copy.entrySet().stream().forEach(entry -> entry.setValue(this.deepCopyReadOnly(entry.getValue())));
return copy;
}
private Set deepCopyReadOnly(Set set) {
if (set == null)
return null;
return Collections.unmodifiableSet(new HashSet<>(set));
}
}
// MonitorPredicate
private abstract static class MonitorPredicate> implements Predicate {
private final byte[] objTypeBytes;
MonitorPredicate(byte[] objTypeBytes) {
this.objTypeBytes = objTypeBytes;
}
MonitorPredicate(int objTypeStorageId) {
this(ObjId.getMin(objTypeStorageId).getBytes());
}
@Override
public boolean test(M monitor) {
final KeyRanges filter = monitor.getTargetFilter();
return filter == null || filter.contains(this.objTypeBytes);
}
}
// FieldMonitorPredicate
// Matches FieldMonitors who monitor the specified field in the specified object type
private static final class FieldMonitorPredicate extends MonitorPredicate> {
private final int fieldStorageId;
FieldMonitorPredicate(byte[] objTypeBytes, int fieldStorageId) {
super(objTypeBytes);
this.fieldStorageId = fieldStorageId;
}
FieldMonitorPredicate(int objTypeStorageId, int fieldStorageId) {
super(objTypeStorageId);
this.fieldStorageId = fieldStorageId;
}
@Override
public boolean test(FieldMonitor> monitor) {
return monitor.storageId == this.fieldStorageId && super.test(monitor);
}
}
// DeleteMonitorPredicate
// Matches DeleteMonitors who monitor the specified object type
private static final class DeleteMonitorPredicate extends MonitorPredicate {
DeleteMonitorPredicate(byte[] objTypeBytes) {
super(objTypeBytes);
}
DeleteMonitorPredicate(int objTypeStorageId) {
super(objTypeStorageId);
}
}
// MonitorCache
//
// This provides a way to do a quick check for the existence of field and delete monitors.
// Each long flag in the set is split decoded as two 32-bit integers as follows:
//
// [Hi Bits, Lo Bits] Decode Meaning
// --------------------- ------- -------
// [ Positive, Positive ] Hi = ObjType, Lo = Field FieldMonitor exists for that type and that field
// [ Positive, Zero ] Hi = ObjType FieldMonitor exists for that type and some field
// [ Positive, -1 ] Hi = ObjType DeleteMonitor exists for that type
//
@SuppressWarnings("serial")
private static class MonitorCache extends LongSet {
public boolean hasFieldMonitor(int objTypeStorageId) {
return this.contains(this.buildKey(objTypeStorageId, 0));
}
public boolean hasFieldMonitor(int objTypeStorageId, int fieldStorageId) {
return this.contains(this.buildKey(objTypeStorageId, fieldStorageId));
}
public boolean hasDeleteMonitor(int objTypeStorageId) {
return this.contains(this.buildKey(objTypeStorageId, -1));
}
public void addDeleteMonitor(int objTypeStorageId) {
this.add(this.buildKey(objTypeStorageId, -1));
}
public void addFieldMonitor(int objTypeStorageId, int fieldStorageId) {
this.add(this.buildKey(objTypeStorageId, fieldStorageId));
this.add(this.buildKey(objTypeStorageId, 0));
}
private long buildKey(int objTypeStorageId, int fieldStorageId) {
assert objTypeStorageId > 0;
assert fieldStorageId >= -1;
return ((long)objTypeStorageId << 32) | ((long)fieldStorageId & 0xffffffffL);
}
}
/**
* Build a {@link MonitorCache} based on this transaction's current monitors.
*/
private synchronized MonitorCache buildMonitorCache() {
final MonitorCache cache = new MonitorCache();
for (Schema otherSchema : this.schemaBundle.getSchemasBySchemaId().values()) {
for (ObjType objType : otherSchema.getObjTypes().values()) {
final int objTypeStorageId = objType.storageId;
final byte[] objTypeBytes = ObjId.getMin(objTypeStorageId).getBytes();
// Add flags for FieldMonitors
for (Field> field : objType.fieldsAndSubFields.values()) {
final int fieldStorageId = field.storageId;
final Set> monitors = this.getFieldMonitorsForField(fieldStorageId);
if (monitors != null && monitors.stream().anyMatch(new FieldMonitorPredicate(objTypeBytes, fieldStorageId)))
cache.addFieldMonitor(objTypeStorageId, fieldStorageId);
}
// Add flags for DeleteMonitors
if (Optional.ofNullable(this.deleteMonitors)
.map(map -> map.get(objTypeStorageId))
.filter(map -> !map.isEmpty())
.isPresent())
cache.addDeleteMonitor(objTypeStorageId);
}
}
return cache;
}
}