From 55b15473e1e625908a57032db8e446777b4aebf0 Mon Sep 17 00:00:00 2001
From: Jean-Paul Calderone <exarkun@twistedmatrix.com>
Date: Mon, 18 Nov 2019 11:04:40 -0500
Subject: [PATCH] Redeeming and unpaid states

---
 src/_zkapauthorizer/controller.py             | 78 ++++++++++++++++---
 src/_zkapauthorizer/model.py                  | 67 ++++++++++------
 .../tests/test_client_resource.py             | 10 ++-
 3 files changed, 117 insertions(+), 38 deletions(-)

diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index ba49e35..fb3bd82 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -17,6 +17,13 @@ This module implements controllers (in the MVC sense) for the web interface
 for the client side of the storage plugin.
 """
 
+from sys import (
+    exc_info,
+)
+from operator import (
+    setitem,
+    delitem,
+)
 from functools import (
     partial,
 )
@@ -60,8 +67,9 @@ from .model import (
     UnblindedToken,
     Voucher,
     Pass,
-    Pending,
+    Pending as model_Pending,
     Unpaid as model_Unpaid,
+    Redeeming as model_Redeeming,
 )
 
 
@@ -434,11 +442,13 @@ class PaymentController(object):
     The ``PaymentController`` coordinates the process of turning a voucher
     into a collection of ZKAPs:
 
-      1. A voucher to be consumed is handed to the controller.
-         Once a voucher is handed over to the controller the controller takes all responsibility for it.
+      1. A voucher to be consumed is handed to the controller.  Once a voucher
+         is handed over to the controller the controller takes all
+         responsibility for it.
 
-      2. The controller tells the data store to remember the voucher.
-         The data store provides durability for the voucher which represents an investment (ie, a purchase) on the part of the client.
+      2. The controller tells the data store to remember the voucher.  The
+         data store provides durability for the voucher which represents an
+         investment (ie, a purchase) on the part of the client.
 
       3. The controller hands the voucher and some random tokens to a redeemer.
          In the future, this step will need to be retried in the case of failures.
@@ -448,6 +458,10 @@ class PaymentController(object):
          the voucher.  The data store marks the voucher as redeemed and stores
          the unblinded tokens for use by the storage client.
 
+    :ivar dict[voucher, datetime] _active: A mapping from voucher identifiers
+        which currently have redemption attempts in progress to timestamps
+        when the attempt began.
+
     :ivar dict[voucher, datetime] _unpaid: A mapping from voucher identifiers
         which have recently failed a redemption attempt due to an unpaid
         response from the redemption server to timestamps when the failure was
@@ -459,6 +473,7 @@ class PaymentController(object):
     redeemer = attr.ib()
 
     _unpaid = attr.ib(default=attr.Factory(dict))
+    _active = attr.ib(default=attr.Factory(dict))
 
     def redeem(self, voucher, num_tokens=100):
         """
@@ -484,7 +499,11 @@ class PaymentController(object):
 
         # Ask the redeemer to do the real task of redemption.
         self._log.info("Redeeming random tokens for a voucher ({voucher}).", voucher=voucher)
