Merge origin/main (561b053f79) into simp/forwardport: forward-port 220 main commits into the simplified tree
This commit is contained in:
+101
-49
@@ -14,9 +14,10 @@ from typing import Any, Callable, Collection, Dict, List, Optional, Tuple
|
||||
from agent.skill_commands import describe_skill_invocation
|
||||
from utils import env_float
|
||||
from hermes_state_common import (
|
||||
FTS_CJK_STALE_KEY, FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, FTS_TRIGRAM_SQL,
|
||||
FTS_CJK_STALE_KEY, FTS_SQL, FTS_STALE_KEY, FTS_STORAGE_VERSION, FTS_TOOL_CONTENT_PREFIX_CHARS,
|
||||
FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, FTS_TRIGRAM_EXCLUDED_SOURCES, FTS_TRIGRAM_SQL,
|
||||
MAX_FTS5_QUERY_CHARS, SCHEMA_VERSION, _FTS_CJK_TRIGGERS,
|
||||
escape_like as _escape_like, fts_rebuild_admission,
|
||||
escape_like as _escape_like, fts_rebuild_admission, fts_trigram_session_sql,
|
||||
)
|
||||
|
||||
# Pre-split logger identity so log filtering/capture is unchanged.
|
||||
@@ -214,43 +215,64 @@ class SessionSearchMixin:
|
||||
return {"pending": True, "total": total, "indexed": progress, "percent": min(100, int(100 * progress / total))}
|
||||
|
||||
# Re-index rows in an id window the index is missing. docsize has one row
|
||||
# per indexed doc, so the anti-join is exact.
|
||||
# per indexed doc, so the anti-join is exact. Params: (lo, hi) — the base sweep
|
||||
# takes (hw, prefix_chars, lo, hi): tool rows past the high water index only a prefix.
|
||||
_BOUNDARY_SWEEP_SQL = (
|
||||
"INSERT INTO {table}(rowid, content, tool_name, tool_calls) "
|
||||
"SELECT m.id, m.content, m.tool_name, m.tool_calls FROM messages m WHERE m.id > ? AND m.id <= ? {extra}"
|
||||
"AND NOT EXISTS (SELECT 1 FROM {table}_docsize d WHERE d.id = m.id)"
|
||||
)
|
||||
_BASE_BOUNDARY_SWEEP_SQL = (
|
||||
"INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) "
|
||||
"SELECT m.id, CASE WHEN m.role = 'tool' AND m.id > ? THEN substr(COALESCE(m.content, ''), 1, ?) "
|
||||
"ELSE m.content END, m.tool_name, m.tool_calls FROM messages m WHERE m.id > ? AND m.id <= ? "
|
||||
"AND NOT EXISTS (SELECT 1 FROM messages_fts_docsize d WHERE d.id = m.id)"
|
||||
)
|
||||
# Trigram excludes tool rows and FTS_TRIGRAM_EXCLUDED_SOURCES sessions; no tool_calls column.
|
||||
_TRIGRAM_BOUNDARY_SWEEP_SQL = (
|
||||
"INSERT INTO messages_fts_trigram(rowid, content, tool_name) "
|
||||
"SELECT m.id, m.content, m.tool_name FROM messages m JOIN sessions s ON s.id = m.session_id "
|
||||
f"WHERE m.id > ? AND m.id <= ? AND m.role <> 'tool' AND {fts_trigram_session_sql('s')} "
|
||||
"AND NOT EXISTS (SELECT 1 FROM messages_fts_trigram_docsize d WHERE d.id = m.id)"
|
||||
)
|
||||
_CHUNK_INSERT_SQL = (
|
||||
"INSERT INTO {table}(rowid, content, tool_name, tool_calls) "
|
||||
"SELECT id, content, tool_name, tool_calls FROM messages WHERE id > ? AND id <= ?{extra}"
|
||||
)
|
||||
_TRIGRAM_CHUNK_INSERT_SQL = (
|
||||
"INSERT INTO messages_fts_trigram(rowid, content, tool_name) "
|
||||
"SELECT m.id, m.content, m.tool_name FROM messages m JOIN sessions s ON s.id = m.session_id "
|
||||
f"WHERE m.id > ? AND m.id <= ? AND m.role <> 'tool' AND {fts_trigram_session_sql('s')}"
|
||||
)
|
||||
|
||||
def _fts_rebuild_finish(self) -> None:
|
||||
"""Finalize the deferred rebuild: boundary sweep + clear markers. The sweep is cheap
|
||||
insurance against a write that slipped between high_water capture and trigger
|
||||
activation. The trigram half is gated on ``_trigram_available``: without the
|
||||
tokenizer/table an unconditional INSERT raises and aborts the whole rebuild."""
|
||||
sweeps = [self._BOUNDARY_SWEEP_SQL.format(table="messages_fts", extra="")]
|
||||
sweeps = [(self._BASE_BOUNDARY_SWEEP_SQL, True)]
|
||||
if self._trigram_available:
|
||||
sweeps.append(self._BOUNDARY_SWEEP_SQL.format(table="messages_fts_trigram", extra="AND m.role <> 'tool' "))
|
||||
sweeps.append((self._TRIGRAM_BOUNDARY_SWEEP_SQL, False))
|
||||
self._rebuild_finish("fts_rebuild", sweeps)
|
||||
logger.info("Deferred FTS rebuild complete — all messages indexed.")
|
||||
|
||||
def _fts_cjk_rebuild_finish(self) -> None:
|
||||
"""Boundary sweep + clear the cjk markers; index becomes servable."""
|
||||
sweep = self._BOUNDARY_SWEEP_SQL.format(table="messages_fts_cjk", extra="AND m.role <> 'tool' ")
|
||||
self._rebuild_finish("fts_cjk_rebuild", [sweep])
|
||||
self._rebuild_finish("fts_cjk_rebuild", [(sweep, False)])
|
||||
self._fts_cjk_available = True
|
||||
logger.info("CJK FTS index backfill complete — serving CJK search.")
|
||||
|
||||
def _rebuild_finish(self, prefix: str, sweep_sqls: List[str]) -> None:
|
||||
"""Sweep a generous window around the high-water boundary, then clear the markers."""
|
||||
def _rebuild_finish(self, prefix: str, sweep_sqls: List[Tuple[str, bool]]) -> None:
|
||||
"""Sweep a generous window around the high-water boundary, then clear the markers.
|
||||
``(sql, bounded)``: a bounded sweep takes the (hw, prefix_chars) tool-content params first."""
|
||||
def _do(conn):
|
||||
hw_row = _meta_row(conn, f"{prefix}_high_water")
|
||||
if hw_row is not None:
|
||||
hw = int(hw_row[0])
|
||||
for sql in sweep_sqls:
|
||||
conn.execute(sql, (hw - 1000, hw + 1000))
|
||||
for sql, bounded in sweep_sqls:
|
||||
params = (hw, FTS_TOOL_CONTENT_PREFIX_CHARS) if bounded else ()
|
||||
conn.execute(sql, (*params, hw - 1000, hw + 1000))
|
||||
_delete_meta(conn, f"{prefix}_high_water", f"{prefix}_progress")
|
||||
self._execute_write(_do)
|
||||
|
||||
@@ -262,9 +284,9 @@ class SessionSearchMixin:
|
||||
return False
|
||||
inserts = [self._CHUNK_INSERT_SQL.format(table="messages_fts", extra="")]
|
||||
if self._trigram_available:
|
||||
inserts.append(self._CHUNK_INSERT_SQL.format(table="messages_fts_trigram", extra=" AND role <> 'tool'"))
|
||||
inserts.append(self._TRIGRAM_CHUNK_INSERT_SQL)
|
||||
return self._rebuild_step("fts_rebuild", inserts, fail_msg="FTS rebuild chunk failed (will retry): %s",
|
||||
finish=self._fts_rebuild_finish)
|
||||
finish=self._fts_rebuild_finish, finish_when_empty=True)
|
||||
|
||||
def fts_cjk_rebuild_step(self) -> bool:
|
||||
"""Backfill one chunk of the CJK index. True while work remains."""
|
||||
@@ -274,8 +296,10 @@ class SessionSearchMixin:
|
||||
return self._rebuild_step("fts_cjk_rebuild", [insert], finish=self._fts_cjk_rebuild_finish,
|
||||
fail_msg="CJK FTS rebuild chunk failed (will retry): %s")
|
||||
|
||||
def _rebuild_step(self, prefix: str, insert_sqls: List[str], *, fail_msg: str, finish) -> bool:
|
||||
"""Shared chunk engine for the base and CJK deferred backfills."""
|
||||
def _rebuild_step(self, prefix: str, insert_sqls: List[str], *, fail_msg: str, finish,
|
||||
finish_when_empty: bool = False) -> bool:
|
||||
"""Shared chunk engine for the base and CJK deferred backfills. ``finish_when_empty``
|
||||
finalizes a high_water <= 0 marker (empty messages table) instead of leaving it pending."""
|
||||
high_water_raw = self.get_meta(f"{prefix}_high_water")
|
||||
if high_water_raw is None:
|
||||
return False
|
||||
@@ -306,7 +330,9 @@ class SessionSearchMixin:
|
||||
return True # transient (lock contention) — caller retries
|
||||
if more is False:
|
||||
status = self._rebuild_status(prefix)
|
||||
if status is not None and status["indexed"] >= status["total"]:
|
||||
if (finish_when_empty and high_water <= 0) or (
|
||||
status is not None and status["indexed"] >= status["total"]
|
||||
):
|
||||
finish()
|
||||
return False
|
||||
return bool(more)
|
||||
@@ -316,8 +342,8 @@ class SessionSearchMixin:
|
||||
work remains. INTEGER single-column-key tables drain with a high-water marker so
|
||||
each chunk's scan is bounded (restarting the scan was O(n²)); compound-key tables
|
||||
keep the chunked ``LIMIT`` delete — they are small by construction."""
|
||||
with self._lock:
|
||||
trash = [r[0] for r in self._conn.execute(
|
||||
with self._read_ctx() as conn:
|
||||
trash = [r[0] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE ? ESCAPE '\\'",
|
||||
(self._FTS_TRASH_PREFIX.replace("_", "\\_") + "%",),
|
||||
).fetchall()]
|
||||
@@ -409,16 +435,26 @@ class SessionSearchMixin:
|
||||
without an anti-join, so it needs a known-empty index. A missing docsize table counts
|
||||
as empty."""
|
||||
if _meta_row(conn, "fts_rebuild_progress") is None:
|
||||
try:
|
||||
known_empty = int(conn.execute("SELECT COUNT(*) FROM messages_fts_docsize").fetchone()[0]) == 0
|
||||
except sqlite3.OperationalError:
|
||||
known_empty = True
|
||||
if not known_empty:
|
||||
for tbl in ("messages_fts", "messages_fts_trigram"):
|
||||
with contextlib.suppress(sqlite3.OperationalError): # table absent — already an empty surface
|
||||
conn.execute(f"INSERT INTO {tbl}({tbl}) VALUES('delete-all')")
|
||||
if not self._fts_index_known_empty(conn):
|
||||
self._reset_fts_index_to_empty(conn)
|
||||
self.set_meta("fts_rebuild_progress", "0", cursor=conn)
|
||||
|
||||
@staticmethod
|
||||
def _fts_index_known_empty(conn) -> bool:
|
||||
"""True when the base external-content index holds no rows (a missing table counts as empty)."""
|
||||
try:
|
||||
return int(conn.execute("SELECT COUNT(*) FROM messages_fts_docsize").fetchone()[0]) == 0
|
||||
except sqlite3.OperationalError:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _reset_fts_index_to_empty(conn) -> None:
|
||||
"""Truncate the v23 external-content tables via FTS5 ``'delete-all'`` (O(1); a plain DELETE is
|
||||
O(rows) and corrupts the index when indexed rows diverged from ``messages``)."""
|
||||
for tbl in ("messages_fts", "messages_fts_trigram"):
|
||||
with contextlib.suppress(sqlite3.OperationalError): # table absent — already an empty surface
|
||||
conn.execute(f"INSERT INTO {tbl}({tbl}) VALUES('delete-all')")
|
||||
|
||||
def _seed_fts_rebuild_markers(self, conn, *, force: bool = False) -> int:
|
||||
"""Write ``fts_rebuild_high_water`` / ``fts_rebuild_progress`` for a full backfill; returns
|
||||
the high-water id. Without ``force`` an existing high_water only gets a missing
|
||||
@@ -426,10 +462,12 @@ class SessionSearchMixin:
|
||||
existing_hw = _meta_row(conn, "fts_rebuild_high_water")
|
||||
if existing_hw is not None and not force:
|
||||
self._reseed_missing_progress(conn)
|
||||
self.set_meta(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(int(existing_hw[0])), cursor=conn)
|
||||
return int(existing_hw[0])
|
||||
hw = conn.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0]
|
||||
self.set_meta("fts_rebuild_high_water", str(hw), cursor=conn)
|
||||
self.set_meta("fts_rebuild_progress", "0", cursor=conn)
|
||||
self.set_meta(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(hw), cursor=conn)
|
||||
return int(hw)
|
||||
|
||||
def _repair_optimize_bookkeeping(self) -> None:
|
||||
@@ -449,15 +487,15 @@ class SessionSearchMixin:
|
||||
self._execute_write(_do)
|
||||
|
||||
def fts_optimize_available(self) -> bool:
|
||||
"""True when `optimize_fts_storage()` has work: legacy inline FTS, an interrupted optimize
|
||||
"""True when `optimize_fts_storage()` has work: legacy inline FTS or a v23 trigram still
|
||||
carrying ``tool_calls`` (``_db_needs_fts_storage_upgrade``), an interrupted optimize
|
||||
(markers/trash), a CJK backfill on this tokenizer-capable host, or an empty external
|
||||
index without markers. False when FTS5 is unavailable."""
|
||||
if not self._fts_enabled or self.read_only:
|
||||
return False
|
||||
with self._lock:
|
||||
conn = self._conn
|
||||
with self._read_ctx() as conn:
|
||||
return (
|
||||
self._db_has_legacy_inline_fts(conn)
|
||||
self._db_needs_fts_storage_upgrade(conn)
|
||||
or _meta_row(conn, "fts_rebuild_high_water") is not None # interrupted optimize
|
||||
# CJK work is only offerable when THIS process can tokenize.
|
||||
or (self._fts_cjk_loaded and (
|
||||
@@ -469,7 +507,7 @@ class SessionSearchMixin:
|
||||
)
|
||||
|
||||
def _demote_legacy_fts_to_trash(self) -> int:
|
||||
"""Demote the legacy inline FTS vtables and stage their shadow tables for chunked
|
||||
"""Demote upgrade-eligible FTS vtables and stage their shadow tables for chunked
|
||||
teardown; returns MAX(messages.id) as the rebuild high water. O(1) schema surgery
|
||||
— the heavy delete is deferred. Markers land in the same BEGIN IMMEDIATE, BEFORE
|
||||
the empty v23 schema is created (``executescript`` implicitly COMMITs), closing
|
||||
@@ -555,8 +593,9 @@ class SessionSearchMixin:
|
||||
def optimize_fts_storage(
|
||||
self, *, progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None, vacuum: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""Migrate a legacy v22 inline-FTS DB to the v23 external-content schema, foreground and
|
||||
to completion; re-running resumes. ``progress_cb`` receives {"phase", "percent",
|
||||
"""Repair an older FTS layout into the current v23 shape, foreground and to completion:
|
||||
legacy-v22 inline -> external-content, or a v23 ``messages_fts_trigram`` that still stores
|
||||
``tool_calls``. Re-running resumes. ``progress_cb`` receives {"phase", "percent",
|
||||
"indexed", "total"}. A missing trigram tokenizer is not fatal (CJK falls back to LIKE)."""
|
||||
if not self._fts_enabled:
|
||||
return {"ok": False, "reason": "fts5_unavailable"}
|
||||
@@ -566,11 +605,11 @@ class SessionSearchMixin:
|
||||
# Heal bookkeeping BEFORE deciding whether to demote again.
|
||||
self._repair_optimize_bookkeeping()
|
||||
with self._lock:
|
||||
legacy = self._db_has_legacy_inline_fts(self._conn)
|
||||
needs_storage_upgrade = self._db_needs_fts_storage_upgrade(self._conn)
|
||||
pending = self.get_meta("fts_rebuild_high_water") is not None
|
||||
if legacy and not pending:
|
||||
if needs_storage_upgrade and not pending:
|
||||
self._demote_legacy_fts_to_trash()
|
||||
elif pending and not legacy:
|
||||
elif pending and not needs_storage_upgrade:
|
||||
# Resume mid-demote: the process may have died between the staged demote
|
||||
# commit and schema ensure.
|
||||
self._ensure_v23_fts_tables("failed to re-create v23 messages_fts on optimize-storage resume")
|
||||
@@ -607,10 +646,10 @@ class SessionSearchMixin:
|
||||
# Phase 2: tear down the demoted legacy shadow tables in chunks.
|
||||
_emit("teardown")
|
||||
_drive("teardown", self._fts_teardown_trash_step)
|
||||
with self._lock:
|
||||
still_pending = _meta_row(self._conn, "fts_rebuild_high_water") is not None
|
||||
still_trash = self._has_fts_trash(self._conn)
|
||||
empty_index = self._fts_external_index_empty_with_messages(self._conn)
|
||||
with self._read_ctx() as conn:
|
||||
still_pending = _meta_row(conn, "fts_rebuild_high_water") is not None
|
||||
still_trash = self._has_fts_trash(conn)
|
||||
empty_index = self._fts_external_index_empty_with_messages(conn)
|
||||
if still_pending or still_trash or empty_index:
|
||||
reason = "backfill_incomplete" if still_pending or empty_index else "teardown_incomplete"
|
||||
logger.warning("FTS storage optimization did not settle (%s): pending=%s trash=%s empty_index=%s",
|
||||
@@ -688,8 +727,8 @@ class SessionSearchMixin:
|
||||
with a DB pick that includes them."""
|
||||
active_clause = "" if include_inactive else " AND active = 1"
|
||||
display_clause = " AND (display_kind IS NULL OR display_kind = '')"
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
with self._read_ctx() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, timestamp, content FROM messages WHERE session_id = ? AND role = 'user'"
|
||||
f"{active_clause}{display_clause} "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
@@ -977,6 +1016,11 @@ class SessionSearchMixin:
|
||||
return []
|
||||
filters = dict(include_inactive=include_inactive, source_filter=source_filter,
|
||||
exclude_sources=exclude_sources, role_filter=role_filter)
|
||||
# New oversized tool results index only a bounded prefix; an explicit tool-role search is the
|
||||
# opt-in full-body path and scans canonical rows via LIKE.
|
||||
if role_filter and "tool" in role_filter:
|
||||
matches = self._search_messages_like_fallback(query, limit=limit, offset=offset, sort=sort, **filters)
|
||||
return self._finalize_search_matches(matches, result_fields=result_fields)
|
||||
self._refresh_fts_stale_state()
|
||||
if self._fts_stale:
|
||||
matches = self._search_messages_like_fallback(query, limit=limit, offset=offset, sort=sort, **filters)
|
||||
@@ -986,11 +1030,13 @@ class SessionSearchMixin:
|
||||
|
||||
order_by_sql = _FTS_ORDER_BY.get(sort.strip().lower() if isinstance(sort, str) else None, "ORDER BY rank")
|
||||
route = dict(order_by_sql=order_by_sql, limit=limit, offset=offset, **filters)
|
||||
# Tool rows are excluded from the trigram/cjk indexes (see FTS_TRIGRAM_SQL).
|
||||
wants_tool_rows = bool(role_filter) and "tool" in role_filter
|
||||
# Tool rows and FTS_TRIGRAM_EXCLUDED_SOURCES sessions are excluded from the trigram/cjk
|
||||
# indexes (see FTS_TRIGRAM_SQL); an explicit filter for them must scan the base table.
|
||||
wants_unindexed_rows = (bool(role_filter) and "tool" in role_filter) or (
|
||||
bool(source_filter) and any(src in FTS_TRIGRAM_EXCLUDED_SOURCES for src in source_filter))
|
||||
is_cjk = self._contains_cjk(query)
|
||||
if is_cjk:
|
||||
matches = self._search_cjk(query, wants_tool_rows, route)
|
||||
matches = self._search_cjk(query, wants_unindexed_rows, route)
|
||||
else:
|
||||
sql, params = self._fts_match_sql("messages_fts", query, **route)
|
||||
try:
|
||||
@@ -1020,7 +1066,7 @@ class SessionSearchMixin:
|
||||
# substring-capable indexes: cjk first (exact ranked match), then trigram (>=3-char
|
||||
# tokens). Gated on a miss so hits keep their ranking ("cat" may then match
|
||||
# "concatenate"). Skipped for role='tool' (both indexes exclude tool rows).
|
||||
if not matches and not is_cjk and not wants_tool_rows:
|
||||
if not matches and not is_cjk and not (bool(role_filter) and "tool" in role_filter):
|
||||
fb_query = _quote_fts_tokens(query.strip('"').strip())
|
||||
if self._fts_cjk_available:
|
||||
matches = self._match_rows("messages_fts_cjk", fb_query, **route) or matches
|
||||
@@ -1028,21 +1074,22 @@ class SessionSearchMixin:
|
||||
matches = self._match_rows("messages_fts_trigram", fb_query, **route) or matches
|
||||
return self._finalize_search_matches(matches, result_fields=result_fields)
|
||||
|
||||
def _search_cjk(self, query: str, wants_tool_rows: bool, route: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
def _search_cjk(self, query: str, wants_unindexed_rows: bool, route: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""CJK routing: the unicode61 table splits CJK into single characters (false positives,
|
||||
missed phrases). cjk-bigram serves every shape except role='tool' queries and LONE
|
||||
missed phrases). cjk-bigram serves every shape except queries wanting rows the
|
||||
substring indexes exclude (role='tool', cron/subagent sources) and LONE
|
||||
1-char CJK runs (bigrams only exist for runs >=2 — LIKE is broader); then trigram
|
||||
(>=3 CJK chars per token); then a LIKE substring scan with one clause per
|
||||
non-operator token so "广西 OR 桂林 OR 漓江" matches each term."""
|
||||
raw_query = query.strip('"').strip()
|
||||
match_query = _quote_fts_tokens(raw_query)
|
||||
if self._fts_cjk_available and not wants_tool_rows and not self._has_lone_cjk_run(raw_query):
|
||||
if self._fts_cjk_available and not wants_unindexed_rows and not self._has_lone_cjk_run(raw_query):
|
||||
matches = self._match_rows(
|
||||
"messages_fts_cjk", match_query, fail_open="CJK-bigram",
|
||||
operational_debug="messages_fts_cjk query failed; falling back to trigram/LIKE", **route)
|
||||
if matches is not None:
|
||||
return matches
|
||||
if self._trigram_route_ok(raw_query) and not wants_tool_rows:
|
||||
if self._trigram_route_ok(raw_query) and not wants_unindexed_rows:
|
||||
matches = self._match_rows("messages_fts_trigram", match_query, fail_open="Trigram", **route)
|
||||
if matches is not None:
|
||||
return matches
|
||||
@@ -1136,6 +1183,11 @@ class SessionSearchMixin:
|
||||
"Deferred in-place FTS rebuild: another process holds the rebuild authority for this state.db.")
|
||||
return 0
|
||||
with self._lock:
|
||||
high_water = self._conn.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0]
|
||||
self._conn.execute(
|
||||
"INSERT INTO state_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(high_water)),
|
||||
)
|
||||
for tbl in self._present_fts_tables():
|
||||
try:
|
||||
self._conn.execute(f"INSERT INTO {tbl}({tbl}) VALUES('rebuild')")
|
||||
|
||||
Reference in New Issue
Block a user