/*
 * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional information regarding
 * copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance with the License. You may obtain a
 * copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */

package org.apache.fluo.core.impl;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.Spliterators;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.Iterables;
import com.google.common.collect.Iterators;
import com.google.common.collect.MoreCollectors;
import com.google.common.collect.Sets;
import org.apache.accumulo.core.client.ConditionalWriter;
import org.apache.accumulo.core.client.ConditionalWriter.Result;
import org.apache.accumulo.core.client.ConditionalWriter.Status;
import org.apache.accumulo.core.client.IteratorSetting;
import org.apache.accumulo.core.client.Scanner;
import org.apache.accumulo.core.client.TableNotFoundException;
import org.apache.accumulo.core.data.ColumnUpdate;
import org.apache.accumulo.core.data.Condition;
import org.apache.accumulo.core.data.ConditionalMutation;
import org.apache.accumulo.core.data.Key;
import org.apache.accumulo.core.data.Mutation;
import org.apache.accumulo.core.data.Range;
import org.apache.accumulo.core.data.Value;
import org.apache.accumulo.core.security.Authorizations;
import org.apache.fluo.accumulo.iterators.PrewriteIterator;
import org.apache.fluo.accumulo.util.ColumnConstants;
import org.apache.fluo.accumulo.util.ColumnType;
import org.apache.fluo.accumulo.util.ReadLockUtil;
import org.apache.fluo.accumulo.values.DelLockValue;
import org.apache.fluo.accumulo.values.DelReadLockValue;
import org.apache.fluo.accumulo.values.LockValue;
import org.apache.fluo.accumulo.values.ReadLockValue;
import org.apache.fluo.api.client.AbstractTransactionBase;
import org.apache.fluo.api.client.Snapshot;
import org.apache.fluo.api.client.SnapshotBase;
import org.apache.fluo.api.client.scanner.ScannerBuilder;
import org.apache.fluo.api.data.Bytes;
import org.apache.fluo.api.data.Column;
import org.apache.fluo.api.data.RowColumn;
import org.apache.fluo.api.data.Span;
import org.apache.fluo.api.exceptions.AlreadySetException;
import org.apache.fluo.api.exceptions.CommitException;
import org.apache.fluo.api.exceptions.FluoException;
import org.apache.fluo.core.async.AsyncCommitObserver;
import org.apache.fluo.core.async.AsyncConditionalWriter;
import org.apache.fluo.core.async.AsyncTransaction;
import org.apache.fluo.core.async.SyncCommitObserver;
import org.apache.fluo.core.exceptions.AlreadyAcknowledgedException;
import org.apache.fluo.core.exceptions.StaleScanException;
import org.apache.fluo.core.impl.scanner.ScannerBuilderImpl;
import org.apache.fluo.core.oracle.Stamp;
import org.apache.fluo.core.util.ByteUtil;
import org.apache.fluo.core.util.ColumnUtil;
import org.apache.fluo.core.util.ConditionalFlutation;
import org.apache.fluo.core.util.FluoCondition;
import org.apache.fluo.core.util.Flutation;
import org.apache.fluo.core.util.Hex;
import org.apache.fluo.core.util.SpanUtil;
import org.apache.fluo.core.util.UtilWaitThread;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.apache.fluo.api.observer.Observer.NotificationType.STRONG;
import static org.apache.fluo.api.observer.Observer.NotificationType.WEAK;

/**
 * Transaction implementation
 */
public class TransactionImpl extends AbstractTransactionBase implements AsyncTransaction, Snapshot {

  public static final byte[] EMPTY = new byte[0];
  public static final Bytes EMPTY_BS = Bytes.of(EMPTY);
  private static final Bytes DELETE =
      Bytes.of("special delete object f804266bf94935edd45ae3e6c287b93c1814295c");
  private static final Bytes NTFY_VAL =
      Bytes.of("special ntfy value ce0c523e6e4dc093be8a2736b82eca1b95f97ed4");
  private static final Bytes RLOCK_VAL =
      Bytes.of("special rlock value 94da84e7796ff3b23b779805d820a33f1997cb8b");

  // added to avoid findbugs false positive
  private static final Supplier<Void> NULLS = () -> null;

  private static boolean isWrite(Bytes val) {
    return val != NTFY_VAL && val != RLOCK_VAL;
  }

  private static boolean isDelete(Bytes val) {
    return val == DELETE;
  }

  private static boolean isReadLock(Bytes val) {
    return val == RLOCK_VAL;
  }

  private enum TxStatus {
    OPEN, COMMIT_STARTED, COMMITTED, CLOSED
  }

  private final long startTs;
  private final Map<Bytes, Map<Column, Bytes>> updates = new HashMap<>();
  private final Map<Bytes, Set<Column>> weakNotifications = new HashMap<>();
  private final Set<Column> observedColumns;
  private final Environment env;
  private final Map<Bytes, Set<Column>> columnsRead = new HashMap<>();
  // Tracks row columns that were observed to have had a read lock in the past.
  private final Map<Bytes, Set<Column>> readLocksSeen = new HashMap<>();
  private final TxStats stats;
  private Notification notification;
  private Notification weakNotification;
  private TransactorNode tnode = null;
  private TxStatus status = TxStatus.OPEN;
  private boolean commitAttempted = false;
  private AsyncReader asyncReader = null;

  private Authorizations scanTimeAuthz;

  public TransactionImpl(Environment env, Notification trigger, long startTs,
      Authorizations scanTimeAuthz) {
    Objects.requireNonNull(env, "environment cannot be null");
    Preconditions.checkArgument(startTs >= 0, "startTs cannot be negative");
    this.env = env;
    this.scanTimeAuthz = Objects.requireNonNull(scanTimeAuthz);
    this.stats = new TxStats(env);
    this.startTs = startTs;
    this.observedColumns = env.getConfiguredObservers().getObservedColumns(STRONG);

    if (trigger != null
        && env.getConfiguredObservers().getObservedColumns(WEAK).contains(trigger.getColumn())) {
      this.weakNotification = trigger;
    } else {
      this.notification = trigger;
    }

    if (notification != null) {
      Map<Column, Bytes> colUpdates = new HashMap<>();
      colUpdates.put(notification.getColumn(), NTFY_VAL);
      updates.put(notification.getRow(), colUpdates);
    }
  }

  public TransactionImpl(Environment env, Notification trigger) {
    this(env, trigger, allocateTimestamp(env).getTxTimestamp(), env.getAuthorizations());
  }

  public TransactionImpl(Environment env) {
    this(env, null, allocateTimestamp(env).getTxTimestamp(), env.getAuthorizations());
  }

