diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 65d653ddf94ceaa3435fbaf9a2bb690029f4de4b..8a3b8ec91de583a8cf9195034ef7c75f1a71b5f3 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -41,6 +41,10 @@ from twisted.python.filepath import ( FilePath, ) +from .storage_common import ( + BYTES_PER_PASS, + required_passes, +) class StoreOpenError(Exception): """ @@ -157,6 +161,26 @@ def open_and_initialize(path, required_schema_version, connect=None): ) """, ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS [lease-maintenance-spending] ( + [id] integer, -- A unique identifier for a group of activity. + [started] text, -- ISO8601 date+time string when the activity began. + [finished] text, -- ISO8601 date+time string when the activity completed (or null). + + -- The number of passes that would be required to renew all + -- shares encountered during this activity. Note that because + -- leases on different shares don't necessarily expire at the + -- same time this is not necessarily the number of passes + -- **actually** used during this activity. Some shares may + -- not have required lease renewal. Also note that while the + -- activity is ongoing this value may change. + [count] integer, + + PRIMARY KEY([id]) + ) + """, + ) return conn @@ -427,6 +451,140 @@ class VoucherStore(object): u"unblinded-tokens": list(token for (token,) in tokens), } + def start_lease_maintenance(self): + """ + Get an object which can track a newly started round of lease maintenance + activity. + + :return LeaseMaintenance: A new, started lease maintenance object. + """ + m = LeaseMaintenance(self.now, self._connection) + m.start() + return m + + @with_cursor + def get_latest_lease_maintenance_activity(self, cursor): + """ + Get a description of the most recently completed lease maintenance + activity. + + :return LeaseMaintenanceActivity|None: If any lease maintenance has + completed, an object describing its results. Otherwise, None. + """ + cursor.execute( + """ + SELECT [started], [count], [finished] + FROM [lease-maintenance-spending] + WHERE [finished] IS NOT NULL + ORDER BY [finished] DESC + LIMIT 1 + """, + ) + activity = cursor.fetchall() + if len(activity) == 0: + return None + [(started, count, finished)] = activity + return LeaseMaintenanceActivity( + parse_datetime(started, delimiter=u" "), + count, + parse_datetime(finished, delimiter=u" "), + ) + + +@attr.s +class LeaseMaintenance(object): + """ + A state-updating helper for recording pass usage during a lease + maintenance run. + + Get one of these from ``VoucherStore.start_lease_maintenance``. Then use + the ``observe`` and ``finish`` methods to persist state about a lease + maintenance run. + + :ivar _now: A no-argument callable which returns a datetime giving a time + to use as current. + + :ivar _connection: A SQLite3 connection object to use to persist observed + information. + + :ivar _rowid: None for unstarted lease maintenance objects. For started + objects, the database row id that corresponds to the started run. + This is used to make sure future updates go to the right row. + """ + _now = attr.ib() + _connection = attr.ib() + _rowid = attr.ib(default=None) + + @with_cursor + def start(self, cursor): + """ + Record the start of a lease maintenance run. + """ + if self._rowid is not None: + raise Exception("Cannot re-start a particular _LeaseMaintenance.") + + cursor.execute( + """ + INSERT INTO [lease-maintenance-spending] ([started], [finished], [count]) + VALUES (?, ?, ?) + """, + (self._now(), None, 0), + ) + self._rowid = cursor.lastrowid + + @with_cursor + def observe(self, cursor, size): + """ + Record a storage object of a given size. + """ + # XXX The client has no idea how many shares a server is storing and + # can do nothing except renew the lease on all of them. :( Here, guess + # that there is only one share. "Servers of happiness" should make + # this more likely to be the case... We might be underestimating + # storage costs though. + count = required_passes(BYTES_PER_PASS, {size}) + cursor.execute( + """ + UPDATE [lease-maintenance-spending] + SET [count] = [count] + ? + WHERE [id] = ? + """, + (count, self._rowid), + ) + + @with_cursor + def finish(self, cursor): + """ + Record the completion of this lease maintenance run. + """ + cursor.execute( + """ + UPDATE [lease-maintenance-spending] + SET [finished] = ? + WHERE [id] = ? + """, + (self._now(), self._rowid), + ) + self._rowid = None + + +@attr.s +class LeaseMaintenanceActivity(object): + started = attr.ib() + passes_required = attr.ib() + finished = attr.ib() + + +# store = ... +# x = store.start_lease_maintenance() +# x.observe(size=123) +# x.observe(size=456) +# ... +# x.finish() +# +# x = store.get_latest_lease_maintenance_activity() +# xs.started, xs.passes_required, xs.finished + @attr.s(frozen=True) class UnblindedToken(object): diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index d31e669a9f2fd108fa67e25aa655ef747707e246..018eb07f20fc7e526b7d8b30305566c93ec0e29c 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -27,6 +27,9 @@ from os import ( from errno import ( EACCES, ) +from datetime import ( + timedelta, +) from testtools import ( TestCase, @@ -52,7 +55,9 @@ from hypothesis import ( from hypothesis.strategies import ( data, lists, + tuples, datetimes, + timedeltas, integers, ) @@ -60,6 +65,10 @@ from twisted.python.filepath import ( FilePath, ) +from ..storage_common import ( + BYTES_PER_PASS, +) + from ..model import ( SchemaError, StoreOpenError, @@ -68,6 +77,7 @@ from ..model import ( Pending, DoubleSpend, Redeemed, + LeaseMaintenanceActivity, open_and_initialize, memory_connect, ) @@ -78,6 +88,7 @@ from .strategies import ( voucher_objects, random_tokens, unblinded_tokens, + posix_safe_datetimes, ) from .fixtures import ( TemporaryVoucherStore, @@ -217,7 +228,6 @@ class VoucherStoreTests(TestCase): If the underlying database file cannot be opened then ``VoucherStore.from_node_config`` raises ``StoreOpenError``. """ - tempdir = self.useFixture(TempDir()) nodedir = tempdir.join(b"node") @@ -238,6 +248,73 @@ class VoucherStoreTests(TestCase): ) +class LeaseMaintenanceTests(TestCase): + """ + Tests for the lease-maintenance related parts of ``VoucherStore``. + """ + @given( + tahoe_configs(), + posix_safe_datetimes(), + lists( + tuples( + # How much time passes before this activity starts + timedeltas(min_value=timedelta(0), max_value=timedelta(days=1)), + # Some activity. This list of two tuples gives us a trivial + # way to compute the total passes required (just sum the pass + # counts in it). This is nice because it avoids having the + # test re-implement size quantization which would just be + # repeated code duplicating the implementation. The second + # value lets us fuzz the actual size values a little bit in a + # way which shouldn't affect the passes required. + lists( + tuples( + # The activity itself, in pass count + integers(min_value=1, max_value=2 ** 16 - 1), + # Amount by which to trim back the share sizes + integers(min_value=0, max_value=BYTES_PER_PASS - 1), + ), + ), + # How much time passes before this activity finishes + timedeltas(min_value=timedelta(0), max_value=timedelta(days=1)), + ), + ), + ) + def test_lease_maintenance_activity(self, get_config, now, activity): + """ + ``VoucherStore.get_latest_lease_maintenance_activity`` returns a + ``LeaseMaintenanceTests`` with fields reflecting the most recently + finished lease maintenance activity. + """ + store = self.useFixture( + TemporaryVoucherStore(get_config, lambda: now), + ).store + + expected = None + for (start_delay, sizes, finish_delay) in activity: + now += start_delay + started = now + x = store.start_lease_maintenance() + passes_required = 0 + for (num_passes, trim_size) in sizes: + passes_required += num_passes + x.observe(num_passes * BYTES_PER_PASS - trim_size) + now += finish_delay + x.finish() + finished = now + + # Let the last iteration of the loop define the expected value. + expected = LeaseMaintenanceActivity( + started, + passes_required, + finished, + ) + + self.assertThat( + store.get_latest_lease_maintenance_activity(), + Equals(expected), + ) + + class VoucherTests(TestCase): """ Tests for ``Voucher``.