diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs index eaea976..c52fd60 100644 --- a/HellionChat/MessageStore.cs +++ b/HellionChat/MessageStore.cs @@ -189,6 +189,14 @@ internal class MessageStore : IDisposable private volatile bool _ftsReady; public bool IsFtsIndexBuilt => _ftsReady; + // Serialises read/write access to the primary Connection so the DbViewer + // filter-worker (Task.Run) and the live PendingMessageThread UpsertMessage + // path do not race on a non-thread-safe SqliteConnection. Every existing + // internal method that touches Connection takes the same lock at its + // outermost scope. RebuildFtsIndex stays outside the lock -- it owns its + // own SqliteConnection via OpenSecondaryConnection. + private readonly object _readLock = new(); + internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger) { DbPath = dbPath; @@ -409,27 +417,33 @@ internal class MessageStore : IDisposable internal void ClearMessages() { - Connection.Execute("DELETE FROM messages;"); - PerformMaintenance(); + lock (_readLock) + { + Connection.Execute("DELETE FROM messages;"); + PerformMaintenance(); + } } // Returns a (ChatType, count) snapshot over non-deleted messages. // Used by the Privacy tab to preview retroactive cleanup impact. internal Dictionary GetMessageCountsByChatType() { - var result = new Dictionary(); - using var cmd = Connection.CreateCommand(); - cmd.CommandText = - "SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;"; - cmd.CommandTimeout = 120; - using var reader = cmd.ExecuteReader(); - while (reader.Read()) + lock (_readLock) { - var chatType = reader.GetInt32(0); - var count = reader.GetInt64(1); - result[chatType] = count; + var result = new Dictionary(); + using var cmd = Connection.CreateCommand(); + cmd.CommandText = + "SELECT ChatType, COUNT(*) FROM messages WHERE deleted = false GROUP BY ChatType;"; + cmd.CommandTimeout = 120; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var chatType = reader.GetInt32(0); + var count = reader.GetInt64(1); + result[chatType] = count; + } + return result; } - return result; } // Deletes messages older than the per-channel retention window, with a global @@ -457,48 +471,51 @@ internal class MessageStore : IDisposable if (chatTypeDaysMap.Count == 0 && defaultDays <= 0) return 0; - long deleted; - using (var cmd = Connection.CreateCommand()) + lock (_readLock) { - var clauses = new List(); - var index = 0; - foreach (var (type, days) in chatTypeDaysMap) + long deleted; + using (var cmd = Connection.CreateCommand()) { - var cutoff = nowMs - days * 86400000L; - var typeParam = $"$type{index}"; - var cutoffParam = $"$cutoff{index}"; - cmd.Parameters.AddWithValue(typeParam, type); - cmd.Parameters.AddWithValue(cutoffParam, cutoff); - clauses.Add($"(ChatType = {typeParam} AND Date < {cutoffParam})"); - index++; + var clauses = new List(); + var index = 0; + foreach (var (type, days) in chatTypeDaysMap) + { + var cutoff = nowMs - days * 86400000L; + var typeParam = $"$type{index}"; + var cutoffParam = $"$cutoff{index}"; + cmd.Parameters.AddWithValue(typeParam, type); + cmd.Parameters.AddWithValue(cutoffParam, cutoff); + clauses.Add($"(ChatType = {typeParam} AND Date < {cutoffParam})"); + index++; + } + + // defaultDays=0 means "keep forever" for unmapped channels. + if (defaultDays > 0) + { + var defaultCutoff = nowMs - defaultDays * 86400000L; + cmd.Parameters.AddWithValue("$defaultCutoff", defaultCutoff); + + var explicitPlaceholders = + chatTypeDaysMap.Count > 0 + ? BindIntList(cmd, "explicit", chatTypeDaysMap.Keys) + : "-1"; // empty list would produce invalid SQL + clauses.Add( + $"(ChatType NOT IN ({explicitPlaceholders}) AND Date < $defaultCutoff)" + ); + } + + if (clauses.Count == 0) + return 0; + + cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};"; + cmd.CommandTimeout = 600; + deleted = cmd.ExecuteNonQuery(); } - // defaultDays=0 means "keep forever" for unmapped channels. - if (defaultDays > 0) - { - var defaultCutoff = nowMs - defaultDays * 86400000L; - cmd.Parameters.AddWithValue("$defaultCutoff", defaultCutoff); - - var explicitPlaceholders = - chatTypeDaysMap.Count > 0 - ? BindIntList(cmd, "explicit", chatTypeDaysMap.Keys) - : "-1"; // empty list would produce invalid SQL - clauses.Add( - $"(ChatType NOT IN ({explicitPlaceholders}) AND Date < $defaultCutoff)" - ); - } - - if (clauses.Count == 0) - return 0; - - cmd.CommandText = $"DELETE FROM messages WHERE {string.Join(" OR ", clauses)};"; - cmd.CommandTimeout = 600; - deleted = cmd.ExecuteNonQuery(); + if (deleted > 0) + PerformMaintenance(); + return deleted; } - - if (deleted > 0) - PerformMaintenance(); - return deleted; } // Hard-deletes every message whose ChatType is not in the allowlist, @@ -510,27 +527,33 @@ internal class MessageStore : IDisposable "CleanupRetainOnly requires at least one allowed ChatType. Use ClearMessages for a full wipe." ); - long deleted; - using (var cmd = Connection.CreateCommand()) + lock (_readLock) { - var placeholders = BindIntList(cmd, "ct", allowedTypes); - cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({placeholders});"; - cmd.CommandTimeout = 600; - deleted = cmd.ExecuteNonQuery(); + long deleted; + using (var cmd = Connection.CreateCommand()) + { + var placeholders = BindIntList(cmd, "ct", allowedTypes); + cmd.CommandText = $"DELETE FROM messages WHERE ChatType NOT IN ({placeholders});"; + cmd.CommandTimeout = 600; + deleted = cmd.ExecuteNonQuery(); + } + PerformMaintenance(); + return deleted; } - PerformMaintenance(); - return deleted; } internal void PerformMaintenance() { - Connection.Execute( - @" - VACUUM; - REINDEX messages; - ANALYZE; - " - ); + lock (_readLock) + { + Connection.Execute( + @" + VACUUM; + REINDEX messages; + ANALYZE; + " + ); + } } private string LogPath => DbPath + "-wal"; @@ -541,9 +564,12 @@ internal class MessageStore : IDisposable internal int MessageCount() { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM messages;"; - return Convert.ToInt32(cmd.ExecuteScalar()); + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM messages;"; + return Convert.ToInt32(cmd.ExecuteScalar()); + } } // Schema probe for the v1.4.8 FTS5 virtual table. Used by the Build-Suite @@ -705,6 +731,84 @@ internal class MessageStore : IDisposable return total; } + // FTS5 full-text search across the entire messages_fts index. Returns + // hex-encoded GUIDs; the caller resolves them to Message objects via + // LoadByGuids. An empty or whitespace-only term short-circuits to an + // empty list so callers can fall back to the local page filter. + public IReadOnlyList FullTextSearch(string term, int limit = 1000) + { + if (string.IsNullOrWhiteSpace(term)) + return Array.Empty(); + + lock (_readLock) + { + var hexIds = new List(capacity: 256); + using var cmd = Connection.CreateCommand(); + cmd.CommandText = """ + SELECT message_guid FROM messages_fts + WHERE messages_fts MATCH $term + ORDER BY rank + LIMIT $limit; + """; + cmd.Parameters.AddWithValue("$term", EscapeFtsTerm(term)); + cmd.Parameters.AddWithValue("$limit", limit); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + hexIds.Add(reader.GetString(0)); + return hexIds; + } + } + + // Joins hex-encoded GUIDs from FullTextSearch back to Message rows. The + // primary key is BLOB, so we decode the hex back to bytes for the IN(...) + // lookup. SQLite has a hard parameter limit of 999 in default builds, so + // we chunk the input -- a 1000-hit FTS query never explodes the SELECT. + // Result ordering is not guaranteed; callers re-sort (e.g. DbViewer sorts + // by Date descending in Sub-Task 4.4). + public IReadOnlyList LoadByGuids(IReadOnlyList hexIds) + { + if (hexIds.Count == 0) + return Array.Empty(); + + lock (_readLock) + { + var result = new List(hexIds.Count); + const int chunkSize = 500; + for (var offset = 0; offset < hexIds.Count; offset += chunkSize) + { + var batch = hexIds.Skip(offset).Take(chunkSize).ToList(); + using var cmd = Connection.CreateCommand(); + var placeholders = string.Join(",", batch.Select((_, i) => $"$id{i}")); + cmd.CommandText = $""" + SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, + Sender, Content, SenderSource, ContentSource, ExtraChatChannel + FROM messages + WHERE Id IN ({placeholders}) AND Deleted = false; + """; + for (var i = 0; i < batch.Count; i++) + cmd.Parameters.AddWithValue($"$id{i}", Convert.FromHexString(batch[i])); + + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + result.Add(ReadMessageRow(reader)); + } + return result; + } + } + + // FTS5's MATCH operator interprets ", ~, ^, - as syntax. Wrap user terms + // in double quotes so the search is "what you see is what you get" -- a + // multi-word query matches as a phrase, not as per-word AND. Power users + // can opt into raw MATCH syntax by wrapping their own quotes; we detect + // that and pass the term through unchanged. + internal static string EscapeFtsTerm(string term) + { + if (term.Contains('"')) + return term; + return $"\"{term.Replace("\"", "\"\"")}\""; + } + internal void UpsertMessage(Message message) { // Privacy filter -- drop disallowed ChatTypes before they reach storage. @@ -714,9 +818,11 @@ internal class MessageStore : IDisposable return; } - using var cmd = Connection.CreateCommand(); - cmd.CommandText = - @" + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = + @" INSERT INTO messages ( Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel, Deleted @@ -739,77 +845,88 @@ internal class MessageStore : IDisposable Deleted = false; "; - cmd.Parameters.AddWithValue("$Id", message.Id); - cmd.Parameters.AddWithValue("$Receiver", message.Receiver); - cmd.Parameters.AddWithValue("$ContentId", message.ContentId); - cmd.Parameters.AddWithValue("$Date", message.Date.ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$ChatType", message.Code.Type); - cmd.Parameters.AddWithValue("$SourceKind", message.Code.Source); - cmd.Parameters.AddWithValue("$TargetKind", message.Code.Target); - cmd.Parameters.AddWithValue( - "$Sender", - MessagePackSerializer.Serialize(message.Sender, MsgPackOptions) - ); - cmd.Parameters.AddWithValue( - "$Content", - MessagePackSerializer.Serialize(message.Content, MsgPackOptions) - ); - cmd.Parameters.AddWithValue( - "$SenderSource", - MessagePackSerializer.Serialize(message.SenderSource, MsgPackOptions) - ); - cmd.Parameters.AddWithValue( - "$ContentSource", - MessagePackSerializer.Serialize(message.ContentSource, MsgPackOptions) - ); - cmd.Parameters.AddWithValue("$ExtraChatChannel", message.ExtraChatChannel); + cmd.Parameters.AddWithValue("$Id", message.Id); + cmd.Parameters.AddWithValue("$Receiver", message.Receiver); + cmd.Parameters.AddWithValue("$ContentId", message.ContentId); + cmd.Parameters.AddWithValue("$Date", message.Date.ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue("$ChatType", message.Code.Type); + cmd.Parameters.AddWithValue("$SourceKind", message.Code.Source); + cmd.Parameters.AddWithValue("$TargetKind", message.Code.Target); + cmd.Parameters.AddWithValue( + "$Sender", + MessagePackSerializer.Serialize(message.Sender, MsgPackOptions) + ); + cmd.Parameters.AddWithValue( + "$Content", + MessagePackSerializer.Serialize(message.Content, MsgPackOptions) + ); + cmd.Parameters.AddWithValue( + "$SenderSource", + MessagePackSerializer.Serialize(message.SenderSource, MsgPackOptions) + ); + cmd.Parameters.AddWithValue( + "$ContentSource", + MessagePackSerializer.Serialize(message.ContentSource, MsgPackOptions) + ); + cmd.Parameters.AddWithValue("$ExtraChatChannel", message.ExtraChatChannel); - cmd.ExecuteNonQuery(); + cmd.ExecuteNonQuery(); + } } // Streams messages for export, sorted ascending by Date, excluding soft-deleted rows. // Optional filters: chatTypes, from/to inclusive date range. // Caller is responsible for disposing the enumerator. + // Lock caveat: lock guards command setup and ExecuteReader; the returned + // MessageEnumerator is iterated lazily by the caller outside the lock. + // Acceptable for v1.4.8 -- DbViewer iterates on its filter-worker Task and + // any clash with UpsertMessage on the primary Connection is rare and + // serialised by SQLite's own connection-level lock. v1.5.x DI cycle should + // address this with a snapshot-to-list or connection pool. internal MessageEnumerator StreamForExport( IReadOnlyCollection? chatTypes, DateTimeOffset? from, DateTimeOffset? to ) { - var cmd = Connection.CreateCommand(); + lock (_readLock) + { + var cmd = Connection.CreateCommand(); - var clauses = new List { "deleted = false" }; - if (chatTypes is { Count: > 0 }) - clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); - if (from is not null) - clauses.Add("Date >= $From"); - if (to is not null) - clauses.Add("Date <= $To"); + var clauses = new List { "deleted = false" }; + if (chatTypes is { Count: > 0 }) + clauses.Add($"ChatType IN ({BindIntList(cmd, "exct", chatTypes)})"); + if (from is not null) + clauses.Add("Date >= $From"); + if (to is not null) + clauses.Add("Date <= $To"); - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages WHERE " - + string.Join(" AND ", clauses) - + @" + + string.Join(" AND ", clauses) + + @" ORDER BY Date ASC;"; - cmd.CommandTimeout = 600; + cmd.CommandTimeout = 600; - if (from is not null) - cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds()); - if (to is not null) - cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); + if (from is not null) + cmd.Parameters.AddWithValue("$From", from.Value.ToUnixTimeMilliseconds()); + if (to is not null) + cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds()); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator(cmd.ExecuteReader(), _logger); + } } // Returns the most recent messages, oldest-first. // receiver: filter by receiver ContentId (null = no filter) // since: only include messages after this date (null = no filter) // count: max rows to return, defaults to 10,000 + // Lock caveat: same lazy-enumerator note as StreamForExport. internal MessageEnumerator GetMostRecentMessages( ulong? receiver = null, DateTimeOffset? since = null, @@ -824,10 +941,12 @@ internal class MessageStore : IDisposable var whereClause = "WHERE " + string.Join(" AND ", whereClauses); - var cmd = Connection.CreateCommand(); - // Select last N by date DESC, then reverse to ascending order. - cmd.CommandText = - @" + lock (_readLock) + { + var cmd = Connection.CreateCommand(); + // Select last N by date DESC, then reverse to ascending order. + cmd.CommandText = + @" SELECT * FROM ( SELECT @@ -835,23 +954,24 @@ internal class MessageStore : IDisposable Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages " - + whereClause - + @" + + whereClause + + @" ORDER BY Date DESC LIMIT $Count ) ORDER BY Date ASC; "; - cmd.CommandTimeout = 120; + cmd.CommandTimeout = 120; - if (receiver != null) - cmd.Parameters.AddWithValue("$Receiver", receiver); - if (since != null) - cmd.Parameters.AddWithValue("$Since", since.Value.ToUnixTimeMilliseconds()); + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); + if (since != null) + cmd.Parameters.AddWithValue("$Since", since.Value.ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Count", count); + cmd.Parameters.AddWithValue("$Count", count); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator(cmd.ExecuteReader(), _logger); + } } // Returns up to limit tells exchanged with the named player, oldest-first. @@ -869,9 +989,11 @@ internal class MessageStore : IDisposable if (limit <= 0) return []; - using var cmd = Connection.CreateCommand(); - cmd.CommandText = - @" + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel @@ -882,36 +1004,40 @@ internal class MessageStore : IDisposable ORDER BY Date DESC LIMIT $ScanLimit; "; - cmd.CommandTimeout = 60; - cmd.Parameters.AddWithValue("$Receiver", receiver); - cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming); - cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing); - cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit); + cmd.CommandTimeout = 60; + cmd.Parameters.AddWithValue("$Receiver", receiver); + cmd.Parameters.AddWithValue("$TellIncoming", (int)ChatType.TellIncoming); + cmd.Parameters.AddWithValue("$TellOutgoing", (int)ChatType.TellOutgoing); + cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit); - var collected = new List(); - using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger); - foreach (var message in enumerator) - { - if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) - continue; + var collected = new List(); + using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger); + foreach (var message in enumerator) + { + if (!ChunkUtil.MatchesSender(message, senderName, senderWorld)) + continue; - collected.Add(message); - if (collected.Count >= limit) - break; + collected.Add(message); + if (collected.Count >= limit) + break; + } + + // SQL was DESC (newest-first); reverse to oldest-first for tab display. + collected.Reverse(); + return collected; } - - // SQL was DESC (newest-first); reverse to oldest-first for tab display. - collected.Reverse(); - return collected; } // Soft-deletes a message so it won't appear in queries. internal void DeleteMessage(Guid id) { - using var cmd = Connection.CreateCommand(); - cmd.CommandText = "UPDATE messages SET Deleted = true WHERE Id = $Id;"; - cmd.Parameters.AddWithValue("$Id", id); - cmd.ExecuteNonQuery(); + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); + cmd.CommandText = "UPDATE messages SET Deleted = true WHERE Id = $Id;"; + cmd.Parameters.AddWithValue("$Id", id); + cmd.ExecuteNonQuery(); + } } internal long CountDateRange( @@ -921,33 +1047,42 @@ internal class MessageStore : IDisposable ulong? receiver = null ) { - using var cmd = Connection.CreateCommand(); + lock (_readLock) + { + using var cmd = Connection.CreateCommand(); - List whereClauses = ["deleted = false"]; - if (receiver != null) - whereClauses.Add("Receiver = $Receiver"); + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); - whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})"); + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add( + $"ChatType IN ({BindIntList(cmd, "cdr", channels.Select(c => (int)c))})" + ); - var whereClause = "WHERE " + string.Join(" AND ", whereClauses); + var whereClause = "WHERE " + string.Join(" AND ", whereClauses); - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT COUNT(*) FROM messages " + whereClause; - if (receiver != null) - cmd.Parameters.AddWithValue("$Receiver", receiver); + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); - cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); - cmd.CommandTimeout = 120; + cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue( + "$Before", + ((DateTimeOffset)before).ToUnixTimeMilliseconds() + ); + cmd.CommandTimeout = 120; - return (long)cmd.ExecuteScalar()!; + return (long)cmd.ExecuteScalar()!; + } } + // Lock caveat: same lazy-enumerator note as StreamForExport. internal MessageEnumerator GetDateRange( DateTime after, DateTime before, @@ -955,35 +1090,44 @@ internal class MessageStore : IDisposable ulong? receiver = null ) { - var cmd = Connection.CreateCommand(); + lock (_readLock) + { + var cmd = Connection.CreateCommand(); - List whereClauses = ["deleted = false"]; - if (receiver != null) - whereClauses.Add("Receiver = $Receiver"); + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); - whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})"); + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add( + $"ChatType IN ({BindIntList(cmd, "gdr", channels.Select(c => (int)c))})" + ); - var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; + var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages " + whereClause; - cmd.CommandTimeout = 120; + cmd.CommandTimeout = 120; - if (receiver != null) - cmd.Parameters.AddWithValue("$Receiver", receiver); + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); - cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue( + "$Before", + ((DateTimeOffset)before).ToUnixTimeMilliseconds() + ); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator(cmd.ExecuteReader(), _logger); + } } + // Lock caveat: same lazy-enumerator note as StreamForExport. internal MessageEnumerator GetPagedDateRange( DateTime after, DateTime before, @@ -992,40 +1136,48 @@ internal class MessageStore : IDisposable int page = 0 ) { - var cmd = Connection.CreateCommand(); + lock (_readLock) + { + var cmd = Connection.CreateCommand(); - List whereClauses = ["deleted = false"]; - if (receiver != null) - whereClauses.Add("Receiver = $Receiver"); + List whereClauses = ["deleted = false"]; + if (receiver != null) + whereClauses.Add("Receiver = $Receiver"); - whereClauses.Add("Date BETWEEN $After AND $Before"); - whereClauses.Add($"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})"); + whereClauses.Add("Date BETWEEN $After AND $Before"); + whereClauses.Add( + $"ChatType IN ({BindIntList(cmd, "pdr", channels.Select(c => (int)c))})" + ); - var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; + var whereClause = $"WHERE {string.Join(" AND ", whereClauses)}"; - cmd.CommandText = - @" + cmd.CommandText = + @" SELECT Id, Receiver, ContentId, Date, ChatType, SourceKind, TargetKind, Sender, Content, SenderSource, ContentSource, ExtraChatChannel FROM messages " - + whereClause - + @" + + whereClause + + @" ORDER BY Date LIMIT $Offset, $OffsetCount; "; - cmd.CommandTimeout = 120; + cmd.CommandTimeout = 120; - if (receiver != null) - cmd.Parameters.AddWithValue("$Receiver", receiver); + if (receiver != null) + cmd.Parameters.AddWithValue("$Receiver", receiver); - cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds()); - cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page); - cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage); + cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue( + "$Before", + ((DateTimeOffset)before).ToUnixTimeMilliseconds() + ); + cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page); + cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage); - return new MessageEnumerator(cmd.ExecuteReader(), _logger); + return new MessageEnumerator(cmd.ExecuteReader(), _logger); + } } // Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command.