  public TransactionImpl(Environment env, long startTs) {
    this(env, null, startTs, env.getAuthorizations());
  }

  public TransactionImpl(Environment env, Authorizations scanTimeAuthz) {
    this(env, null, allocateTimestamp(env).getTxTimestamp(), scanTimeAuthz);
  }

  private static Stamp allocateTimestamp(Environment env) {
    return env.getSharedResources().getTimestampTracker().allocateTimestamp();
  }

  @Override
  public Bytes get(Bytes row, Column column) {
    checkIfOpen();
    // TODO cache? precache?
    return get(row, Collections.singleton(column)).get(column);
  }

  @Override
  public Map<Column, Bytes> get(Bytes row, Set<Column> columns) {
    checkIfOpen();
    return getImpl(row, columns);
  }

  @Override
  public Map<Bytes, Map<Column, Bytes>> get(Collection<Bytes> rows, Set<Column> columns) {
    checkIfOpen();

    if (rows.isEmpty() || columns.isEmpty()) {
      return Collections.emptyMap();
    }

    env.getSharedResources().getVisCache().validate(columns);

    ParallelSnapshotScanner pss =
        new ParallelSnapshotScanner(rows, columns, env, startTs, stats, readLocksSeen, kve -> {
        }, this.scanTimeAuthz);

    Map<Bytes, Map<Column, Bytes>> ret = pss.scan();

    for (Entry<Bytes, Map<Column, Bytes>> entry : ret.entrySet()) {
      updateColumnsRead(entry.getKey(), entry.getValue().keySet());
    }

    return ret;
  }

  @Override
  public Map<RowColumn, Bytes> get(Collection<RowColumn> rowColumns) {
    checkIfOpen();
    return getImpl(rowColumns, kve -> {
    });
  }

  private Map<Column, Bytes> getImpl(Bytes row, Set<Column> columns) {

    // TODO push visibility filtering to server side?

    env.getSharedResources().getVisCache().validate(columns);

    boolean shouldCopy = false;

    for (Column column : columns) {
      if (column.isVisibilitySet()) {
        shouldCopy = true;
      }
    }

    SnapshotScanner.Opts opts;
    if (shouldCopy) {
      HashSet<Column> cols = new HashSet<>();
      for (Column column : columns) {
        if (column.isVisibilitySet()) {
          cols.add(new Column(column.getFamily(), column.getQualifier()));
        } else {
          cols.add(column);
        }
      }
      opts = new SnapshotScanner.Opts(Span.exact(row), columns, true, this.scanTimeAuthz);
    } else {
      opts = new SnapshotScanner.Opts(Span.exact(row), columns, true, this.scanTimeAuthz);
    }

    Map<Column, Bytes> ret = new HashMap<>();
    Set<Column> readLockCols = null;

    for (Entry<Key, Value> kve : new SnapshotScanner(env, opts, startTs, stats)) {

      Column col = ColumnUtil.convert(kve.getKey());
      if (shouldCopy && !columns.contains(col)) {
        continue;
      }

      if (ColumnType.from(kve.getKey()) == ColumnType.RLOCK) {
        if (readLockCols == null) {
          readLockCols = readLocksSeen.computeIfAbsent(row, k -> new HashSet<>());
        }
        readLockCols.add(col);
      } else {
        ret.put(col, Bytes.of(kve.getValue().get()));
      }
    }

    // only update columns read after successful read
    updateColumnsRead(row, columns);

    return ret;
  }

  private Map<RowColumn, Bytes> getImpl(Collection<RowColumn> rowColumns,
      Consumer<Entry<Key, Value>> writeLocksSeen) {
    if (rowColumns.isEmpty()) {
      return Collections.emptyMap();
    }

    ParallelSnapshotScanner pss = new ParallelSnapshotScanner(rowColumns, env, startTs, stats,
        readLocksSeen, writeLocksSeen, this.scanTimeAuthz);

    Map<Bytes, Map<Column, Bytes>> scan = pss.scan();
    Map<RowColumn, Bytes> ret = new HashMap<>();

    for (Entry<Bytes, Map<Column, Bytes>> entry : scan.entrySet()) {
      updateColumnsRead(entry.getKey(), entry.getValue().keySet());
      for (Entry<Column, Bytes> colVal : entry.getValue().entrySet()) {
        ret.put(new RowColumn(entry.getKey(), colVal.getKey()), colVal.getValue());
      }
    }

    return ret;
  }

  @Override
  public CompletableFuture<Bytes> getAsync(Bytes row, Column column) {
    return getAsyncReader().get(row, column);
  }

  @Override
  public CompletableFuture<Bytes> getAsync(Bytes row, Column column, Bytes defaultValue) {
    return getAsyncReader().get(row, column, defaultValue);
  }

  @Override
  public CompletableFuture<String> getsAsync(String row, Column column) {
    return getAsyncReader().gets(row, column);
  }

  @Override
  public CompletableFuture<String> getsAsync(String row, Column column, String defaultValue) {
    return getAsyncReader().gets(row, column, defaultValue);
  }

  private AsyncReader getAsyncReader() {
    if (asyncReader == null) {
      asyncReader = new AsyncReader(this);
    }
    return asyncReader;
  }

  @Override
  public ScannerBuilder scanner() {
    checkIfOpen();
    return new ScannerBuilderImpl(this, this.scanTimeAuthz);
  }

  private void updateColumnsRead(Bytes row, Set<Column> columns) {
    Set<Column> colsRead = columnsRead.get(row);
    if (colsRead == null) {
      colsRead = new HashSet<>();
      columnsRead.put(row, colsRead);
    }
    colsRead.addAll(columns);
  }

  void setReadLock(Bytes row, Column col) {
    checkIfOpen();
    Objects.requireNonNull(row);
    Objects.requireNonNull(col);

    if (col.getFamily().equals(ColumnConstants.NOTIFY_CF)) {
      throw new IllegalArgumentException(ColumnConstants.NOTIFY_CF + " is a reserved family");
    }

    env.getSharedResources().getVisCache().validate(col);

    Map<Column, Bytes> colUpdates = updates.computeIfAbsent(row, k -> new HashMap<>());
    Bytes curVal = colUpdates.get(col);
    if (curVal != null && (isWrite(curVal) || isDelete(curVal))) {
      throw new AlreadySetException("Attemped read lock after write lock " + row + " " + col);
    }

    colUpdates.put(col, RLOCK_VAL);
  }

  @Override
  public SnapshotBase withReadLock() {
    return new ReadLockSnapshot(this);
  }

