package com.atlassian.stash.internal.activity;

import com.atlassian.bitbucket.util.concurrent.LockGuard;
import org.hibernate.SessionFactory;

import javax.annotation.Nonnull;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * An {@link ActivityLock} implementation specific to the quirks of MySQL.
 * <p>
 * MySQL applies "gap locks" to indexes when inserting and deleting rows. Because the activity data is hierarchical,
 * spreading rows across multiple tables, these "gap locks" can trigger deadlocks where concurrent insert-vs-delete
 * or delete-vs-delete results in "gap lock" contention. This {@link ActivityLock} ensures:
 * <ul>
 *     <li>Concurrent inserts are allowed
 *     <ul>
 *         <li>Gap locks cannot deadlock for insert-vs-insert</li>
 *     </ul>
 *     </li>
 *     <li>If one or more inserts are in progress (the read lock is held), deletions are blocked</li>
 *     <li>Exactly <i>one</i> delete can be in progress at a time, with no concurrent inserts (the write lock is held)
 *     <ul>
 *         <li>This prevents delete-vs-delete deadlocks</li>
 *     </ul>
 *     </li>
 * </ul>
 * <p>
 * <b>Note</b>: Even when gap locks are "disabled", they are still applied to some operations:
 * <pre>
 * Gap locking can be disabled explicitly. This occurs if you change the transaction isolation level to READ COMMITTED
 * or enable the innodb_locks_unsafe_for_binlog system variable. Under these circumstances, gap locking is disabled for
 * searches and index scans and is used only for foreign-key constraint checking and duplicate-key checking.
 * </pre>
 * See the <a href="http://dev.mysql.com/doc/refman/5.5/en/innodb-record-level-locks.html">MySQL documentation</a> for
 * more.
 * <p>
 * Because gap locks are <i>always</i> applied for foreign and primary key ("duplicate-key") checking, insert and
 * delete operations will <i>always</i> be vulnerable to deadlocks on MySQL when multiple tables are involved.
 *
 * @since 3.0.7, 3.1.4, 3.2.1
 */
public class MySqlActivityLock implements ActivityLock {

    private final Lock createLock;
    private final Lock deleteLock;
    private final ReentrantReadWriteLock lock;
    private final SessionFactory sessionFactory;

    public MySqlActivityLock(SessionFactory sessionFactory) {
        this.sessionFactory = sessionFactory;

        //MySQL locks indexes while deleting rows, preventing inserts. For multi-table entities like activities
        //that can result in deadlocks between inserts and deletes, like so:
        // - Delete locks sta_pr_comment_activity index
        // - Insert locks sta_pr_activity index
        // - Delete attempts to acquire sta_pr_activity index
        // - Insert attempts to acquire sta_pr_comment_activity index
        //To prevent such deadlocks, we use a read/write lock to allow:
        // - Any number of threads to insert activities, as long as there are no threads trying to delete them
        // - A single thread to delete activities, with _no other inserts or deletes_ at the same time
        //Only allowing a single thread to be deleting at once is important because delete-vs-delete can cause
        //index deadlocks as well, not just insert-vs-delete
        lock = new ReentrantReadWriteLock();
        createLock = lock.readLock();
        deleteLock = lock.writeLock();
    }

    @Nonnull
    @Override
    public LockGuard forCreate() {
        return new FlushingLockGuard(createLock, sessionFactory);
    }

    @Nonnull
    @Override
    public LockGuard forDelete() {
        return new FlushingLockGuard(deleteLock, sessionFactory);
    }

    public ReentrantReadWriteLock getLock() {
        return lock;
    }

    private static class FlushingLockGuard extends LockGuard {

        private final SessionFactory sessionFactory;

        private FlushingLockGuard(Lock lock, SessionFactory sessionFactory) {
            super(lock);

            this.sessionFactory = sessionFactory;
        }

        /**
         * Flushes the current Hibernate {@code Session} before releasing the lock, ensuring any pending SQL statements
         * are applied to the database before the lock is released. Those SQL statements may acquire database locks,
         * <i>without contention</i>, and those database-side locks effectively assume responsibility for ensuring the
         * overall operation is coherent.
         * <p>
         * Note that, because Hibernate doesn't have deterministic flushing for {@code INSERT}, {@code UPDATE} and
         * {@code DELETE} statements, without this explicit flush there is <i>no benefit</i> to applying Java-side
         * locking for activities.
         */
        @Override
        public void close() {
            try {
                sessionFactory.getCurrentSession().flush();
            } finally {
                super.close();
            }
        }
    }
}
