UniqueTracker.java
/*
** Module : UniqueTracker.java
** Abstract : Tracks records for testing whether the unique index constraints are broken.
**
** Copyright (c) 2020-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- ---------------------------------------Description---------------------------------------
** 001 OM 20200110 Created initial version.
** ECF 20200211 Changes to integrate with DMO validation and flushing.
** CA 20200622 Small performance improvement - use IdentityHashMap if the key is java.lang.Class.
** 002 OM 20201002 Use DmoMeta cached information instead of map lookups.
** 003 CA 20220210 Removed Commitable.rollbackPending and its notifications, as there is no actual
** implementation of this method.
** OM 20221005 Do not save deleted transient records for rollback. Added UniqueIndex.toString() method.
** TJD 20221122 Reset rollback flag on commit
** 004 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 005 CA 20240507 Unique index validation must validate only dirty indexes, even for NEW records, if we are
** not in flush mode.
** 006 TJD 20240123 Java 17 compatibility updates.
** 007 CA 20240924 Do not push scopes to 'changes' and 'deletes' until the scope is used.
*/
/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
**
** Additional terms under GNU Affero GPL version 3 section 7:
**
** Under Section 7 of the GNU Affero GPL version 3, the following additional
** terms apply to the works covered under the License. These additional terms
** are non-permissive additional terms allowed under Section 7 of the GNU
** Affero GPL version 3 and may not be removed by you.
**
** 0. Attribution Requirement.
**
** You must preserve all legal notices or author attributions in the covered
** work or Appropriate Legal Notices displayed by works containing the covered
** work. You may not remove from the covered work any author or developer
** credit already included within the covered work.
**
** 1. No License To Use Trademarks.
**
** This license does not grant any license or rights to use the trademarks
** Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
** of Golden Code Development Corporation. You are not authorized to use the
** name Golden Code, FWD, or the names of any author or contributor, for
** publicity purposes without written authorization.
**
** 2. No Misrepresentation of Affiliation.
**
** You may not represent yourself as Golden Code Development Corporation or FWD.
**
** You may not represent yourself for publicity purposes as associated with
** Golden Code Development Corporation, FWD, or any author or contributor to
** the covered work, without written authorization.
**
** 3. No Misrepresentation of Source or Origin.
**
** You may not represent the covered work as solely your work. All modified
** versions of the covered work must be marked in a reasonable way to make it
** clear that the modified work is not originating from Golden Code Development
** Corporation or FWD. All modified versions must contain the notices of
** attribution required in this license.
*/
package com.goldencode.p2j.persist.orm;
import java.util.*;
import java.util.BitSet;
import java.util.List;
import java.util.concurrent.locks.*;
import com.goldencode.p2j.persist.*;
import com.goldencode.p2j.persist.Record;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;
import org.apache.commons.lang3.tuple.*;
/**
* Implements a tracker for records in unique indices in uncommitted transactions. This enables
* a proactive, synchronized check to ensure the insertion or update of a record will not cause
* a unique constraint violation, with respect to any uncommitted changes across FWD sessions.
* Otherwise, such a failure would not occur until the latter of two transactions containing a
* conflicting insert/update were committed. This would be too late in terms of legacy
* compatibility, since the 4GL reports this failure at the moment of the index update, not at
* the eventual transaction commit.
* <p>
* The overall structure of the {@code UniqueTracker} architecture is one map instance per unique
* index, per table/DMO type. The maps are keyed by {@code Key} which emulate the P4GL
* constraints from the point of view of the unique indexes. The values mapped are the {@code id}s
* of the records.
* <p>
* The exposed API allows to:
* <ul>
* <li>add new {@code Record} to tracking;</li>
* <li>test whether a specified {@code Record} is tracked;</li>
* <li>remove a specified {@code Record} (or using its {@code id}) from tracking;</li>
* </ul>
*
* The tracker also keeps track of the transactions and all elements associated with a transaction
* are processed when the transaction is committed (or rolled back).
*/
public class UniqueTracker
{
/** Logger */
private static final CentralLogger LOG = CentralLogger.get(UniqueTracker.class);
/** Cache of DMO implementation classes to instances of this class */
private static final Map<Class<? extends BaseRecord>, UniqueTracker> instances =
new IdentityHashMap<>();
/** Context local state for this session's uncommitted transaction data */
private static final ContextLocal<Context> context = new ContextLocal<Context>()
{
@Override protected Context initialValue() { return new Context(); }
@Override protected void cleanup(Context context) { context.cleanup(true); }
};
/**
* The list of {@code UniqueIndex} for a {@code DataModelObject}. When an {@code UniqueTracker}
* is created the DMO class is analyzed and the list of unique indexes is extracted. For each
* index there an {@code UniqueIndex} is created and added to this array.
*/
private final UniqueIndex[] uniqueIndexes;
/**
* Default constructor, used only by no-op subclass.
*
* @see #instantiate(DmoMeta)
*/
protected UniqueTracker()
{
this.uniqueIndexes = null;
}
/**
* Creates a new tracker for a designated {@code BaseRecord}. The {@code dmoClass} is used to
* extract the unique indexes and create an optimized internal data structure.
*
* @param dmoInfo
* The DMO meta information for DMO implementation class.
*/
private UniqueTracker(DmoMeta dmoInfo)
{
RecordMeta rm = dmoInfo.recordMeta;
BitSet[] uniqueBits = rm.getUniqueIndices();
List<UniqueIndex> indexList = new ArrayList<>(5);
for (BitSet b : uniqueBits)
{
indexList.add(new UniqueIndex(b));
}
uniqueIndexes = indexList.toArray(new UniqueIndex[indexList.size()]);
}
/**
* Get the context-local helper object which is the local session's access to the {@code
* UniqueTracker} API.
*
* @return Context-local helper object.
*/
public static Context getContext()
{
return context.get();
}
/**
* Get a {@code UniqueTracker} for a specified {@code DataModelObject}. If this is the first
* call for a specified class, a new instance is created.
*
* @param dmoInfo
* The DMO meta information for DMO implementation class.
*
* @return The {@code UniqueTracker} for the specified {@code DataModelObject}.
*/
public static UniqueTracker getInstance(DmoMeta dmoInfo)
{
Class<? extends Record> dmoClass = dmoInfo.getImplementationClass();
UniqueTracker ut = instances.get(dmoClass);
if (ut == null)
{
ut = instantiate(dmoInfo);
instances.put(dmoClass, ut);
}
return ut;
}
/**
* Instantiate the appropriate form of this class for the given DMO implementation class.
* This returns a no-op version for use with temp-table DMOs, and fully functioning version
* for persistent table DMOs. Temp-tables operate only in a single context. Therefore,
* validating against the database is sufficient to detect any unique constraint violation.
*
* @param dmoInfo
* The DMO meta information for DMO implementation class.
*
* @return A new instance of this class, possibly with some methods neutered.
*/
private static UniqueTracker instantiate(DmoMeta dmoInfo)
{
if (dmoInfo.isTempTable())
{
// temp-tables use a no-op implementation of this class
return new UniqueTracker()
{
@Override
protected final Token lockAndChange(BaseRecord dmo, Context context, String bufferName, boolean flush)
{ return null; }
@Override
protected final Token lockAndDelete(BaseRecord dmo, Context context) { return null; }
@Override
protected final void rollbackChange(Token token) { }
@Override
protected final void unlock(Token token) { }
};
}
return new UniqueTracker(dmoInfo);
}
/**
* Check the given record's state and update the minimal, necessary unique index(es), based
* on any dirty properties in the record, or update all unique indexes in the case that
* {@code dmo} has been newly created and was not previously persisted.
* <p>
* If the data within {@code dmo} violates an existing unique constraint, an error is
* raised. Any changes made up to that point are rolled back automatically.
*
* @param dmo
* Record whose state is driving an update to this tracker.
* @param context
* Uncommitted data managed for the current context.
* @param bufferName
* The name of the buffer. It is used when printing the error message, if any.
* @param flush
* Whether to flush the record; {@code false} to validate only.
*
* @return A token which contains information about the update operation. This token must
* be passed to either {@link #unlock(Token)} or to {@link #rollbackChange(Token)}
* in order to commit or roll back the update, respectively, and to ensure any
* locks acquired by the update are released.
*
* @throws ValidationException
* if the data in {@code dmo} represent a unique constraint violation within any
* session's uncommitted transactions.
*/
protected Token lockAndChange(BaseRecord dmo, Context context, String bufferName, boolean flush)
throws ValidationException
{
if (context.pendingChangesScopes == 0 && context.changes.scopes() <= 0)
{
LOG.severe("", new IllegalStateException("Empty scopes in UniqueTracker"));
return null;
}
// figure out which indexes need to be locked for update; for a newly created record, a new
// entry must be created for each unique index; otherwise, just dirty indices are updated
boolean full = flush && dmo.checkState(DmoState.NEW);
// affected pairs of Integers to Keys; the left element represents the position of
// the affected UniqueIndex in uniqueIndexes array, the right is the old value to be reset
// on rollback, or null if this operation represents a new insert
List<Pair<Integer, UniqueIndex.Key>> affected = new LinkedList<>();
Long id = dmo.primaryKey();
try
{
BitSet dirty = dmo.getDirtyProps();
int len = uniqueIndexes.length;
for (int i = 0; i < len; i++)
{
UniqueIndex ui = uniqueIndexes[i];
// only update UniqueIndex if we are indexing a new record or if one of its fields
// has been touched
if (full || dirty.intersects(ui.fieldMap))
{
// obtain lock; may block here
ui.lock();
// if an entry for this record exists in this UniqueIndex, remove it before
// trying to add new Key
UniqueIndex.Key oldVal = ui.entries.remove(id);
if (oldVal != null)
{
ui.records.remove(oldVal, id);
}
// remember changes in case rollback is needed; left element of pair is position
// of affected UniqueIndex instance in uniqueIndexes array; right element is old
// value of Key (null in full case)
affected.add(Pair.of(i, oldVal));
// attempt to store new Key, based on record's data
UniqueIndex.Key newVal = ui.createKey(dmo);
if (ui.records.containsKey(newVal))
{
reportUniqueConstraintViolation(dmo, newVal, bufferName);
}
// track unique key in shared data structures
ui.records.put(newVal, id);
ui.entries.put(id, newVal);
// track unique key in context-local data structure
while (context.pendingChangesScopes > 0)
{
context.changes.pushScope();
context.pendingChangesScopes--;
}
context.changes.add(false, newVal);
}
}
}
catch (ValidationException exc)
{
rollbackChange(id, affected);
throw exc;
}
return new Token(id, affected);
}
/**
* The given DMO has been deleted in the current transaction. If a unique index already is
* being tracked for this DMO, it indicates that the DMO previously was inserted or updated
* in the same transaction. In this case, remove its earlier unique index entry, but
* remember it in case we need to roll back this delete later. In the event there is no
* unique index already being tracked, it indicates that the record deleted was not
* previously inserted or updated during the current transaction. In this case, we store
* {@code null} for the key in the {@code deletes} we are tracking. Rollback code must
* tolerate this {@code null}.
* <p>
* This operation performs no validation, as no unique constraint can be violated by a
* delete.
*
* @param dmo
* DMO which was deleted.
* @param context
* Uncommitted data managed for the current context.
*
* @return A token which contains information about the update operation. This token must
* be passed to either {@link #unlock(Token)} or to {@link #rollbackChange(Token)}
* in order to commit or roll back the update, respectively, and to ensure any
* locks acquired by the update are released.
*/
protected Token lockAndDelete(BaseRecord dmo, Context context)
{
// affected pairs of Integers and Keys; the left element represents the position of the
// affected UniqueIndex in uniqueIndexes array, the right is the old value to be reset
// on rollback, or null if this operation represents the delete of a record not previously
// inserted or updated in this transaction
List<Pair<Integer, UniqueIndex.Key>> affected = new LinkedList<>();
Long id = dmo.primaryKey();
int len = uniqueIndexes.length;
for (int i = 0; i < len; i++)
{
UniqueIndex ui = uniqueIndexes[i];
// obtain lock; may block here
ui.lock();
// if an entry for this record exists in this UniqueIndex, remove it
UniqueIndex.Key oldVal = ui.entries.remove(id);
if (oldVal != null)
{
ui.records.remove(oldVal, id);
}
// remember changes in case rollback is needed; left element of pair is position of affected
// UniqueIndex instance in uniqueIndexes array; right element is old value of Key (null in the
// case of the delete of a record not previously inserted or updated in this transaction)
affected.add(Pair.of(i, oldVal));
if (!dmo.checkState(DmoState.NEW))
{
// track pair of record ID and old unique key in context-local data structure; null key
// has same meaning as above
// NOTE: do this only for non transient record. The transient records were not yet saved so there
// is no point in restoring them.
while (context.pendingDeletesScopes > 0)
{
context.deletes.pushScope();
context.pendingDeletesScopes--;
}
context.deletes.add(false, Pair.of(id, oldVal));
}
}
return new Token(id, affected);
}
/**
* Complete the operation begun by {@link #lockAndChange} or by
* {@link #lockAndDelete(BaseRecord, Context)}, using the token returned by the respective method.
* A commit is no more than the absence of a rollback and an unlocking of any {@code UniqueIndex}
* instances locked by the update or delete.
* <p>
* This method should be invoked when no unique constraint violation has occurred during a record
* validation event.
*
* @param token
* A token returned by {@link #lockAndChange} or by {@link
* #lockAndDelete(BaseRecord, Context)}, used to unlock any {@code UniqueIndex} instances
* instances locked by those methods.
*/
protected void unlock(Token token)
{
if (token == null || token.affected.isEmpty())
{
// no resources are locked
return;
}
for (Pair<Integer, UniqueIndex.Key> pair : token.affected)
{
int i = pair.getLeft();
uniqueIndexes[i].unlock();
}
}
/**
* Roll back an update operation on zero or more unique keys in this tracker, presumably in response
* to a unique constraint violation either generated by this tracker or which occurred during a database
* insert or update.
* <p>
* This method should be invoked when a unique constraint violation has occurred during a record
* validation event.
*
* @param token
* A token returned by {@link #lockAndChange}, used to rollback
* any partial or full updates made and to unlock any {@code UniqueIndex} instances locked by
* the update.
*/
protected void rollbackChange(Token token)
{
rollbackChange(token.id, token.affected);
}
/**
* Roll back an update operation on zero or more unique keys in this tracker, presumably in
* response to a unique constraint violation either generated by this tracker or which
* occurred during a database insert or update.
*
* @param id
* Primary key of the record for which the update operation was performed.
* @param affected
* A list of pairs of {@code UniqueIndex} positions in the {@code uniqueIndexes}
* array and corresponding {@code Key} instances to be restored (or {@code
* null} if the operation represented a new record insert).
*/
private void rollbackChange(Long id, List<Pair<Integer, UniqueIndex.Key>> affected)
{
try
{
Iterator<Pair<Integer, UniqueIndex.Key>> iter = affected.iterator();
while (iter.hasNext())
{
Pair<Integer, UniqueIndex.Key> pair = iter.next();
// access UniqueIndex instance
int i = pair.getLeft();
UniqueIndex ui = uniqueIndexes[i];
// remove new mappings, if any
UniqueIndex.Key newVal = ui.entries.remove(id);
if (newVal != null)
{
ui.records.remove(newVal);
}
// restore old mappings, if any
UniqueIndex.Key oldVal = pair.getRight();
if (oldVal != null)
{
ui.entries.put(id, oldVal);
ui.records.put(oldVal, id);
}
// unlock the UniqueIndex
ui.unlock();
// remove the pair, to prevent attempting rollback/unlock more than once
iter.remove();
}
}
finally
{
// unlock all affected resources, in case we exited the try block abnormally; no-op
// if already unlocked above
unlock(affected);
}
}
/**
* Unlock the {@code UniqueIndex} instances specified in the given list of pairs.
*
* @param affected
* A list of zero or more pairs of {@code UniqueIndex} positions in the {@code
* uniqueIndexes} array and {@code Key} instances. The {@code UniqueIndex}
* instances at the given positions are to be unlocked.
*/
private void unlock(List<Pair<Integer, UniqueIndex.Key>> affected)
{
for (Pair<Integer, UniqueIndex.Key> pair : affected)
{
int i = pair.getLeft();
uniqueIndexes[i].unlock();
}
}
/**
* Report a unique constraint violation using the expected, legacy error message.
*
* @param dmo
* Record which violated constraint.
* @param key
* Unique index key which contains the non-unique data.
* @param bufferName
* The name of the buffer. It is used when printing the error message, if any.
*
* @throws ValidationException
* always; describes the unique constraint violation.
*/
private void reportUniqueConstraintViolation(BaseRecord dmo, UniqueIndex.Key key, String bufferName)
throws ValidationException
{
BitSet uIndex = new BitSet();
for (int i : key.fieldPos)
{
uIndex.set(i);
}
String msg = Validation.getFailUniqueIndexMessage(uIndex, bufferName, dmo);
throw new ValidationException(msg, 132);
}
/**
* Context-local API through which {@code UniqueTracker} functionality should be accessed.
*/
public static class Context
implements Finalizable,
Commitable
{
/** Scoped list of uncommitted insert/update changes local to this context */
private final ScopedList<UniqueIndex.Key> changes = new ScopedList<>();
/** The number of pending scopes to be added to {@link #changes}. */
private int pendingChangesScopes = 0;
/** Scoped list of uncommitted deletes local to this context */
private final ScopedList<Pair<Long, UniqueIndex.Key>> deletes = new ScopedList<>();
/** The number of pending scopes to be added to {@link #deletes}. */
private int pendingDeletesScopes = 0;
/** Whether to reverse apply changes/deletes for the innermost scope at end of life */
private boolean rollback = false;
/** Whether the innermost scope represents a full (vs. sub) transaction */
private boolean fullTx = false;
public void register(TransactionManager.TransactionHelper txHelper, int blockDepth)
{
txHelper.registerFinalizable(this, false);
txHelper.registerCommit(this);
// log(">>> entry");
beginTxScope();
// log("<<< entry");
}
/**
* Check the given record's state and update the minimal, necessary unique index(es), based
* on any dirty properties in the record, or update all unique indexes in the case that
* {@code dmo} has been newly created and was not previously persisted.
* <p>
* If the data within {@code dmo} violates an existing unique constraint, an error is
* raised. Any changes made up to that point are rolled back automatically.
*
* @param tracker
* The unique tracker associated with the affected table.
* @param dmo
* Record whose state is driving an update to this tracker.
* @param bufferName
* The name of the buffer. It is used when printing the error message, if any.
* @param flush
* Whether to flush the record; {@code false} to validate only.
*
* @return A token which contains information about the update operation. This token must
* be passed to either {@link #unlock(Token)} or to {@link #rollbackChange(Token)}
* in order to commit or roll back the update, respectively, and to ensure any
* locks acquired by the update are released.
*
* @throws ValidationException
* if the data in {@code dmo} represent a unique constraint violation within any
* session's uncommitted transactions.
*/
public Token lockAndChange(UniqueTracker tracker, BaseRecord dmo, String bufferName, boolean flush)
throws ValidationException
{
return tracker.lockAndChange(dmo, this, bufferName, flush);
}
/**
* The given DMO has been deleted in the current transaction. If a unique index already is
* being tracked for this DMO, it indicates that the DMO previously was inserted or updated
* in the same transaction. In this case, remove its earlier unique index entry, but
* remember it in case we need to roll back this delete later. In the event there is no
* unique index already being tracked, it indicates that the record deleted was not
* previously inserted or updated during the current transaction. In this case, we store
* {@code null} for the key in the {@code deletes} we are tracking. Rollback code must
* tolerate this {@code null}.
* <p>
* This operation performs no validation, as no unique constraint can be violated by a
* delete.
*
* @param tracker
* The unique tracker associated with the affected table.
* @param dmo
* DMO which was deleted.
*
* @return A token which contains information about the update operation. This token must
* be passed to either {@link #unlock(Token)} or to {@link #rollbackChange(Token)}
* in order to commit or roll back the update, respectively, and to ensure any
* locks acquired by the update are released.
*/
public Token lockAndDelete(UniqueTracker tracker, BaseRecord dmo)
{
return tracker.lockAndDelete(dmo, this);
}
/**
* Roll back an update operation on zero or more unique keys in this tracker, presumably
* in response to a unique constraint violation either generated by this tracker or which
* occurred during a database insert or update.
* <p>
* This method should be invoked when a unique constraint violation has occurred during a
* record validation event.
*
* @param tracker
* The unique tracker associated with the affected table.
* @param token
* A token returned by {@link #lockAndChange}, used
* to roll back any partial or full updates made and to unlock any {@code UniqueIndex}
* instances locked by the update.
*/
public void rollbackChange(UniqueTracker tracker, Token token)
{
tracker.rollbackChange(token);
}
/**
* Complete the operation begun by {@link #lockAndChange}
* or by {@link #lockAndDelete(UniqueTracker, BaseRecord)}, using the token returned by the
* respective method. A commit is no more than the absence of a rollback and an unlocking
* of any {@code UniqueIndex} instances locked by the update or delete.
* <p>
* This method should be invoked when no unique constraint violation has occurred during a
* record validation event.
*
* @param token
* A token returned by {@link #lockAndChange} or by
* {@link #lockAndDelete(UniqueTracker, BaseRecord)}, used to unlock any {@code
* UniqueIndex} instances locked by those methods.
*/
public void unlock(UniqueTracker tracker, Token token)
{
tracker.unlock(token);
}
/**
* Callback when a looping block is iterated, before business logic in the block is
* executed. End the current scope and begin a new one.
*/
@Override
public void iterate()
{
// log(">>> iterate");
endTxScope();
beginTxScope();
// log("<<< iterate");
}
/**
* Callback when a block is retried, before business logic in the block is executed.
* End the current scope and begin a new one.
*/
@Override
public void retry()
{
// log(">>> retry");
endTxScope();
beginTxScope();
// log("<<< retry");
}
/**
* Callback when a block is finished. End the current scope.
*/
@Override
public void finished()
{
// log(">>> finished");
endTxScope();
// log("<<< finished");
}
/**
* No-op block callback.
*/
@Override
public void deleted()
{
}
/**
* Callback when a full or sub-transaction block is committed.
*
* @param fullTx
* {@code true} if the block is a full transaction block; {@code false} if it is
* a sub-transaction block.
*/
@Override
public void commit(boolean fullTx)
{
// log("commit");
this.fullTx = fullTx;
this.rollback = false;
}
/**
* Callback when a full or sub-transaction block is rolled back.
*
* @param fullTx
* {@code true} if the block is a full transaction block; {@code false} if it is
* a sub-transaction block.
*/
@Override
public void rollback(boolean fullTx)
{
// log("rollback");
this.fullTx = fullTx;
this.rollback = true;
}
/**
* No-op callback.
*/
@Override
public void validate(boolean transaction, boolean aggressiveFlush)
{
}
/*
// temporary for debugging
private final Set<String> entries = new LinkedHashSet<>();
private final Set<String> finishes = new LinkedHashSet<>();
private void log(String msg)
{
Throwable t = new Throwable();
StackTraceElement[] ste = t.getStackTrace();
String elem = "UNINITIALIZED";
if (ste != null)
{
for (int i = 0; i < ste.length; i++)
{
StackTraceElement e = ste[i];
String className = e.getClassName();
if (className.startsWith("com.goldencode.hotel."))
{
elem = e.toString();
if (msg.startsWith(">>> entry"))
{
entries.add(elem);
}
else if (msg.startsWith(">>> finished"))
{
finishes.add(elem);
}
msg += (" " + elem);
break;
}
}
}
int count = changes.scopes();
if (count == 0 && msg.startsWith(">>>"))
{
entries.clear();
finishes.clear();
LOG.finer();
LOG.finer("---------------------------- BEGIN ----------------------------");
}
LOG.finer("#" + count + " --> " + msg);
if (count == 0 && msg.startsWith("<<<"))
{
LOG.finer("----------------------------- END -----------------------------");
LOG.finer();
Set<String> unmatched = new LinkedHashSet<>(finishes);
unmatched.removeAll(entries);
if (!unmatched.isEmpty())
{
LOG.finer("finished() calls unmatched by entry() calls:");
for (String e : unmatched)
{
LOG.finer(e);
}
LOG.finer();
}
}
}
*/
/**
* Begin a transaction scope (full or sub-). This event causes new scopes to be pushed
* onto the scoped lists of changes and deletes.
*/
private void beginTxScope()
{
// changes.pushScope();
pendingChangesScopes++;
// deletes.pushScope();
pendingDeletesScopes++;
}
/**
* End a transaction scope (full or sub-), resulting in a commit or rollback of any changes
* or deletes in the context's current, uncommitted database transaction, with respect to
* the {@code UniqueTracker} instances active for this context.
* <p>
* A commit simply rolls up any changes/deletes for the current sub-transaction scope to
* the enclosing scope, if any. A rollback reverse applies the changes/deletes for the current
* current (sub-)transaction scope. Either way, the current scope is popped.
*/
private void endTxScope()
{
if (pendingChangesScopes == 0 && changes.scopes() <= 0)
{
LOG.severe("", new IllegalStateException("Imbalanced Scopes in UniqueTracker"));
return;
}
try
{
if (fullTx)
{
cleanup(false);
}
else if (rollback)
{
// roll back insert/update changes by removing each of them from their index
if (pendingChangesScopes == 0)
{
for (UniqueIndex.Key key : changes.sublistAtScope(0))
{
key.removeFromIndex();
}
}
if (pendingDeletesScopes == 0)
{
// roll back deletes by adding any deleted unique index keys back to their index;
// if the key is null, it indicates the delete being rolled back was of a record
// not inserted or updated in this transaction, so do not try to re-insert in this
// case
for (Pair<Long, UniqueIndex.Key> pair : deletes.sublistAtScope(0))
{
Long id = pair.getLeft();
UniqueIndex.Key key = pair.getRight();
if (key != null)
{
// if key was not null, it indicates the delete that we are rolling back
// was of a record we were tracking, so it had been inserted and/or updated
// in the current transaction previous to the delete.
key.addToIndex(id);
// if the key was null, the delete being rolled back was of a record not
// otherwise touched by the current transaction; therefore, the rollback of
// the delete already is reflected in this session's version of the
// database, so we do not need to track it separately as uncommitted data
}
}
}
}
else
{
// on sub-transaction commit, reversible changes need to roll up to parent scope
if (pendingChangesScopes == 0)
{
changes.rollUp();
}
if (pendingDeletesScopes == 0)
{
deletes.rollUp();
}
}
}
finally
{
if (pendingChangesScopes == 0)
{
changes.popScope();
}
else
{
pendingChangesScopes--;
}
if (pendingDeletesScopes == 0)
{
deletes.popScope();
}
else
{
pendingDeletesScopes--;
}
}
}
/**
* Clean up as a block is finishing its processing. Optionally cleanup as the current
* context is exiting.
*
* @param exit
* {@code true} if the context is exiting, not just the current block. This
* represents an emergency cleanup.
*/
private void cleanup(boolean exit)
{
// TODO: make lock/unlock more efficient
for (UniqueIndex.Key key : changes)
{
key.removeFromIndex();
}
// TODO: is this sufficient cleanup on context end? Don't we need to rollback this
// context's impact on shared resources?
if (exit)
{
pendingChangesScopes = 0;
while (changes.scopes() > 0)
{
changes.popScope();
}
pendingDeletesScopes = 0;
while (deletes.scopes() > 0)
{
deletes.popScope();
}
}
}
}
/**
* An object which holds information about the set of {@code UniqueIndex} objects locked and
* updated by an operation on a particular {@code UniqueTracker} instance. A token is created
* by {@link UniqueTracker#lockAndChange} or {@link
* UniqueTracker#lockAndDelete(BaseRecord, Context)}, and must subsequently be passed to
* {@link UniqueTracker#unlock(Token)} or {@link UniqueTracker#rollbackChange(Token)}.
* The latter method uses the token to roll back the changes made in an update or delete
* operation. Both methods use the token to unlock any locked {@code UniqueIndex} instances.
*/
public static class Token
{
/** Primary key of record indexed by a unit of work */
private final Long id;
/** Pairs of {@code UniqueIndex} offsets and old {@code Key} values */
private final List<Pair<Integer, UniqueIndex.Key>> affected;
/**
* Constructor.
*
* @param id
* Primary key of the record involved in the operation described by this token.
* @param affected
* Pairs of {@code UniqueIndex} offsets in their array within the tracker, and
* {@code Key} instances which were affected by the operation.
*/
private Token(long id, List<Pair<Integer, UniqueIndex.Key>> affected)
{
this.id = id;
this.affected = affected;
}
}
/**
* The data related to a specified unique index. This holds all the unique records added for
* an index of a specific {@code DMO}.
*/
private static class UniqueIndex
{
/** Mutex lock */
private final ReentrantLock lock = new ReentrantLock();
/**
* The set of fields of the {@code BaseRecord} which comprise the index. The order is
* irrelevant and this array uses the 'natural' order in which the fields were declared at
* the definition of the table.
*/
private final int[] fieldPos;
/** Bitset whereby set bits describe the components of the index by position in the data */
private final BitSet fieldMap;
/**
* The set of unique records, keyed by {@code Key}, mapping to the original
* {@code id} of the record.
*/
private final Map<Key, Long> records = new HashMap<>();
/** Unique index keys mapped by primary keys */
private final Map<Long, Key> entries = new HashMap<>();
/**
* Creates a new structure that contains data related to an unique index of a table.
*
* @param fieldMap
* The bitmap of the index, that is, the bits corresponding to field component of
* the index.
*/
public UniqueIndex(BitSet fieldMap)
{
this.fieldMap = fieldMap;
// convert from BitSet to an array of the offsets of the component fields. The size of
// the array is the length of index component. We can now access the k field directly
// using record.data[fieldPos[k]].
this.fieldPos = fieldMap.stream().toArray();
}
/**
* Lock this resource exclusively.
*/
void lock()
{
lock.lock();
}
/**
* Release the lock on this resource.
*/
void unlock()
{
lock.unlock();
}
/**
* Create a {@code Key} for the given record within the current index. This is just
* a wrapper for the {@code Key} constructor with access to its implicit {@code
* UniqueIndex.this} reference.
*
* @param dmo
* Database record.
*
* @return {@code Key} instance.
*/
Key createKey(BaseRecord dmo)
{
return new Key(this, dmo.data);
}
/**
* The key used for tracking a record within an unique index.
*/
private class Key
{
/** The snapshot of the record at the moment of indexing. */
private final Object[] snapshot;
/**
* The fields that the unique index is compounded of. Having the offsets in an int array
* is faster than computing them bitwise from a {@code BitSet}.
*/
private final int[] fieldPos;
/** The pre-computed {@code hash} code */
private final int hash;
/**
* Flags whether the record has {@code null} values for fields components of this unique
* index. In this case the record is unique.
*/
private boolean hasNulls;
/**
* Creates a new, immutable {@code Key} object. For performance reasons, the
* {@code hash} code will be computed only once and the {@code hasNulls} will also be
* evaluated.
*
* @param uniqueIndex
* The components of the index, by their position in {@code data} array.
* @param data
* The values of the fields in the record (a reference will be saved, not
* copied).
*/
public Key(UniqueIndex uniqueIndex, Object[] data)
{
snapshot = data;
fieldPos = uniqueIndex.fieldPos;
hash = computeHash();
}
/**
* Test for equality with a specified object. The test is done using the 4GL uniqueness
* semantics: two records are unique from the point of view of an unique index if the
* compound fields (their index in {@code data} array is specified in the {@code
* fieldPos}) must be different except when one of them is {@code null}, in which case
* the records are unique.
*
* @param o
* The object to be tested against.
*
* @return {@code true} if and only if the two keys are equals from the point of view of
* the 4GL uniqueness semantics.
*/
@Override
public boolean equals(Object o)
{
// basic tests:
// identity:
if (this == o)
{
return true;
}
// null comparison
if (o == null)
{
return false;
}
// just in case this was skipped: check hashes:
Key that = (Key) o;
if (this.hash != that.hash)
{
return false;
}
// in P4GL 'unique' semantics, a record with an unknown (null) field as index component
// will make the record unique; this intentionally departs from the implementations of
// hashCode and equals being consistent, to ensure separate Key instances are
// stored if all else is the same, except that one or both contain null
if (this.hasNulls || that.hasNulls)
{
return false;
}
// check for equality of fields in index:
int len = fieldPos.length;
for (int i = 0; i < len; i++)
{
int position = fieldPos[i];
if (!Objects.equals(this.snapshot[position], that.snapshot[position]))
// if (!this.snapshot[position].equals(that.snapshot[position]))
{
return false;
}
}
return true;
}
/**
* Composes and return a short description of this object which includes the values of the indexed
* fields.
*
* @return a short description of this object.
*/
@Override
public String toString()
{
StringBuilder sb = new StringBuilder("Kuid{");
for (int i = 0; i < fieldPos.length; i++)
{
if (i != 0)
{
sb.append(":");
}
sb.append(snapshot[fieldPos[i]]);
}
sb.append('}');
return sb.toString();
}
/**
* Obtain the hash code for this unique key.
*
* @return the hash code for this unique key.
*/
@Override
public int hashCode()
{
return hash;
}
/**
* Add this key to its enclosing {@code UniqueIndex}. The index is locked before the
* operation and released after.
*
* @param id
* Primary key to associate with this key.
*/
void addToIndex(Long id)
{
lock();
try
{
records.put(this, id);
entries.put(id, this);
}
finally
{
unlock();
}
}
/**
* Remove this key from its enclosing {@code UniqueIndex}. The index is locked before
* the operation and released after.
*/
void removeFromIndex()
{
lock();
try
{
Long id = records.remove(this);
if (id != null)
{
entries.remove(id);
}
}
finally
{
unlock();
}
}
/**
* Computes the hash code for this unique key. Only the compound fields of the current index
* from the record are taken into consideration, the other are ignored.
*
* @return the hash code for this unique key.
*/
private int computeHash()
{
int res = 0;
int len = fieldPos.length;
for (int i = 0; i < len; i++)
{
int position = fieldPos[i];
Object o = snapshot[position];
if (o == null)
{
hasNulls = true;
}
res = res * 37 + (o == null ? 97 : o.hashCode());
}
return res;
}
}
}
}