  @Override
  public void set(Bytes row, Column col, Bytes value) throws AlreadySetException {
    checkIfOpen();
    Objects.requireNonNull(row);
    Objects.requireNonNull(col);
    Objects.requireNonNull(value);

    if (col.getFamily().equals(ColumnConstants.NOTIFY_CF)) {
      throw new IllegalArgumentException(ColumnConstants.NOTIFY_CF + " is a reserved family");
    }

    env.getSharedResources().getVisCache().validate(col);

    Map<Column, Bytes> colUpdates = updates.computeIfAbsent(row, k -> new HashMap<>());

    Bytes curVal = colUpdates.get(col);
    if (curVal != null && isWrite(curVal)) {
      throw new AlreadySetException("Value already set " + row + " " + col);
    }
    colUpdates.put(col, value);
  }

  @Override
  public void setWeakNotification(Bytes row, Column col) {
    checkIfOpen();
    Objects.requireNonNull(row);
    Objects.requireNonNull(col);

    if (!env.getConfiguredObservers().getObservedColumns(WEAK).contains(col)) {
      throw new IllegalArgumentException("Column not configured for weak notifications " + col);
    }

    env.getSharedResources().getVisCache().validate(col);

    Set<Column> columns = weakNotifications.get(row);
    if (columns == null) {
      columns = new HashSet<>();
      weakNotifications.put(row, columns);
    }

    columns.add(col);
  }

  @Override
  public void delete(Bytes row, Column col) throws AlreadySetException {
    checkIfOpen();
    Objects.requireNonNull(row);
    Objects.requireNonNull(col);
    set(row, col, DELETE);
  }

  private ConditionalFlutation prewrite(ConditionalFlutation cm, Bytes row, Column col, Bytes val,
      Bytes primaryRow, Column primaryColumn, boolean isTriggerRow) {
    IteratorSetting iterConf = new IteratorSetting(10, PrewriteIterator.class);
    PrewriteIterator.setSnaptime(iterConf, startTs);
    boolean isTrigger = isTriggerRow && col.equals(notification.getColumn());
    if (isTrigger) {
      PrewriteIterator.enableAckCheck(iterConf, notification.getTimestamp());
    }

    if (isReadLock(val)) {
      PrewriteIterator.setReadlock(iterConf);
    }

    Condition cond = new FluoCondition(env, col).setIterators(iterConf);

    if (cm == null) {
      cm = new ConditionalFlutation(env, row, cond);
    } else {
      cm.addCondition(cond);
    }

    if (isWrite(val) && !isDelete(val)) {
      cm.put(col, ColumnType.DATA.encode(startTs), val.toArray());
    }

    if (isReadLock(val)) {
      cm.put(col, ColumnType.RLOCK.encode(ReadLockUtil.encodeTs(startTs, false)),
          ReadLockValue.encode(primaryRow, primaryColumn, getTransactorID()));
    } else {
      cm.put(col, ColumnType.LOCK.encode(startTs), LockValue.encode(primaryRow, primaryColumn,
          isWrite(val), isDelete(val), isTriggerRow, getTransactorID()));
    }

    return cm;
  }

  private ConditionalFlutation prewrite(Bytes row, Column col, Bytes val, Bytes primaryRow,
      Column primaryColumn, boolean isTriggerRow) {
    return prewrite(null, row, col, val, primaryRow, primaryColumn, isTriggerRow);
  }

  private void prewrite(ConditionalFlutation cm, Column col, Bytes val, Bytes primaryRow,
      Column primaryColumn, boolean isTriggerRow) {
    prewrite(cm, null, col, val, primaryRow, primaryColumn, isTriggerRow);
  }

  public static class CommitData {
    ConditionalWriter cw;
    private Bytes prow;
    private Column pcol;
    private Bytes pval;

    private HashSet<Bytes> acceptedRows;
    private Map<Bytes, Set<Column>> rejected = null;

    private void addPrimaryToRejected() {
      rejected = Collections.singletonMap(prow, Collections.singleton(pcol));
    }

    private void addToRejected(Bytes row, Set<Column> columns) {
      if (rejected == null) {
        rejected = new HashMap<>();
      }

      Set<Column> ret = rejected.put(row, columns);
      if (ret != null) {
        throw new IllegalStateException();
      }
    }

    private Map<Bytes, Set<Column>> getRejected() {
      if (rejected == null) {
        return Collections.emptyMap();
      }

      return rejected;
    }

    @Override
    public String toString() {
      return prow + " " + pcol + " " + pval + " " + getRejected().size();
    }

    public String getShortCollisionMessage() {
      StringBuilder sb = new StringBuilder();
      if (!getRejected().isEmpty()) {
        int numCollisions = 0;
        for (Set<Column> cols : getRejected().values()) {
          numCollisions += cols.size();
        }

        sb.append("Collisions(");
        sb.append(numCollisions);
        sb.append("):");

        String sep = "";
        outer: for (Entry<Bytes, Set<Column>> entry : getRejected().entrySet()) {
          Bytes row = entry.getKey();
          for (Column col : entry.getValue()) {
            sb.append(sep);
            sep = ", ";
            Hex.encNonAscii(sb, row);
            sb.append(" ");
            Hex.encNonAscii(sb, col, " ");
            if (sb.length() > 100) {
              sb.append(" ...");
              break outer;
            }
          }
        }
      }

      return sb.toString();
    }

    // async stuff
    private AsyncConditionalWriter acw;
    private AsyncConditionalWriter bacw;
    private AsyncCommitObserver commitObserver;

  }

  private boolean isTriggerRow(Bytes row) {
    return notification != null && notification.getRow().equals(row);
  }

  public boolean preCommit(CommitData cd) {
    return preCommit(cd, null);
  }

  @VisibleForTesting
  public boolean preCommit(CommitData cd, RowColumn primary) {

    synchronized (this) {
      checkIfOpen();
      status = TxStatus.COMMIT_STARTED;
      commitAttempted = true;
    }

    SyncCommitObserver sco = new SyncCommitObserver();
    cd = setUpBeginCommitAsync(cd, sco, primary);
    if (cd != null) {
      beginCommitAsyncTest(cd);
    }
    try {
      sco.waitForCommit();
    } catch (AlreadyAcknowledgedException e) {
      throw e;
    } catch (CommitException e) {
      return false;
    }
    return true;
  }

