Compare commits

...

7 Commits

Author SHA1 Message Date
Anna Schumaker bea49c5eae xfstestsdb 1.2
Signed-off-by: Anna Schumaker <anna@nowheycreamery.com>
2023-07-26 11:27:32 -04:00
Anna Schumaker 14654dcf23 gc: Create the `xfstestsdb gc` command
This command is used to garbage collect the xfstestsdb sqlite database.
It removes xfstests runs that have no added xunits, and runs older than
180 days that have not been tagged.

Implements: #16 (`xfstestsdb gc`)
Signed-off-by: Anna Schumaker <anna@nowheycreamery.com>
2023-07-26 11:26:09 -04:00
Anna Schumaker 68a00ea94d xunit: Create the `xfstestsdb xunit gc` command
This command is used to garbage collect the xunit table by removing
xunits that have no testcases. This could be expanded on later to add
more removal conditions, such as xunits older than some age.

Implements: #15 (`xfstestsdb xunit gc`)
Signed-off-by: Anna Schumaker <anna@nowheycreamery.com>
2023-07-26 11:26:08 -04:00
Anna Schumaker c3eb740fb5 xunit delete: Xunit property performance improvements
I found that deleting an xunit from the database was very, very slow.
This patch addresses the issue by adding additional indexes on the
link_xunit_properties table to make it easier to find properties that
are still in use. I also rework the `cleanup_xunit_properties` trigger
to only check propids that were used by the deleted xunit.

Signed-off-by: Anna Schumaker <anna@nowheycreamery.com>
2023-07-25 12:42:30 -04:00
Anna Schumaker a168a7f84b xunit delete: Testcase message performance improvements
I found that deleting an xunit from the database was very, very slow.
This patch addresses the issue by adding additional indexes on the
testcases table to make it easier to find messages that are still in
use. I also rework the `cleanup_testcase_messages` trigger to only check
messageids that were used by the deleted testcase.

Signed-off-by: Anna Schumaker <anna@nowheycreamery.com>
2023-07-25 10:41:58 -04:00
Anna Schumaker 2082b904a0 sqlite: Add an upgrade script to database version 2
Right now it doesn't do any extra changes to the database besides
bumping the user_version to '2'. I'll fill it out over the next several
commits.

Signed-off-by: Anna Schumaker <anna@nowheycreamery.com>
2023-07-25 10:03:32 -04:00
Anna Schumaker 3f61adc941 sqlite: Give the Connection an executescript() function
This function builds on the built-in Python function, and adds in
opening and reading the file in a way that can be used for running
generic scripts on the database.

I also take this chance to move SQL scripts into a subdirectory to keep
them together.

Signed-off-by: Anna Schumaker <anna@nowheycreamery.com>
2023-07-25 09:54:31 -04:00
12 changed files with 361 additions and 10 deletions

7
tests/test-script.sql Normal file
View File

@ -0,0 +1,7 @@
/* Copyright 2023 (c) Anna Schumaker */
CREATE TABLE test (a INT, b INT);
INSERT INTO test VALUES (1, 2);
INSERT INTO test VALUES (3, 4);
INSERT INTO test VALUES (5, 6);
INSERT INTO test VALUES (7, 8);
INSERT INTO test VALUES (9, 0);

123
tests/test_gc.py Normal file
View File