-        d = self.redeemer.redeem(Voucher(voucher), tokens)
+        d = bracket(
+            lambda: setitem(self._active, voucher, self.store.now()),
+            lambda: delitem(self._active, voucher),
+            lambda: self.redeemer.redeem(Voucher(voucher), tokens),
+        )
         d.addCallbacks(
             partial(self._redeemSuccess, voucher),
             partial(self._redeemFailure, voucher),
@@ -533,11 +552,17 @@ class PaymentController(object):
         example, if a redemption attempt is current in progress, this is
         incorporated.
         """
-        if isinstance(voucher.state, Pending) and voucher.number in self._unpaid:
-            return attr.evolve(
-                voucher,
-                state=model_Unpaid(finished=self._unpaid[voucher.number]),
-            )
+        if isinstance(voucher.state, model_Pending):
+            if voucher.number in self._active:
+                return attr.evolve(
+                    voucher,
+                    state=model_Redeeming(started=self._active[voucher.number]),
+                )
+            if voucher.number in self._unpaid:
+                return attr.evolve(
+                    voucher,
+                    state=model_Unpaid(finished=self._unpaid[voucher.number]),
+                )
         return voucher
 
 
@@ -558,3 +583,34 @@ _REDEEMERS = {
     u"unpaid": UnpaidRedeemer.make,
     u"ristretto": RistrettoRedeemer.make,
 }
+
+
+@inlineCallbacks
+def bracket(first, last, between):
+    """
+    Invoke an action between two other actions.
+
+    :param first: A no-argument function that may return a Deferred.  It is
+        called first.
+
+    :param last: A no-argument function that may return a Deferred.  It is
+        called last.
+
+    :param between: A no-argument function that may return a Deferred.  It is
+        called after ``first`` is done and completes before ``last`` is called.
+
+    :return Deferred: A ``Deferred`` which fires with the result of
+        ``between``.
+    """
+    yield first()
+    try:
+        result = yield between()
+    except GeneratorExit:
+        raise
+    except:
+        info = exc_info()
+        yield last()
+        raise info
+    else:
+        yield last()
+        returnValue(result)
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 95780c8..4337dec 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -466,7 +466,26 @@ class RandomToken(object):
 
 @attr.s(frozen=True)
 class Pending(object):
-    pass
+    def to_json_v1(self):
+        return {
+            u"name": u"pending",
+        }
+
+
+@attr.s(frozen=True)
+class Redeeming(object):
+    """
+    This is a non-persistent state in which a voucher exists when the database
+    state is **pending** but for which there is a redemption operation in
+    progress.
+    """
+    started = attr.ib(validator=attr.validators.instance_of(datetime))
+
+    def to_json_v1(self):
+        return {
+            u"name": u"redeeming",
+            u"started": self.started.isoformat(),
+        }
 
 
 @attr.s(frozen=True)
@@ -474,11 +493,24 @@ class Redeemed(object):
     finished = attr.ib(validator=attr.validators.instance_of(datetime))
     token_count = attr.ib(validator=attr.validators.instance_of((int, long)))
 
+    def to_json_v1(self):
+        return {
+            u"name": u"redeemed",
+            u"finished": self.finished.isoformat(),
+            u"token-count": self.token_count,
+        }
+
 
 @attr.s(frozen=True)
 class DoubleSpend(object):
     finished = attr.ib(validator=attr.validators.instance_of(datetime))
 
+    def to_json_v1(self):
+        return {
+            u"name": u"double-spend",
+            u"finished": self.finished.isoformat(),
+        }
+
 
 @attr.s(frozen=True)
 class Unpaid(object):
@@ -489,6 +521,12 @@ class Unpaid(object):
     """
     finished = attr.ib(validator=attr.validators.instance_of(datetime))
 
+    def to_json_v1(self):
+        return {
+            u"name": u"unpaid",
+            u"finished": self.finished.isoformat(),
+        }
+
 
 @attr.s
 class Voucher(object):
@@ -554,6 +592,10 @@ class Voucher(object):
         state_name = state_json[u"name"]
         if state_name == u"pending":
             state = Pending()
+        elif state_name == u"redeeming":
+            state = Redeeming(
+                started=parse_datetime(state_json[u"started"]),
+            )
         elif state_name == u"double-spend":
             state = DoubleSpend(
                 finished=parse_datetime(state_json[u"finished"]),
@@ -586,28 +628,7 @@ class Voucher(object):
 
 
     def to_json_v1(self):
-        if isinstance(self.state, Pending):
-            state = {
-                u"name": u"pending",
-            }
-        elif isinstance(self.state, DoubleSpend):
-            state = {
-                u"name": u"double-spend",
-                u"finished": self.state.finished.isoformat(),
-            }
-        elif isinstance(self.state, Redeemed):
-            state = {
-                u"name": u"redeemed",
-                u"finished": self.state.finished.isoformat(),
-                u"token-count": self.state.token_count,
-            }
-        elif isinstance(self.state, Unpaid):
-            state = {
-                u"name": u"unpaid",
-                u"finished": self.state.finished.isoformat(),
-            }
-        else:
-            raise ValueError("Unrecognized state {!r}".format(self.state.__class__))
+        state = self.state.to_json_v1()
         return {
             u"number": self.number,
             u"created": None if self.created is None else self.created.isoformat(),
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index cfc9a8c..7af34c1 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -112,7 +112,7 @@ from treq.testing import (
 
 from ..model import (
     Voucher,
-    Pending,
+    Redeeming,
     Redeemed,
     DoubleSpend,
     Unpaid,
@@ -599,11 +599,11 @@ class VoucherTests(TestCase):
         )
 
     @given(tahoe_configs(client_nonredeemer_configurations()), datetimes(), vouchers())
-    def test_get_known_voucher_pending(self, get_config, now, voucher):
+    def test_get_known_voucher_redeeming(self, get_config, now, voucher):
         """
         When a voucher is first ``PUT`` and then later a ``GET`` is issued for the
         same voucher then the response code is **OK** and details, including
-        those relevant to a voucher which is still pending redemption, about
+        those relevant to a voucher which is actively being redeemed, about
         the voucher are included in a json-encoded response body.
         """
         return self._test_get_known_voucher(
@@ -613,7 +613,9 @@ class VoucherTests(TestCase):
             MatchesStructure(
                 number=Equals(voucher),
                 created=Equals(now),
-                state=Equals(Pending()),
+                state=Equals(Redeeming(
+                    started=now,
+                )),
             ),
         )
 
-- 
GitLab