  /**
   * This function helps handle the following case
   *
   * <OL>
   * <LI>TX1 locks r1 col1
   * <LI>TX1 fails before unlocking
   * <LI>TX2 attempts to write r1:col1 w/o reading it
   * </OL>
   *
   * <p>
   * In this case TX2 would not roll back TX1, because it never read the column. This function
   * attempts to handle this case if TX2 fails. Only doing this in case of failures is cheaper than
   * trying to always read unread columns.
   *
   * @param cd Commit data
   */
  private void readUnread(CommitData cd, Consumer<Entry<Key, Value>> writeLocksSeen) {
    // TODO need to keep track of ranges read (not ranges passed in, but actual data read... user
    // may not iterate over entire range
    Collection<RowColumn> rowColumnsToRead = new ArrayList<>();

    for (Entry<Bytes, Set<Column>> entry : cd.getRejected().entrySet()) {
      Set<Column> rowColsRead = columnsRead.get(entry.getKey());
      if (rowColsRead == null) {
        for (Column column : entry.getValue()) {
          rowColumnsToRead.add(new RowColumn(entry.getKey(), column));
        }
      } else {
        HashSet<Column> colsToRead = new HashSet<>(entry.getValue());
        colsToRead.removeAll(rowColsRead);
        if (!colsToRead.isEmpty()) {
          for (Column column : colsToRead) {
            rowColumnsToRead.add(new RowColumn(entry.getKey(), column));
          }
        }
      }
    }

    getImpl(rowColumnsToRead, writeLocksSeen);
  }

  private void checkForOrphanedReadLocks(CommitData cd, Map<Bytes, Set<Column>> locksResolved)
      throws Exception {

    if (readLocksSeen.isEmpty()) {
      return;
    }

    Map<Bytes, Set<Column>> rowColsToCheck = new HashMap<>();

    for (Entry<Bytes, Set<Column>> entry : cd.getRejected().entrySet()) {

      Set<Column> resolvedColumns =
          locksResolved.getOrDefault(entry.getKey(), Collections.emptySet());

      Set<Column> colsToCheck = null;
      Set<Column> readLockCols = readLocksSeen.get(entry.getKey());
      if (readLockCols != null) {
        for (Column candidate : Sets.intersection(readLockCols, entry.getValue())) {
          if (resolvedColumns.contains(candidate)) {
            // A write lock was seen and this is probably what caused the collision, no need to
            // check this column for read locks.
            continue;
          }

          if (!isReadLock(updates.getOrDefault(entry.getKey(), Collections.emptyMap())
              .getOrDefault(candidate, EMPTY_BS))) {
            if (colsToCheck == null) {
              colsToCheck = new HashSet<>();
            }
            colsToCheck.add(candidate);
          }
        }

        if (colsToCheck != null) {
          rowColsToCheck.put(entry.getKey(), colsToCheck);
        }
      }
    }

    if (!rowColsToCheck.isEmpty()) {

      long waitTime = SnapshotScanner.INITIAL_WAIT_TIME;

      boolean resolved = false;

      List<Entry<Key, Value>> openReadLocks = LockResolver.getOpenReadLocks(env, rowColsToCheck);

      long startTime = System.currentTimeMillis();

      while (!resolved) {
        resolved = LockResolver.resolveLocks(env, startTs, stats, openReadLocks, startTime);
        if (!resolved) {
          UtilWaitThread.sleep(waitTime);
          stats.incrementLockWaitTime(waitTime);
          waitTime = Math.min(SnapshotScanner.MAX_WAIT_TIME, waitTime * 2);

          openReadLocks = LockResolver.getOpenReadLocks(env, rowColsToCheck);
        }
      }
    }
  }

  private void checkForOrphanedLocks(CommitData cd) throws Exception {

    Map<Bytes, Set<Column>> writeLocksSeen = new HashMap<>();

    readUnread(cd, kve -> {
      Bytes row = ByteUtil.toBytes(kve.getKey().getRowData());
      Column col = ColumnUtil.convert(kve.getKey());
      writeLocksSeen.computeIfAbsent(row, k -> new HashSet<>()).add(col);
    });

    checkForOrphanedReadLocks(cd, writeLocksSeen);
  }

  private boolean checkForAckCollision(ConditionalMutation cm) {
    Bytes row = Bytes.of(cm.getRow());

    if (isTriggerRow(row)) {
      List<ColumnUpdate> updates = cm.getUpdates();

      for (ColumnUpdate cu : updates) {
        // TODO avoid create col vis object
        Column col = new Column(Bytes.of(cu.getColumnFamily()), Bytes.of(cu.getColumnQualifier()),
            Bytes.of(cu.getColumnVisibility()));

        if (notification.getColumn().equals(col)) {
          // check to see if ACK exist after notification
          Key startKey = SpanUtil.toKey(notification.getRowColumn());
          startKey.setTimestamp(ColumnType.ACK.first());

          Key endKey = SpanUtil.toKey(notification.getRowColumn());
          endKey.setTimestamp(ColumnType.ACK.encode(notification.getTimestamp() + 1));

          Range range = new Range(startKey, endKey);

          try (Scanner scanner =
              env.getAccumuloClient().createScanner(env.getTable(), env.getAuthorizations())) {
            scanner.setRange(range);

            // TODO could use iterator that stops after 1st ACK. thought of using versioning iter
            // but
            // it scans to ACK
            if (scanner.iterator().hasNext()) {
              env.getSharedResources().getBatchWriter()
                  .writeMutationAsync(notification.newDelete(env));
              return true;
            }
          } catch (TableNotFoundException e) {
            // TODO proper exception handling
            throw new RuntimeException(e);
          }
        }
      }
    }

    return false;
  }

  public CommitData createCommitData() {
    CommitData cd = new CommitData();
    cd.cw = env.getSharedResources().getConditionalWriter();
    cd.acw = env.getSharedResources().getAsyncConditionalWriter();
    cd.bacw = env.getSharedResources().getBulkAsyncConditionalWriter();
    return cd;
  }

  @Override
  public synchronized void commit() throws CommitException {
    try {
      SyncCommitObserver sco = new SyncCommitObserver();
      commitAsync(sco);
      sco.waitForCommit();
    } finally {
      updates.clear();
      weakNotification = null;
      columnsRead.clear();
    }
  }

  void deleteWeakRow() {
    if (weakNotification != null) {
      env.getSharedResources().getBatchWriter()
          .writeMutation(weakNotification.newDelete(env, startTs));
    }
  }

  @Override
  public TxStats getStats() {
    return stats;
  }

  public long getStartTs() {
    return startTs;
  }

  /**
   * Sets the transactor of this transaction
   *
   * @param tnode TransactorNode
   * @return this Transaction
   */
  @VisibleForTesting
  public TransactionImpl setTransactor(TransactorNode tnode) {
    this.tnode = tnode;
    return this;
  }