@ -0,0 +1,123 @@
# Copyright 2023 (c) Anna Schumaker.
"""Tests the `xfstestsdb gc` command."""
import datetime
import io
import unittest
import unittest.mock
import xfstestsdb.xunit.gc
import tests.xunit
class TestGC(unittest.TestCase):
"""Tests the `xfstestsdb xunit gc` command."""
def setUp(self):
"""Set up common variables."""
self.xfstestsdb = xfstestsdb.Command()
self.xunit = self.xfstestsdb.commands["xunit"]
self.gc = self.xunit.commands["gc"]
def setup_runs(self, mock_stdout: io.StringIO):
"""Set up runs in the database and clear stdout."""
self.xfstestsdb.run(["new", "/dev/vda1"])
self.xfstestsdb.run(["new", "/dev/vda1"])
self.xfstestsdb.run(["new", "/dev/vda1"])
self.xfstestsdb.run(["xunit", "read", "1", str(tests.xunit.XUNIT_1)])
self.xfstestsdb.run(["xunit", "read", "3", str(tests.xunit.XUNIT_1)])
self.xfstestsdb.sql("DELETE FROM testcases WHERE xunitid=2")
mock_stdout.seek(0)
mock_stdout.truncate(0)
def test_init(self):
"""Check that the gc command was set up properly."""
self.assertIsInstance(self.gc, xfstestsdb.commands.Command)
self.assertIsInstance(self.gc, xfstestsdb.xunit.gc.Command)
self.assertEqual(self.xunit.subparser.choices["gc"],
self.gc.parser)
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_gc_empty(self, mock_stdout: io.StringIO):
"""Test garbage collecting an empty database."""
cur = self.xfstestsdb.sql("SELECT runid FROM xfstests_gc_runs")
self.assertListEqual([row['runid'] for row in cur.fetchall()], [])
self.xfstestsdb.run(["gc"])
self.assertEqual(mock_stdout.getvalue(), "")
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_gc_no_testcases(self, mock_stdout: io.StringIO):
"""Test garbage collecting runs with no testcases."""
self.setup_runs(mock_stdout)
cur = self.xfstestsdb.sql("SELECT runid FROM xfstests_gc_runs")
self.assertListEqual([row['runid'] for row in cur.fetchall()], [2, 3])
self.xfstestsdb.run(["gc"])
self.assertRegex(mock_stdout.getvalue(),
"run #2 has been deleted\nrun #3 has been deleted")
cur = self.xfstestsdb.sql("SELECT runid FROM xfstests_runs")
self.assertListEqual([row['runid'] for row in cur.fetchall()], [1])
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_gc_expired(self, mock_stdout: io.StringIO):
"""Test garbage collecting old runs."""
self.setup_runs(mock_stdout)
self.xfstestsdb.run(["xunit", "read", "2", str(tests.xunit.XUNIT_1)])
utcnow = datetime.datetime.utcnow()
expired = utcnow - datetime.timedelta(days=180, seconds=1)
self.xfstestsdb.sql("""UPDATE xfstests_runs SET timestamp=?
WHERE runid=1""", expired)
not_expired = utcnow - datetime.timedelta(days=180, seconds=-1)
self.xfstestsdb.sql("""UPDATE xfstests_runs SET timestamp=?
WHERE runid=2""", not_expired)
cur = self.xfstestsdb.sql("SELECT runid FROM xfstests_gc_runs")
self.assertListEqual([row['runid'] for row in cur.fetchall()], [1, 3])
self.xfstestsdb.run(["gc"])
self.assertRegex(mock_stdout.getvalue(),
"run #1 has been deleted\nrun #3 has been deleted")
cur = self.xfstestsdb.sql("SELECT runid FROM xfstests_runs")
self.assertListEqual([row['runid'] for row in cur.fetchall()], [2])
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_gc_expired_tagged(self, mock_stdout: io.StringIO):
"""Test that we don't garbage collect expired runs that are tagged."""
self.setup_runs(mock_stdout)
self.xfstestsdb.run(["xunit", "read", "2", str(tests.xunit.XUNIT_1)])
self.xfstestsdb.run(["tag", "1", "my-tag"])
self.xfstestsdb.run(["tag", "3", "my-tag"])
utcnow = datetime.datetime.utcnow()
expired = utcnow - datetime.timedelta(days=181)
self.xfstestsdb.sql("UPDATE xfstests_runs SET timestamp=?", expired)
cur = self.xfstestsdb.sql("SELECT runid FROM xfstests_gc_runs")
self.assertListEqual([row['runid'] for row in cur.fetchall()], [2, 3])
self.xfstestsdb.run(["gc"])
self.assertRegex(mock_stdout.getvalue(),
"run #2 has been deleted\nrun #3 has been deleted")
cur = self.xfstestsdb.sql("SELECT runid FROM xfstests_runs")
self.assertListEqual([row['runid'] for row in cur.fetchall()], [1])
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_gc_dry_run(self, mock_stdout: io.StringIO):
"""Test garbage collecting with the --dry-run option."""
self.setup_runs(mock_stdout)
self.xfstestsdb.run(["gc", "--dry-run"])
self.assertRegex(mock_stdout.getvalue(),
"run #2 would be deleted\nrun #3 would be deleted")
cur = self.xfstestsdb.sql("SELECT runid FROM xfstests_runs")
self.assertListEqual([row['runid'] for row in cur.fetchall()],
[1, 2, 3])

View File

