Files
2026-07-13 12:31:29 +08:00

569 lines
21 KiB
C#

using Microsoft.Data.Sqlite;
namespace CodexProviderSync.Core;
public sealed class SqliteStateService
{
private const int DefaultBusyTimeoutMs = 5000;
private sealed record StateDbCandidateStats(
StateDbLocation Location,
int Priority,
long ThreadCount,
long MaxThreadTimestampMs,
long LastWriteTimeUtcTicks,
long RolloutDistance);
static SqliteStateService()
{
SQLitePCL.Batteries_V2.Init();
}
public string StateDbPath(string codexHome)
{
return Path.Combine(codexHome, AppConstants.SqliteDirBasename, AppConstants.DbFileBasename);
}
public string LegacyStateDbPath(string codexHome)
{
return Path.Combine(codexHome, AppConstants.DbFileBasename);
}
public IReadOnlyList<StateDbLocation> StateDbCandidates(string codexHome)
{
return
[
new StateDbLocation(
StateDbPath(codexHome),
Path.Combine(AppConstants.SqliteDirBasename, AppConstants.DbFileBasename),
"sqlite-dir"),
new StateDbLocation(
LegacyStateDbPath(codexHome),
AppConstants.DbFileBasename,
"legacy-root")
];
}
public StateDbLocation? DetectStateDb(string codexHome)
{
List<(StateDbLocation Location, int Priority)> existingCandidates = [];
IReadOnlyList<StateDbLocation> candidates = StateDbCandidates(codexHome);
for (int index = 0; index < candidates.Count; index += 1)
{
StateDbLocation candidate = candidates[index];
if (File.Exists(candidate.Path))
{
existingCandidates.Add((candidate, index));
}
}
if (existingCandidates.Count == 0)
{
return null;
}
long rolloutCount = CountRolloutFiles(codexHome);
List<StateDbCandidateStats> readableCandidates = [];
foreach ((StateDbLocation candidate, int priority) in existingCandidates)
{
try
{
StateDbCandidateStats stats = ReadStateDbCandidateStats(candidate, priority, rolloutCount);
readableCandidates.Add(stats);
}
catch
{
// Keep unreadable candidates as a fallback so existing status/error
// handling still points at state_5.sqlite when no usable DB exists.
}
}
if (readableCandidates.Count == 0)
{
return existingCandidates[0].Location;
}
return readableCandidates
.OrderBy(static candidate => candidate.RolloutDistance)
.ThenByDescending(static candidate => candidate.ThreadCount)
.ThenByDescending(static candidate => candidate.MaxThreadTimestampMs)
.ThenByDescending(static candidate => candidate.LastWriteTimeUtcTicks)
.ThenBy(static candidate => candidate.Priority)
.First()
.Location;
}
public string? ExistingStateDbPath(string codexHome)
{
return DetectStateDb(codexHome)?.Path;
}
public async Task<ProviderCounts?> ReadSqliteProviderCountsAsync(string codexHome)
{
string? dbPath = ExistingStateDbPath(codexHome);
if (dbPath is null)
{
return null;
}
try
{
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly);
await connection.OpenAsync();
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT
CASE
WHEN model_provider IS NULL OR model_provider = '' THEN '(missing)'
ELSE model_provider
END AS model_provider,
archived,
COUNT(*) AS count
FROM threads
GROUP BY model_provider, archived
ORDER BY archived, model_provider
""";
Dictionary<string, int> sessions = new(StringComparer.Ordinal);
Dictionary<string, int> archivedSessions = new(StringComparer.Ordinal);
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string provider = reader.GetString(0);
bool archived = reader.GetInt64(1) != 0;
int count = reader.GetInt32(2);
Dictionary<string, int> bucket = archived ? archivedSessions : sessions;
bucket[provider] = count;
}
return new ProviderCounts
{
Sessions = sessions,
ArchivedSessions = archivedSessions
};
}
catch (Exception error) when (IsSqliteMalformedError(error))
{
return new ProviderCounts
{
Unreadable = true,
Error = "state_5.sqlite is malformed or unreadable"
};
}
catch (Exception error) when (IsSqliteBusyError(error))
{
return new ProviderCounts
{
Unreadable = true,
Error = "state_5.sqlite is currently in use"
};
}
}
public async Task<SqliteRepairStats?> ReadSqliteRepairStatsAsync(
string codexHome,
IReadOnlyCollection<string>? userEventThreadIds = null,
IReadOnlyDictionary<string, string>? threadCwdsById = null)
{
string? dbPath = ExistingStateDbPath(codexHome);
if (dbPath is null)
{
return null;
}
try
{
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly);
await connection.OpenAsync();
int userEventRowsNeedingRepair = 0;
if (userEventThreadIds?.Count > 0 && await TableHasColumnAsync(connection, "threads", "has_user_event"))
{
await using SqliteCommand userEventCommand = connection.CreateCommand();
userEventCommand.CommandText = "SELECT has_user_event FROM threads WHERE id = $id";
SqliteParameter idParameter = userEventCommand.Parameters.Add("$id", SqliteType.Text);
foreach (string threadId in userEventThreadIds)
{
idParameter.Value = threadId;
object? value = await userEventCommand.ExecuteScalarAsync();
if (value is not null && value is not DBNull && Convert.ToInt64(value) != 1)
{
userEventRowsNeedingRepair += 1;
}
}
}
int cwdRowsNeedingRepair = 0;
if (threadCwdsById?.Count > 0 && await TableHasColumnAsync(connection, "threads", "cwd"))
{
await using SqliteCommand cwdCommand = connection.CreateCommand();
cwdCommand.CommandText = "SELECT cwd FROM threads WHERE id = $id";
SqliteParameter idParameter = cwdCommand.Parameters.Add("$id", SqliteType.Text);
foreach ((string threadId, string expectedCwd) in threadCwdsById)
{
if (string.IsNullOrWhiteSpace(threadId) || string.IsNullOrWhiteSpace(expectedCwd))
{
continue;
}
idParameter.Value = threadId;
object? value = await cwdCommand.ExecuteScalarAsync();
if (value is not null
&& value is not DBNull
&& !string.Equals(Convert.ToString(value), expectedCwd, StringComparison.Ordinal))
{
cwdRowsNeedingRepair += 1;
}
}
}
return new SqliteRepairStats
{
UserEventRowsNeedingRepair = userEventRowsNeedingRepair,
CwdRowsNeedingRepair = cwdRowsNeedingRepair
};
}
catch (Exception error)
{
throw WrapSqliteMalformedError(
WrapSqliteBusyError(error, "read SQLite repair diagnostics"),
"read SQLite repair diagnostics");
}
}
public async Task<bool> AssertSqliteWritableAsync(string codexHome, int? busyTimeoutMs = null)
{
string? dbPath = ExistingStateDbPath(codexHome);
if (dbPath is null)
{
return false;
}
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadWriteCreate);
try
{
await connection.OpenAsync();
await SetBusyTimeoutAsync(connection, busyTimeoutMs);
await ExecuteNonQueryAsync(connection, "BEGIN IMMEDIATE");
await ExecuteNonQueryAsync(connection, "ROLLBACK");
return true;
}
catch (Exception error)
{
throw WrapSqliteMalformedError(
WrapSqliteBusyError(error, "update session provider metadata"),
"update session provider metadata");
}
}
public async Task<(int UpdatedRows, int ProviderRowsUpdated, int UserEventRowsUpdated, int CwdRowsUpdated, bool DatabasePresent)> UpdateSqliteProviderAsync(
string codexHome,
string targetProvider,
Func<(int UpdatedRows, int ProviderRowsUpdated, int UserEventRowsUpdated, int CwdRowsUpdated, bool DatabasePresent), Task>? afterUpdate = null,
int? busyTimeoutMs = null,
IReadOnlyCollection<string>? userEventThreadIds = null,
IReadOnlyDictionary<string, string>? threadCwdsById = null)
{
string? dbPath = ExistingStateDbPath(codexHome);
if (dbPath is null)
{
if (afterUpdate is not null)
{
await afterUpdate((0, 0, 0, 0, false));
}
return (0, 0, 0, 0, false);
}
await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadWriteCreate);
bool transactionOpen = false;
try
{
await connection.OpenAsync();
await SetBusyTimeoutAsync(connection, busyTimeoutMs);
await ExecuteNonQueryAsync(connection, "BEGIN IMMEDIATE");
transactionOpen = true;
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE threads
SET model_provider = $provider
WHERE COALESCE(model_provider, '') <> $provider
""";
command.Parameters.AddWithValue("$provider", targetProvider);
int providerRowsUpdated = await command.ExecuteNonQueryAsync();
int userEventRowsUpdated = 0;
if (userEventThreadIds?.Count > 0 && await TableHasColumnAsync(connection, "threads", "has_user_event"))
{
await using SqliteCommand userEventCommand = connection.CreateCommand();
userEventCommand.CommandText = """
UPDATE threads
SET has_user_event = 1
WHERE id = $id AND COALESCE(has_user_event, 0) <> 1
""";
SqliteParameter idParameter = userEventCommand.Parameters.Add("$id", SqliteType.Text);
foreach (string threadId in userEventThreadIds)
{
idParameter.Value = threadId;
userEventRowsUpdated += await userEventCommand.ExecuteNonQueryAsync();
}
}
int cwdRowsUpdated = 0;
if (threadCwdsById?.Count > 0 && await TableHasColumnAsync(connection, "threads", "cwd"))
{
await using SqliteCommand cwdCommand = connection.CreateCommand();
cwdCommand.CommandText = """
UPDATE threads
SET cwd = $cwd
WHERE id = $id AND COALESCE(cwd, '') <> $cwd
""";
SqliteParameter cwdIdParameter = cwdCommand.Parameters.Add("$id", SqliteType.Text);
SqliteParameter cwdParameter = cwdCommand.Parameters.Add("$cwd", SqliteType.Text);
foreach ((string threadId, string cwd) in threadCwdsById)
{
if (string.IsNullOrWhiteSpace(threadId) || string.IsNullOrWhiteSpace(cwd))
{
continue;
}
cwdIdParameter.Value = threadId;
cwdParameter.Value = cwd;
cwdRowsUpdated += await cwdCommand.ExecuteNonQueryAsync();
}
}
int updatedRows = providerRowsUpdated + userEventRowsUpdated + cwdRowsUpdated;
if (afterUpdate is not null)
{
await afterUpdate((updatedRows, providerRowsUpdated, userEventRowsUpdated, cwdRowsUpdated, true));
}
await ExecuteNonQueryAsync(connection, "COMMIT");
transactionOpen = false;
return (updatedRows, providerRowsUpdated, userEventRowsUpdated, cwdRowsUpdated, true);
}
catch (Exception error)
{
if (transactionOpen)
{
try
{
await ExecuteNonQueryAsync(connection, "ROLLBACK");
}
catch
{
// Ignore rollback failures and surface the original error.
}
}
throw WrapSqliteMalformedError(
WrapSqliteBusyError(error, "update session provider metadata"),
"update session provider metadata");
}
}
private static SqliteConnection OpenConnection(string dbPath, SqliteOpenMode mode)
{
SqliteConnectionStringBuilder builder = new()
{
DataSource = dbPath,
Mode = mode,
Pooling = false
};
return new SqliteConnection(builder.ConnectionString);
}
private static long CountRolloutFiles(string codexHome)
{
long count = 0;
foreach (string directory in AppConstants.SessionDirectories)
{
count += CountRolloutFilesInDirectory(Path.Combine(codexHome, directory));
}
return count;
}
private static long CountRolloutFilesInDirectory(string rootDir)
{
if (!Directory.Exists(rootDir))
{
return 0;
}
try
{
return Directory
.EnumerateFiles(rootDir, "rollout-*.jsonl", SearchOption.AllDirectories)
.LongCount();
}
catch
{
return 0;
}
}
private static StateDbCandidateStats ReadStateDbCandidateStats(
StateDbLocation candidate,
int priority,
long rolloutCount)
{
using SqliteConnection connection = OpenConnection(candidate.Path, SqliteOpenMode.ReadOnly);
connection.Open();
if (!TableExists(connection, "threads"))
{
throw new InvalidOperationException("threads table not found");
}
long threadCount = ExecuteScalarLong(connection, "SELECT COUNT(*) FROM threads");
long rolloutDistance = rolloutCount > 0 ? Math.Abs(threadCount - rolloutCount) : 0;
return new StateDbCandidateStats(
candidate,
priority,
threadCount,
MaxThreadTimestampMs(connection),
File.GetLastWriteTimeUtc(candidate.Path).Ticks,
rolloutDistance);
}
private static bool TableExists(SqliteConnection connection, string tableName)
{
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name";
command.Parameters.AddWithValue("$name", tableName);
object? value = command.ExecuteScalar();
return value is not null && value is not DBNull;
}
private static long MaxThreadTimestampMs(SqliteConnection connection)
{
if (TableHasColumn(connection, "threads", "updated_at_ms"))
{
return ExecuteScalarLong(connection, "SELECT COALESCE(MAX(updated_at_ms), 0) FROM threads");
}
if (TableHasColumn(connection, "threads", "updated_at"))
{
return ExecuteScalarLong(connection, "SELECT COALESCE(MAX(updated_at), 0) FROM threads") * 1000;
}
if (TableHasColumn(connection, "threads", "created_at_ms"))
{
return ExecuteScalarLong(connection, "SELECT COALESCE(MAX(created_at_ms), 0) FROM threads");
}
if (TableHasColumn(connection, "threads", "created_at"))
{
return ExecuteScalarLong(connection, "SELECT COALESCE(MAX(created_at), 0) FROM threads") * 1000;
}
return 0;
}
private static long ExecuteScalarLong(SqliteConnection connection, string commandText)
{
using SqliteCommand command = connection.CreateCommand();
command.CommandText = commandText;
object? value = command.ExecuteScalar();
return value is null || value is DBNull ? 0 : Convert.ToInt64(value);
}
private static async Task SetBusyTimeoutAsync(SqliteConnection connection, int? busyTimeoutMs)
{
int timeout = busyTimeoutMs is >= 0 ? busyTimeoutMs.Value : DefaultBusyTimeoutMs;
await ExecuteNonQueryAsync(connection, $"PRAGMA busy_timeout = {timeout}");
}
private static async Task ExecuteNonQueryAsync(SqliteConnection connection, string commandText)
{
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = commandText;
await command.ExecuteNonQueryAsync();
}
private static async Task<bool> TableHasColumnAsync(SqliteConnection connection, string tableName, string columnName)
{
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info({QuoteIdentifier(tableName)})";
await using SqliteDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
if (string.Equals(reader.GetString(1), columnName, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static bool TableHasColumn(SqliteConnection connection, string tableName, string columnName)
{
using SqliteCommand command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info({QuoteIdentifier(tableName)})";
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
if (string.Equals(reader.GetString(1), columnName, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static string QuoteIdentifier(string value)
{
return "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\"";
}
internal static Exception WrapSqliteBusyError(Exception error, string action)
{
if (error is not SqliteException sqliteError
|| (sqliteError.SqliteErrorCode != 5 && sqliteError.SqliteErrorCode != 6))
{
return error;
}
return new InvalidOperationException(
$"Unable to {action} because state_5.sqlite is currently in use. Close Codex and the Codex app, then retry. Original error: {sqliteError.Message}",
sqliteError);
}
private static bool IsSqliteBusyError(Exception error)
{
if (error.InnerException is not null && IsSqliteBusyError(error.InnerException))
{
return true;
}
return error is SqliteException sqliteError
&& (sqliteError.SqliteErrorCode == 5 || sqliteError.SqliteErrorCode == 6);
}
private static bool IsSqliteMalformedError(Exception error)
{
if (error.InnerException is not null && IsSqliteMalformedError(error.InnerException))
{
return true;
}
return error is SqliteException sqliteError
&& (sqliteError.SqliteErrorCode == 11
|| sqliteError.Message.Contains("malformed", StringComparison.OrdinalIgnoreCase)
|| sqliteError.Message.Contains("not a database", StringComparison.OrdinalIgnoreCase));
}
internal static Exception WrapSqliteMalformedError(Exception error, string action)
{
if (!IsSqliteMalformedError(error))
{
return error;
}
return new InvalidOperationException(
$"Unable to {action} because state_5.sqlite is malformed or unreadable. Close Codex, back up or repair the database, then retry. Original error: {error.Message}",
error);
}
}