  /**
   * Retrieves transactor ID by first getting/creating transactor (which is only done until
   * necessary)
   */
  private Long getTransactorID() {
    if (tnode == null) {
      tnode = env.getSharedResources().getTransactorNode();
    }
    return tnode.getTransactorID().getLongID();
  }

  private synchronized void close(boolean checkForStaleScan) {
    if (asyncReader != null) {
      asyncReader.close();
    }

    if (status != TxStatus.CLOSED) {
      status = TxStatus.CLOSED;

      if (checkForStaleScan && !commitAttempted) {
        Stamp stamp = env.getSharedResources().getOracleClient().getStamp();
        if (startTs < stamp.getGcTimestamp()) {
          throw new StaleScanException();
        }
      }

      env.getSharedResources().getTimestampTracker().removeTimestamp(startTs);
    }
  }

  @Override
  public void close() {
    close(true);
  }

  private synchronized void checkIfOpen() {
    if (status != TxStatus.OPEN) {
      throw new IllegalStateException("Transaction is no longer open! status = " + status);
    }
  }

  // CHECKSTYLE:OFF
  @Override
  protected void finalize() {
    // CHECKSTYLE:ON
    // TODO Log an error if transaction is not closed (See FLUO-486)
    close(false);
  }

  @Override
  public long getStartTimestamp() {
    return startTs;
  }

  @Override
  public int getSize() {
    // TODO could calculate as items are added/set
    int size = 0;

    for (Entry<Bytes, Map<Column, Bytes>> entry : updates.entrySet()) {
      size += entry.getKey().length();
      for (Entry<Column, Bytes> entry2 : entry.getValue().entrySet()) {
        Column c = entry2.getKey();
        size += c.getFamily().length();
        size += c.getQualifier().length();
        size += c.getVisibility().length();
        size += entry2.getValue().length();
      }
    }

    for (Entry<Bytes, Set<Column>> entry : columnsRead.entrySet()) {
      size += entry.getKey().length();
      for (Column c : entry.getValue()) {
        size += c.getFamily().length();
        size += c.getQualifier().length();
        size += c.getVisibility().length();
      }
    }

    return size;
  }

  abstract class CommitStep {
    private CommitStep nextStep;

    // the boolean indicates if the operation was successful.
    abstract CompletableFuture<Boolean> getMainOp(CommitData cd);

    // create and run this op in the event that the main op was a failure
    abstract CompletableFuture<Void> getFailureOp(CommitData cd);

    // set the next step to run if this step is successful
    CommitStep andThen(CommitStep next) {
      this.nextStep = next;
      return next;
    }

    CompletableFuture<Void> compose(CommitData cd) {
      return getMainOp(cd).thenComposeAsync(successful -> {
        if (successful) {
          if (nextStep != null) {
            return nextStep.compose(cd);
          } else {
            return CompletableFuture.completedFuture(null);
          }
        } else {
          return getFailureOp(cd);
        }
      }, env.getSharedResources().getAsyncCommitExecutor());
    }

  }

  abstract class ConditionalStep extends CommitStep {

    public abstract Collection<ConditionalMutation> createMutations(CommitData cd);

    public abstract Iterator<Result> handleUnknown(CommitData cd, Iterator<Result> results)
        throws Exception;

    public abstract boolean processResults(CommitData cd, Iterator<Result> results)
        throws Exception;

    public AsyncConditionalWriter getACW(CommitData cd) {
      return cd.acw;
    }

    @Override
    CompletableFuture<Boolean> getMainOp(CommitData cd) {
      // TODO not sure threading is correct
      Executor ace = env.getSharedResources().getAsyncCommitExecutor();
      return getACW(cd).apply(createMutations(cd)).thenCompose(results -> {
        // ugh icky that this is an iterator, forces copy to inspect.. could refactor async CW to
        // return collection
        ArrayList<Result> resultsList = new ArrayList<>();
        Iterators.addAll(resultsList, results);
        boolean containsUknown = false;
        for (Result result : resultsList) {
          try {
            containsUknown |= result.getStatus() == Status.UNKNOWN;
          } catch (Exception e) {
            throw new CompletionException(e);
          }
        }
        if (containsUknown) {
          // process unknown in sync executor
          Executor se = env.getSharedResources().getSyncCommitExecutor();
          return CompletableFuture.supplyAsync(() -> {
            try {
              return handleUnknown(cd, resultsList.iterator());
            } catch (Exception e) {
              throw new CompletionException(e);
            }
          }, se);
        } else {
          return CompletableFuture.completedFuture(resultsList.iterator());
        }
      }).thenApplyAsync(results -> {
        try {
          return processResults(cd, results);
        } catch (Exception e) {
          throw new CompletionException(e);
        }
      }, ace);
    }

  }

  class LockPrimaryStep extends ConditionalStep {

    @Override
    public Collection<ConditionalMutation> createMutations(CommitData cd) {
      return Collections
          .singleton(prewrite(cd.prow, cd.pcol, cd.pval, cd.prow, cd.pcol, isTriggerRow(cd.prow)));
    }

    @Override
    public Iterator<Result> handleUnknown(CommitData cd, Iterator<Result> results)
        throws Exception {

      Result result = Iterators.getOnlyElement(results);
      Status mutationStatus = result.getStatus();
      // TODO convert this code to async
      while (mutationStatus == Status.UNKNOWN) {
        TxInfo txInfo = TxInfo.getTransactionInfo(env, cd.prow, cd.pcol, startTs);

        switch (txInfo.getStatus()) {
          case LOCKED:
            return Collections
                .singleton(
                    new Result(Status.ACCEPTED, result.getMutation(), result.getTabletServer()))
                .iterator();
          case ROLLED_BACK:
            return Collections
                .singleton(
                    new Result(Status.REJECTED, result.getMutation(), result.getTabletServer()))
                .iterator();
          case UNKNOWN:
            // TODO async
            Result newResult = cd.cw.write(result.getMutation());
            mutationStatus = newResult.getStatus();
            if (mutationStatus != Status.UNKNOWN) {
              return Collections.singleton(newResult).iterator();
            }
            // TODO handle case were data other tx has lock
            break;
          case COMMITTED:
          default:
            throw new IllegalStateException(
                "unexpected tx state " + txInfo.getStatus() + " " + cd.prow + " " + cd.pcol);

        }
      }

      // TODO
      throw new IllegalStateException();
    }

    @Override
    public boolean processResults(CommitData cd, Iterator<Result> results) throws Exception {
      Result result = StreamSupport.stream(Spliterators.spliteratorUnknownSize(results, 0), false)
          .collect(MoreCollectors.onlyElement());
      return result.getStatus() == Status.ACCEPTED;
    }