@ -22,8 +22,12 @@ class TestConnection(unittest.TestCase):
data_dir / "xfstestsdb-debug.sqlite3")
self.assertEqual(xfstestsdb.sqlite.DATABASE, ":memory:")
script = pathlib.Path(xfstestsdb.__file__).parent / "xfstestsdb.sql"
self.assertEqual(xfstestsdb.sqlite.SQL_SCRIPT, script)
self.assertEqual(xfstestsdb.sqlite.SQL_SCRIPTS,
pathlib.Path(xfstestsdb.__file__).parent / "scripts")
self.assertEqual(xfstestsdb.sqlite.SQL_V1_SCRIPT,
xfstestsdb.sqlite.SQL_SCRIPTS / "xfstestsdb.sql")
self.assertEqual(xfstestsdb.sqlite.SQL_V2_SCRIPT,
xfstestsdb.sqlite.SQL_SCRIPTS / "upgrade-v2.sql")
def test_foreign_keys(self):
"""Test that foreign key constraints are enabled."""
@ -33,7 +37,7 @@ class TestConnection(unittest.TestCase):
def test_version(self):
"""Test checking the database schema version."""
cur = self.sql("PRAGMA user_version")
self.assertEqual(cur.fetchone()["user_version"], 1)
self.assertEqual(cur.fetchone()["user_version"], 2)
def test_connection(self):
"""Check that the connection manager is initialized properly."""
@ -72,6 +76,17 @@ class TestConnection(unittest.TestCase):
self.assertListEqual([(row["a"], row["b"]) for row in rows],
[(1, 2), (3, 4), (5, 6), (7, 8), (9, 0)])
def test_executescript(self):
"""Test running a sql script."""
script = pathlib.Path(__file__).parent / "test-script.sql"
cur = self.sql.executescript(script)
self.assertIsInstance(cur, sqlite3.Cursor)
rows = self.sql("SELECT * FROM test").fetchall()
self.assertListEqual([(row["a"], row["b"]) for row in rows],
[(1, 2), (3, 4), (5, 6), (7, 8), (9, 0)])
self.assertIsNone(self.sql.executescript(script.parent / "no-script"))
def test_transaction(self):
"""Test that we can manually start a transaction."""
self.assertFalse(self.sql.sql.in_transaction)

View File

@ -52,7 +52,7 @@ class TestXfstestsdb(unittest.TestCase):
def test_version(self, mock_stdout: io.StringIO):
"""Test printing version information."""
self.assertEqual(xfstestsdb.MAJOR, 1)
self.assertEqual(xfstestsdb.MINOR, 1)
self.assertEqual(xfstestsdb.MINOR, 2)
self.xfstestsdb.run(["--version"])
self.assertEqual(mock_stdout.getvalue(), "xfstestsdb v1.1-debug\n")
self.assertEqual(mock_stdout.getvalue(), "xfstestsdb v1.2-debug\n")

70
tests/xunit/test_gc.py Normal file
View File

@ -0,0 +1,70 @@
# Copyright 2023 (c) Anna Schumaker.
"""Tests the `xfstestsdb xunit gc` command."""
import io
import unittest
import unittest.mock
import xfstestsdb.xunit.gc
import tests.xunit
class TestXunitGC(unittest.TestCase):
"""Tests the `xfstestsdb xunit gc` command."""
def setUp(self):
"""Set up common variables."""
self.xfstestsdb = xfstestsdb.Command()
self.xunit = self.xfstestsdb.commands["xunit"]
self.gc = self.xunit.commands["gc"]
def setup_runs(self, mock_stdout: io.StringIO):
"""Set up runs in the database and clear stdout."""
self.xfstestsdb.run(["new", "/dev/vda1"])
self.xfstestsdb.run(["new", "/dev/vda1"])
self.xfstestsdb.run(["xunit", "read", "1", str(tests.xunit.XUNIT_1)])
self.xfstestsdb.run(["xunit", "read", "1", str(tests.xunit.XUNIT_1),
"--name", "test-2"])
self.xfstestsdb.run(["xunit", "read", "2", str(tests.xunit.XUNIT_1)])
self.xfstestsdb.sql("DELETE FROM testcases WHERE xunitid=?", 2)
self.xfstestsdb.sql("DELETE FROM testcases WHERE xunitid=?", 3)
mock_stdout.seek(0)
mock_stdout.truncate(0)
def test_init(self):
"""Check that the xunit gc command was set up properly."""
self.assertIsInstance(self.gc, xfstestsdb.commands.Command)
self.assertIsInstance(self.gc, xfstestsdb.xunit.gc.Command)
self.assertEqual(self.xunit.subparser.choices["gc"],
self.gc.parser)
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_gc_empty(self, mock_stdout: io.StringIO):
"""Test garbage collecting an empty database."""
self.xfstestsdb.run(["xunit", "gc"])
self.assertEqual(mock_stdout.getvalue(), "")
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_gc_xunits(self, mock_stdout: io.StringIO):
"""Test garbage collecting xunits with default options."""
self.setup_runs(mock_stdout)
self.xfstestsdb.run(["xunit", "gc"])
self.assertRegex(mock_stdout.getvalue(),
"run #1 xunit 'test-2' has been deleted\n"
"run #2 xunit 'test-1' has been deleted")
cur = self.xfstestsdb.sql("SELECT COUNT(*) FROM xunits")
self.assertEqual(cur.fetchone()["COUNT(*)"], 1)
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
def test_gc_xunits_dry_run(self, mock_stdout: io.StringIO):
"""Test garbage collecting xunits with the --dry-run option."""
self.setup_runs(mock_stdout)
self.xfstestsdb.run(["xunit", "gc", "--dry-run"])
self.assertRegex(mock_stdout.getvalue(),
"run #1 xunit 'test-2' would be deleted\n"
"run #2 xunit 'test-1' would be deleted")
cur = self.xfstestsdb.sql("SELECT COUNT(*) FROM xunits")
self.assertEqual(cur.fetchone()["COUNT(*)"], 3)

