File: Storage\SQLite\v2\SQLiteConnectionPoolService.cs
Web Access
Project: ..\..\..\src\Workspaces\Core\Portable\Microsoft.CodeAnalysis.Workspaces.csproj (Microsoft.CodeAnalysis.Workspaces)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Composition;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ErrorReporting;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.SQLite.v2.Interop;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.SQLite.v2
{
    [Export]
    [Shared]
    internal sealed class SQLiteConnectionPoolService : IDisposable
    {
        private const string LockFile = "db.lock";
 
        private readonly object _gate = new();
 
        /// <summary>
        /// Maps from database file path to connection pool.
        /// </summary>
        /// <remarks>
        /// Access to this field is synchronized through <see cref="_gate"/>.
        /// </remarks>
        private readonly Dictionary<string, ReferenceCountedDisposable<SQLiteConnectionPool>> _connectionPools = new();
 
        [ImportingConstructor]
        [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
        public SQLiteConnectionPoolService()
        {
        }
 
        /// <summary>
        /// Use a <see cref="ConcurrentExclusiveSchedulerPair"/> to simulate a reader-writer lock.
        /// Read operations are performed on the <see cref="ConcurrentExclusiveSchedulerPair.ConcurrentScheduler"/>
        /// and writes are performed on the <see cref="ConcurrentExclusiveSchedulerPair.ExclusiveScheduler"/>.
        ///
        /// We use this as a condition of using the in-memory shared-cache sqlite DB.  This DB
        /// doesn't busy-wait when attempts are made to lock the tables in it, which can lead to
        /// deadlocks.  Specifically, consider two threads doing the following:
        ///
        /// Thread A starts a transaction that starts as a reader, and later attempts to perform a
        /// write. Thread B is a writer (either started that way, or started as a reader and
        /// promoted to a writer first). B holds a RESERVED lock, waiting for readers to clear so it
        /// can start writing. A holds a SHARED lock (it's a reader) and tries to acquire RESERVED
        /// lock (so it can start writing).  The only way to make progress in this situation is for
        /// one of the transactions to roll back. No amount of waiting will help, so when SQLite
        /// detects this situation, it doesn't honor the busy timeout.
        ///
        /// To prevent this scenario, we control our access to the db explicitly with operations that
        /// can concurrently read, and operations that exclusively write.
        ///
        /// All code that reads or writes from the db should go through this.
        /// </summary>
        public ConcurrentExclusiveSchedulerPair Scheduler { get; } = new();
 
        public void Dispose()
        {
            lock (_gate)
            {
                foreach (var (_, pool) in _connectionPools)
                    pool.Dispose();
 
                _connectionPools.Clear();
            }
        }
 
        public ReferenceCountedDisposable<SQLiteConnectionPool>? TryOpenDatabase(
            string databaseFilePath,
            IPersistentStorageFaultInjector? faultInjector,
            Action<SqlConnection, CancellationToken> initializer,
            CancellationToken cancellationToken)
        {
            lock (_gate)
            {
                if (_connectionPools.TryGetValue(databaseFilePath, out var pool))
                {
                    return pool.TryAddReference() ?? throw ExceptionUtilities.Unreachable();
                }
 
                // try to get db ownership lock. if someone else already has the lock. it will throw
                var ownershipLock = TryGetDatabaseOwnership(databaseFilePath);
                if (ownershipLock == null)
                {
                    return null;
                }
 
                try
                {
                    pool = new ReferenceCountedDisposable<SQLiteConnectionPool>(
                        new SQLiteConnectionPool(this, faultInjector, databaseFilePath, ownershipLock));
 
                    pool.Target.Initialize(initializer, cancellationToken);
 
                    // Place the initial ownership reference in _connectionPools, and return another
                    _connectionPools.Add(databaseFilePath, pool);
                    return pool.TryAddReference() ?? throw ExceptionUtilities.Unreachable();
                }
                catch (Exception ex) when (FatalError.ReportAndCatchUnlessCanceled(ex, cancellationToken))
                {
                    if (pool is not null)
                    {
                        // Dispose of the connection pool, releasing the ownership lock.
                        pool.Dispose();
                    }
                    else
                    {
                        // The storage was not created so nothing owns the lock.
                        // Dispose the lock to allow reuse.
                        ownershipLock.Dispose();
                    }
 
                    throw;
                }
            }
        }
 
        /// <summary>
        /// Returns null in the case where an IO exception prevented us from being able to acquire
        /// the db lock file.
        /// </summary>
        private static IDisposable? TryGetDatabaseOwnership(string databaseFilePath)
        {
            return IOUtilities.PerformIO<IDisposable?>(() =>
            {
                // make sure directory exist first.
                EnsureDirectory(databaseFilePath);
 
                var directoryName = Path.GetDirectoryName(databaseFilePath);
                Contract.ThrowIfNull(directoryName);
 
                return File.Open(
                    Path.Combine(directoryName, LockFile),
                    FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
            }, defaultValue: null);
        }
 
        private static void EnsureDirectory(string databaseFilePath)
        {
            var directory = Path.GetDirectoryName(databaseFilePath);
            Contract.ThrowIfNull(directory);
 
            if (Directory.Exists(directory))
            {
                return;
            }
 
            Directory.CreateDirectory(directory);
        }
    }
}