    @Override
    CompletableFuture<Void> getFailureOp(CommitData cd) {
      // TODO can this be simplified by pushing some code to the superclass?
      return CompletableFuture.supplyAsync(() -> {
        final ConditionalMutation pcm =
            createMutations(cd).stream().collect(MoreCollectors.onlyElement());

        cd.addPrimaryToRejected();
        getStats().setRejected(cd.getRejected());
        // TODO do async
        try {
          checkForOrphanedLocks(cd);
        } catch (Exception e) {
          throw new CompletionException(e);
        }
        if (checkForAckCollision(pcm)) {
          cd.commitObserver.alreadyAcknowledged();
        } else {
          cd.commitObserver.commitFailed(cd.getShortCollisionMessage());
        }

        return null;
      }, env.getSharedResources().getSyncCommitExecutor());
    }

  }

  class LockOtherStep extends ConditionalStep {

    @Override
    public AsyncConditionalWriter getACW(CommitData cd) {
      return cd.bacw;
    }

    @Override
    public Collection<ConditionalMutation> createMutations(CommitData cd) {

      ArrayList<ConditionalMutation> mutations = new ArrayList<>();

      for (Entry<Bytes, Map<Column, Bytes>> rowUpdates : updates.entrySet()) {
        ConditionalFlutation cm = null;

        for (Entry<Column, Bytes> colUpdates : rowUpdates.getValue().entrySet()) {
          if (cm == null) {
            cm = prewrite(rowUpdates.getKey(), colUpdates.getKey(), colUpdates.getValue(), cd.prow,
                cd.pcol, false);
          } else {
            prewrite(cm, colUpdates.getKey(), colUpdates.getValue(), cd.prow, cd.pcol, false);
          }
        }

        mutations.add(cm);
      }

      cd.acceptedRows = new HashSet<>();

      return mutations;
    }

    @Override
    public Iterator<Result> handleUnknown(CommitData cd, Iterator<Result> results) {
      // TODO this step does not currently handle unknown
      return results;
    }

    @Override
    public boolean processResults(CommitData cd, Iterator<Result> results) throws Exception {

      while (results.hasNext()) {
        Result result = results.next();
        // TODO handle unknown?
        Bytes row = Bytes.of(result.getMutation().getRow());
        if (result.getStatus() == Status.ACCEPTED) {
          cd.acceptedRows.add(row);
        } else {
          cd.addToRejected(row, updates.get(row).keySet());
        }
      }

      return cd.getRejected().isEmpty();
    }

    @Override
    CompletableFuture<Void> getFailureOp(CommitData cd) {
      return CompletableFuture.supplyAsync(() -> {
        getStats().setRejected(cd.getRejected());
        try {
          // Does this need to be async?
          checkForOrphanedLocks(cd);
        } catch (Exception e) {
          throw new CompletionException(e);
        }
        return null;
      }, env.getSharedResources().getSyncCommitExecutor()).thenCompose(v -> rollbackLocks(cd));
    }
  }

  abstract class BatchWriterStep extends CommitStep {
    public abstract Collection<Mutation> createMutations(CommitData cd);

    @Override
    CompletableFuture<Boolean> getMainOp(CommitData cd) {
      return env.getSharedResources().getBatchWriter()
          .writeMutationsAsyncFuture(createMutations(cd)).thenApply(v -> true);
    }

    @Override
    CompletableFuture<Void> getFailureOp(CommitData cd) {
      throw new IllegalStateException("Failure not expected");
    }
  }

  private CompletableFuture<Void> rollbackLocks(CommitData cd) {
    CommitStep firstStep = new RollbackOtherLocks();
    firstStep.andThen(new RollbackPrimaryLock());

    return firstStep.compose(cd)
        .thenRun(() -> cd.commitObserver.commitFailed(cd.getShortCollisionMessage()));

  }

  class RollbackOtherLocks extends BatchWriterStep {

    @Override
    public Collection<Mutation> createMutations(CommitData cd) {
      // roll back locks

      // TODO let rollback be done lazily? this makes GC more difficult

      Flutation m;

      ArrayList<Mutation> mutations = new ArrayList<>(cd.acceptedRows.size());
      for (Bytes row : cd.acceptedRows) {
        m = new Flutation(env, row);
        for (Entry<Column, Bytes> entry : updates.get(row).entrySet()) {
          if (isReadLock(entry.getValue())) {
            m.put(entry.getKey(), ColumnType.RLOCK.encode(ReadLockUtil.encodeTs(startTs, true)),
                DelReadLockValue.encodeRollback());
          } else {
            m.put(entry.getKey(), ColumnType.DEL_LOCK.encode(startTs),
                DelLockValue.encodeRollback(false, true));
          }
        }
        mutations.add(m);
      }

      return mutations;
    }
  }

  class RollbackPrimaryLock extends BatchWriterStep {

    @Override
    public Collection<Mutation> createMutations(CommitData cd) {
      // mark transaction as complete for garbage collection purposes
      Flutation m = new Flutation(env, cd.prow);

      m.put(cd.pcol, ColumnType.DEL_LOCK.encode(startTs),
          DelLockValue.encodeRollback(startTs, true, true));
      m.put(cd.pcol, ColumnType.TX_DONE.encode(startTs), EMPTY);

      return Collections.singletonList(m);
    }
  }

  class CommittedTestStep extends CommitStep {
    @Override
    CompletableFuture<Boolean> getMainOp(CommitData cd) {
      cd.commitObserver.committed();
      return CompletableFuture.completedFuture(true);
    }

    @Override
    CompletableFuture<Void> getFailureOp(CommitData cd) {
      throw new IllegalStateException("Failure not expected");
    }
  }

  @VisibleForTesting
  public boolean commitPrimaryColumn(CommitData cd, Stamp commitStamp) {

    SyncCommitObserver sco = new SyncCommitObserver();
    cd.commitObserver = sco;
    try {
      CommitStep firstStep = new GetCommitStampStepTest(commitStamp);

      firstStep.andThen(new WriteNotificationsStep()).andThen(new CommitPrimaryStep())
          .andThen(new CommittedTestStep());

      firstStep.compose(cd).exceptionally(throwable -> {
        setFailed(cd, throwable);
        return null;
      });
      sco.waitForCommit();
    } catch (CommitException e) {
      return false;
    } catch (Exception e) {
      throw new FluoException(e);
    }
    return true;
  }