View File

@ -3,6 +3,7 @@
import argparse
from . import sqlite
from . import delete
from . import gc
from . import list
from . import new
from . import rename
@ -13,7 +14,7 @@ from . import untag
from . import xunit
MAJOR = 1
MINOR = 1
MINOR = 2
class Command:
@ -28,6 +29,7 @@ class Command:
self.subparser = self.parser.add_subparsers(title="commands")
self.sql = sqlite.Connection()
self.commands = {"delete": delete.Command(self.subparser, self.sql),
"gc": gc.Command(self.subparser, self.sql),
"list": list.Command(self.subparser, self.sql),
"new": new.Command(self.subparser, self.sql),
"rename": rename.Command(self.subparser, self.sql),

27
xfstestsdb/gc.py Normal file
View File

@ -0,0 +1,27 @@
# Copyright 2023 (c) Anna Schumaker.
"""The `xfstestsdb gc` command."""
import argparse
from . import commands
from . import sqlite
class Command(commands.Command):
"""The `xfstestsdb gc` command."""
def __init__(self, subparser: argparse.Action,
sql: sqlite.Connection) -> None:
"""Set up the gc command."""
super().__init__(subparser, sql, "gc",
help="garbage collect xfstestsdb entries")
self.parser.add_argument("--dry-run", action="store_true",
help="do not remove the xunit entries")
def do_command(self, args: argparse.Namespace) -> None:
"""Clean up the xunit table."""
how = "would be" if args.dry_run else "has been"
rows = self.sql("SELECT runid FROM xfstests_gc_runs").fetchall()
for runid in sorted([row['runid'] for row in rows]):
if not args.dry_run:
self.sql("DELETE FROM xfstests_runs WHERE runid=?", runid)
print(f"run #{runid} {how} deleted")

View File

@ -0,0 +1,64 @@
/* Copyright 2023 (c) Anna Schumaker */
PRAGMA user_version = 2;
/*
* The original `cleanup_xunit_properties` trigger was very slow on
* large databases. We can do a few things to improve on it:
* 1. Add an index on the link_xunits_props table to make it easier
* to check if specific properties are still in use.
* 2. Rewrite the `cleanup_xunit_properties` trigger to only operate
* on the propid associated with the deleted xunit.
*/
CREATE INDEX link_xunit_props_propid_index ON link_xunit_props (propid);
DROP TRIGGER cleanup_xunit_properties;
CREATE TRIGGER cleanup_xunit_properties
AFTER DELETE ON link_xunit_props
BEGIN
DELETE FROM xunit_properties
WHERE (propid = OLD.propid)
AND NOT EXISTS (SELECT 1 FROM link_xunit_props
WHERE propid = xunit_properties.propid);
END;
/*
* The original `cleanup_testcase_messages` trigger was very slow. We can
* do a few things to improve upon it:
* 1. Add indexes on the testcases table to make it easier to check
* if specific messageids are still in use.
* 2. Rewrite the `cleanup_testcase_messages` trigger to only operate
* on the messageids associated with the deleted testcase.
*/
CREATE INDEX testcases_messageid_index ON testcases (messageid);
CREATE INDEX testcases_stdoutid_index ON testcases (stdoutid);
CREATE INDEX testcases_stderrid_index ON testcases (stderrid);
DROP TRIGGER cleanup_testcase_messages;
CREATE TRIGGER cleanup_testcase_messages
AFTER DELETE ON testcases
BEGIN
DELETE FROM messages
WHERE (messageid = OLD.messageid
OR messageid = OLD.stdoutid
OR messageid = OLD.stderrid)
AND NOT EXISTS
(SELECT 1 FROM testcases WHERE
messageid = messages.messageid
OR stdoutid = messages.messageid
OR stderrid = messages.messageid);
END;
/*
* Create a view on the xfstestsdb_runs to find garbage collection candidates.
*/
CREATE VIEW xfstests_gc_runs AS
SELECT runid
FROM xfstests_runs
WHERE NOT EXISTS (SELECT 1 FROM testcases
JOIN xunits USING (xunitid)
WHERE runid = xfstests_runs.runid)
OR (timestamp < datetime('now', '-180 days')
AND NOT EXISTS (SELECT 1 FROM tags
WHERE runid = xfstests_runs.runid));

View File

@ -9,7 +9,10 @@ import xdg.BaseDirectory
DATA_DIR = pathlib.Path(xdg.BaseDirectory.save_data_path("xfstestsdb"))
DATA_FILE = DATA_DIR / f"xfstestsdb{'-debug' if __debug__ else ''}.sqlite3"
DATABASE = ":memory:" if "unittest" in sys.modules else DATA_FILE
SQL_SCRIPT = pathlib.Path(__file__).parent / "xfstestsdb.sql"
SQL_SCRIPTS = pathlib.Path(__file__).parent / "scripts"
SQL_V1_SCRIPT = SQL_SCRIPTS / "xfstestsdb.sql"
SQL_V2_SCRIPT = SQL_SCRIPTS / "upgrade-v2.sql"
class Connection:
@ -25,9 +28,10 @@ class Connection:
self("PRAGMA foreign_keys = ON")
match self("PRAGMA user_version").fetchone()["user_version"]:
case 0:
with open(SQL_SCRIPT) as f:
self.sql.executescript(f.read())
self.sql.commit()
self.executescript(SQL_V1_SCRIPT)
self.executescript(SQL_V2_SCRIPT)
case 1:
self.executescript(SQL_V2_SCRIPT)
def __call__(self, statement: str,
*args, **kwargs) -> sqlite3.Cursor | None:
@ -64,3 +68,11 @@ class Connection:
return self.sql.executemany(statement, args)
except sqlite3.IntegrityError:
return None
def executescript(self, script: pathlib.Path) -> sqlite3.Cursor | None:
"""Execute a SQL script."""
if script.is_file():
with open(script) as f:
cur = self.sql.executescript(f.read())
self.sql.commit()
return cur

View File

@ -4,6 +4,7 @@ import argparse
from .. import commands
from .. import sqlite
from . import delete
from . import gc
from . import list
from . import properties
from . import read
@ -20,6 +21,7 @@ class Command(commands.Command):
help="xfstestsdb xunit commands")
self.subparser = self.parser.add_subparsers(title="xunit commands")
self.commands = {"delete": delete.Command(self.subparser, sql),
"gc": gc.Command(self.subparser, sql),
"list": list.Command(self.subparser, sql),
"properties": properties.Command(self.subparser, sql),
"read": read.Command(self.subparser, sql),

29
xfstestsdb/xunit/gc.py Normal file
View File

@ -0,0 +1,29 @@
# Copyright 2023 (c) Anna Schumaker.
"""The `xfstestsdb xunit gc` command."""
import argparse
from .. import commands
from .. import sqlite
class Command(commands.Command):
"""The `xfstestsdb xunit gc` command."""
def __init__(self, subparser: argparse.Action,
sql: sqlite.Connection) -> None:
"""Set up the xunit gc command."""
super().__init__(subparser, sql, "gc",
help="garbage collect xunit entries")
self.parser.add_argument("--dry-run", action="store_true",
help="do not remove the xunit entries")
def do_command(self, args: argparse.Namespace) -> None:
"""Clean up the xunit table."""
how = "would be" if args.dry_run else "has been"
cur = self.sql("""SELECT xunitid, runid, name FROM xunits
WHERE NOT EXISTS (SELECT 1 FROM testcases
WHERE xunitid = xunits.xunitid)""")
for row in cur.fetchall():
if not args.dry_run:
self.sql("DELETE FROM xunits WHERE xunitid=?", row['xunitid'])
print(f"run #{row['runid']} xunit '{row['name']}' {how} deleted")