  class GetCommitStampStep extends CommitStep {
    protected CompletableFuture<Stamp> getStampOp() {
      return env.getSharedResources().getOracleClient().getStampAsync();
    }

    @Override
    CompletableFuture<Boolean> getMainOp(CommitData cd) {

      return getStampOp().thenApply(commitStamp -> {
        if (startTs < commitStamp.getGcTimestamp()) {
          return false;
        } else {
          getStats().setCommitTs(commitStamp.getTxTimestamp());
          return true;
        }
      });
    }

    @Override
    CompletableFuture<Void> getFailureOp(CommitData cd) {
      return rollbackLocks(cd);
    }

  }

  class GetCommitStampStepTest extends GetCommitStampStep {
    private final Stamp testStamp;

    public GetCommitStampStepTest(Stamp testStamp) {
      this.testStamp = testStamp;
    }

    @Override
    protected CompletableFuture<Stamp> getStampOp() {
      return CompletableFuture.completedFuture(testStamp);
    }

  }

  class WriteNotificationsStep extends BatchWriterStep {

    @Override
    public Collection<Mutation> createMutations(CommitData cd) {
      long commitTs = getStats().getCommitTs();
      HashMap<Bytes, Mutation> mutations = new HashMap<>();

      if (observedColumns.contains(cd.pcol) && isWrite(cd.pval) && !isDelete(cd.pval)) {
        Flutation m = new Flutation(env, cd.prow);
        Notification.put(env, m, cd.pcol, commitTs);
        mutations.put(cd.prow, m);
      }

      for (Entry<Bytes, Map<Column, Bytes>> rowUpdates : updates.entrySet()) {

        for (Entry<Column, Bytes> colUpdates : rowUpdates.getValue().entrySet()) {
          if (observedColumns.contains(colUpdates.getKey())) {
            Bytes val = colUpdates.getValue();
            if (isWrite(val) && !isDelete(val)) {
              Mutation m = mutations.get(rowUpdates.getKey());
              if (m == null) {
                m = new Flutation(env, rowUpdates.getKey());
                mutations.put(rowUpdates.getKey(), m);
              }
              Notification.put(env, m, colUpdates.getKey(), commitTs);
            }
          }
        }
      }

      for (Entry<Bytes, Set<Column>> entry : weakNotifications.entrySet()) {
        Mutation m = mutations.get(entry.getKey());
        if (m == null) {
          m = new Flutation(env, entry.getKey());
          mutations.put(entry.getKey(), m);
        }
        for (Column col : entry.getValue()) {
          Notification.put(env, m, col, commitTs);
        }
      }
      return mutations.values();
    }

  }

  class CommitPrimaryStep extends ConditionalStep {

    @Override
    public Collection<ConditionalMutation> createMutations(CommitData cd) {
      long commitTs = getStats().getCommitTs();
      IteratorSetting iterConf = new IteratorSetting(10, PrewriteIterator.class);
      PrewriteIterator.setSnaptime(iterConf, startTs);
      boolean isTrigger = isTriggerRow(cd.prow) && cd.pcol.equals(notification.getColumn());

      Condition lockCheck =
          new FluoCondition(env, cd.pcol).setIterators(iterConf).setValue(LockValue.encode(cd.prow,
              cd.pcol, isWrite(cd.pval), isDelete(cd.pval), isTrigger, getTransactorID()));
      final ConditionalMutation delLockMutation = new ConditionalFlutation(env, cd.prow, lockCheck);

      ColumnUtil.commitColumn(env, isTrigger, true, cd.pcol, isWrite(cd.pval), isDelete(cd.pval),
          isReadLock(cd.pval), startTs, commitTs, observedColumns, delLockMutation);

      return Collections.singletonList(delLockMutation);
    }

    @Override
    public Iterator<Result> handleUnknown(CommitData cd, Iterator<Result> results)
        throws Exception {
      // the code for handing this is synchronous and needs to be handled in another thread pool
      // TODO - how do we do the above without return a CF?
      long commitTs = getStats().getCommitTs();
      Result result = StreamSupport.stream(Spliterators.spliteratorUnknownSize(results, 0), false)
          .collect(MoreCollectors.onlyElement());
      Status ms = result.getStatus();

      while (ms == Status.UNKNOWN) {

        // TODO async
        TxInfo txInfo = TxInfo.getTransactionInfo(env, cd.prow, cd.pcol, startTs);

        switch (txInfo.getStatus()) {
          case COMMITTED:
            if (txInfo.getCommitTs() != commitTs) {
              throw new IllegalStateException(
                  cd.prow + " " + cd.pcol + " " + txInfo.getCommitTs() + "!=" + commitTs);
            }
            ms = Status.ACCEPTED;
            break;
          case LOCKED:
            // TODO async
            ConditionalMutation delLockMutation = result.getMutation();
            ms = cd.cw.write(delLockMutation).getStatus();
            break;
          default:
            ms = Status.REJECTED;
        }
      }
      Result newResult = new Result(ms, result.getMutation(), result.getTabletServer());
      return Collections.singletonList(newResult).iterator();
    }

    @Override
    public boolean processResults(CommitData cd, Iterator<Result> results) throws Exception {
      Result result = Iterators.getOnlyElement(results);
      return result.getStatus() == Status.ACCEPTED;
    }

    @Override
    CompletableFuture<Void> getFailureOp(CommitData cd) {
      cd.commitObserver.commitFailed(cd.getShortCollisionMessage());
      return CompletableFuture.completedFuture(NULLS.get());
    }

  }

  @VisibleForTesting
  public boolean finishCommit(CommitData cd, Stamp commitStamp) {
    cd.commitObserver = new SyncCommitObserver();

    getStats().setCommitTs(commitStamp.getTxTimestamp());

    CommitStep firstStep = new DeleteLocksStep();
    firstStep.andThen(new FinishCommitStep());
    firstStep.compose(cd).exceptionally(throwable -> {
      System.err.println("Unexpected exception in finish commit test method : ");
      throwable.printStackTrace();
      return null;
    });

    return true;
  }

  class DeleteLocksStep extends BatchWriterStep {

    @Override
    public Collection<Mutation> createMutations(CommitData cd) {

      long commitTs = getStats().getCommitTs();
      ArrayList<Mutation> mutations = new ArrayList<>(updates.size() + 1);
      for (Entry<Bytes, Map<Column, Bytes>> rowUpdates : updates.entrySet()) {
        Flutation m = new Flutation(env, rowUpdates.getKey());
        boolean isTriggerRow = isTriggerRow(rowUpdates.getKey());
        for (Entry<Column, Bytes> colUpdates : rowUpdates.getValue().entrySet()) {
          ColumnUtil.commitColumn(env,
              isTriggerRow && colUpdates.getKey().equals(notification.getColumn()), false,
              colUpdates.getKey(), isWrite(colUpdates.getValue()), isDelete(colUpdates.getValue()),
              isReadLock(colUpdates.getValue()), startTs, commitTs, observedColumns, m);
        }

        mutations.add(m);
      }

      return mutations;
    }

  }

  class FinishCommitStep extends BatchWriterStep {

    @Override
    CompletableFuture<Boolean> getMainOp(CommitData cd) {
      return super.getMainOp(cd).thenApply(b -> {
        Preconditions.checkArgument(b);
        cd.commitObserver.committed();
        return true;
      });
    }

    @Override
    public Collection<Mutation> createMutations(CommitData cd) {
      long commitTs = getStats().getCommitTs();
      ArrayList<Mutation> afterFlushMutations = new ArrayList<>(2);

      Flutation m = new Flutation(env, cd.prow);
      // mark transaction as complete for garbage collection purposes
      m.put(cd.pcol, ColumnType.TX_DONE.encode(commitTs), EMPTY);
      afterFlushMutations.add(m);

      if (weakNotification != null) {
        afterFlushMutations.add(weakNotification.newDelete(env, startTs));
      }

      if (notification != null) {
        afterFlushMutations.add(notification.newDelete(env, startTs));
      }

      return afterFlushMutations;
    }

  }

  @Override
  public synchronized void commitAsync(AsyncCommitObserver commitCallback) {

    checkIfOpen();
    status = TxStatus.COMMIT_STARTED;
    commitAttempted = true;

    try {
      CommitData cd = createCommitData();
      cd = setUpBeginCommitAsync(cd, commitCallback, null);
      if (cd != null) {
        beginCommitAsync(cd);
      }
    } catch (Exception e) {
      e.printStackTrace();
      commitCallback.failed(e);
    }
  }

  private CommitData setUpBeginCommitAsync(CommitData cd, AsyncCommitObserver commitCallback,
      RowColumn primary) {
    if (updates.isEmpty()) {
      // TODO do async
      deleteWeakRow();
      commitCallback.committed();
      return null;
    }

    for (Map<Column, Bytes> cols : updates.values()) {
      stats.incrementEntriesSet(cols.size());
    }

    Bytes primRow = null;
    Column primCol = null;

    if (primary != null) {
      primRow = primary.getRow();
      primCol = primary.getColumn();
      if (notification != null && !primary.equals(notification.getRowColumn())) {
        throw new IllegalArgumentException("Primary must be notification");
      }
    } else if (notification != null) {
      primRow = notification.getRow();
      primCol = notification.getColumn();
    } else {

      outer: for (Entry<Bytes, Map<Column, Bytes>> entry : updates.entrySet()) {
        for (Entry<Column, Bytes> entry2 : entry.getValue().entrySet()) {
          if (!isReadLock(entry2.getValue())) {
            primRow = entry.getKey();
            primCol = entry2.getKey();
            break outer;
          }
        }
      }

      if (primRow == null) {
        // there are only read locks, so nothing to write
        deleteWeakRow();
        commitCallback.committed();
        return null;
      }
    }

    // get a primary column
    cd.prow = primRow;
    Map<Column, Bytes> colSet = updates.get(cd.prow);
    cd.pcol = primCol;
    cd.pval = colSet.remove(primCol);
    if (colSet.isEmpty()) {
      updates.remove(cd.prow);
    }

    cd.commitObserver = commitCallback;

    return cd;
  }

  private void setFailed(CommitData cd, Throwable throwable) {
    try {
      cd.commitObserver.failed(throwable);
    } catch (RuntimeException e) {
      Logger log = LoggerFactory.getLogger(TransactionImpl.class);
      log.error("Failed to set tx failure (startTs=" + startTs + ") cause ", e);
      log.error("Failed to set tx failure (startTs=" + startTs + ") lost throwable ", throwable);
    }
  }

  private void beginCommitAsync(CommitData cd) {

    // Notification are written between GetCommitStampStep and CommitPrimaryStep for the following
    // reasons :
    // * At this point all columns are locked, this guarantees that anything triggering as a
    // result of this transaction will see all of this transactions changes.
    // * The transaction is not yet committed. If the process dies at this point whatever
    // was running this transaction should rerun and recreate all of the notifications.
    // The next transactions will rerun because this transaction will have to be rolled back.
    // * If notifications are written in the 2nd phase of commit, then when the 2nd phase
    // partially succeeds notifications may never be written. Because in the case of failure
    // notifications would not be written until a column is read and it may never be read.
    // See https://github.com/fluo-io/fluo/issues/642
    //
    // Its very important the notifications which trigger an observer are deleted after the 2nd
    // phase of commit finishes.

    CommitStep firstStep = new LockPrimaryStep();

    firstStep.andThen(new LockOtherStep()).andThen(new GetCommitStampStep())
        .andThen(new WriteNotificationsStep()).andThen(new CommitPrimaryStep())
        .andThen(new DeleteLocksStep()).andThen(new FinishCommitStep());

    firstStep.compose(cd).exceptionally(throwable -> {
      setFailed(cd, throwable);
      return null;
    });
  }

  private void beginCommitAsyncTest(CommitData cd) {

    CommitStep firstStep = new LockPrimaryStep();

    firstStep.andThen(new LockOtherStep()).andThen(new CommittedTestStep());

    firstStep.compose(cd).exceptionally(throwable -> {
      setFailed(cd, throwable);
      return null;
    });
  }

  public SnapshotScanner newSnapshotScanner(Span span, Collection<Column> columns,
      Authorizations scanTimeAuthz) {
    return new SnapshotScanner(env, new SnapshotScanner.Opts(span, columns, false, scanTimeAuthz),
        startTs, stats);
  }

  @Override
  public void setScanTimeAuthorizations(Collection<String> labels) {
    Objects.requireNonNull(labels, "Authorization tokens must not be null!");
    String[] requestedAuthz = Iterables.toArray(labels, String.class);
    if (requestedAuthz != null) {
      if (requestedAuthz.length == 0) {
        this.scanTimeAuthz = Authorizations.EMPTY;
      } else {
        this.scanTimeAuthz = new Authorizations(requestedAuthz);
      }
    }
  }

  @Override
  public Collection<String> getScanTimeAuthorizations() {
    return this.scanTimeAuthz.getAuthorizations().stream()
        .map(auth -> new String(auth, StandardCharsets.UTF_8)).collect(Collectors.toSet());
  }
}
