MPS-20 + MPS-21: tick scripts — auction state transitions, offer expiration
Without these scripts, auctions stay ACTIVE forever and offers never
expire. Cron runs them on a schedule to drive the state machines.
lib/auction_tick.py:
- Pure functions transition_scheduled_to_active and transition_active_to_ended
- tick(dbsession) orchestrator scans:
- SCHEDULED + start_timestamp <= now → ACTIVE
- ACTIVE + end_timestamp <= now → ENDED (winner from is_winning bid;
payment_deadline_timestamp set to end + 48h)
lib/offer_tick.py:
- tick(dbsession) scans non-terminal offers (PENDING / COUNTERED) past
expires_timestamp and calls lib/offer.expire_offer on each, which
flips state to EXPIRED and writes the OFFER_EVENT_EXPIRE audit row
Both ticks are idempotent — running twice on already-transitioned rows
is a no-op.
Cron entry points:
- scripts/auction_tick.py — every minute (* * * * *) recommended;
60s default soft-close window means 1-min granularity is fine
- scripts/offer_tick.py — every 15 minutes; offers expire at hour
granularity so coarse polling is enough
Tests:
- 2 unit (test_models.py): pure transition_scheduled_to_active /
transition_active_to_ended for state, timestamp, and missing-data branches
- 4 integration auction: SCHEDULED→ACTIVE on start passing, ACTIVE→
ENDED with winner recorded, ACTIVE→ENDED with no bids leaves winner
None, idempotent
- 4 integration offer: PENDING past expires → EXPIRED, live offer not
touched, terminal offer not touched, idempotent
Total: 936 tests pass (was 926 + 10).
This commit is contained in:
parent
31ab7507ba
commit
e044c13c74
6 changed files with 471 additions and 0 deletions
94
make_post_sell/lib/auction_tick.py
Normal file
94
make_post_sell/lib/auction_tick.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""auction_tick — scheduled state transitions for MpsAuction.
|
||||
|
||||
Pure functions are unit-testable without a Pyramid environment; the
|
||||
script entry point in scripts/auction_tick.py drives them with a real
|
||||
dbsession.
|
||||
|
||||
Transitions:
|
||||
SCHEDULED + start_timestamp passed → ACTIVE
|
||||
ACTIVE + end_timestamp passed → ENDED (winner = is_winning bidder)
|
||||
|
||||
Idempotent: running the tick twice in a row is a no-op for any auction
|
||||
that has already transitioned past the matching threshold.
|
||||
"""
|
||||
|
||||
from ..models.auction import (
|
||||
AUCTION_STATE_ACTIVE,
|
||||
AUCTION_STATE_ENDED,
|
||||
AUCTION_STATE_SCHEDULED,
|
||||
MpsAuction,
|
||||
MpsBid,
|
||||
now_timestamp,
|
||||
)
|
||||
|
||||
|
||||
def transition_scheduled_to_active(auction, now_ms):
|
||||
"""Pure: returns True iff the auction should transition.
|
||||
Caller (orchestrator) mutates state."""
|
||||
if auction.state != AUCTION_STATE_SCHEDULED:
|
||||
return False
|
||||
if auction.start_timestamp is None:
|
||||
return False
|
||||
return now_ms >= auction.start_timestamp
|
||||
|
||||
|
||||
def transition_active_to_ended(auction, now_ms):
|
||||
"""Pure: returns True iff the active auction should end now."""
|
||||
if auction.state != AUCTION_STATE_ACTIVE:
|
||||
return False
|
||||
if auction.end_timestamp is None:
|
||||
return False
|
||||
return now_ms >= auction.end_timestamp
|
||||
|
||||
|
||||
def tick(dbsession, now_ms=None):
|
||||
"""Run state transitions on every non-terminal auction.
|
||||
|
||||
Returns a dict {activated: int, ended: int} of how many auctions
|
||||
moved each direction. Caller (script entry point) handles its own
|
||||
transaction/commit boundary.
|
||||
"""
|
||||
if now_ms is None:
|
||||
now_ms = now_timestamp()
|
||||
|
||||
activated = 0
|
||||
ended = 0
|
||||
|
||||
# Pull all auctions that could need a transition. Two simple queries
|
||||
# avoid scanning the whole table — no MOAD-0001 here.
|
||||
scheduled = (
|
||||
dbsession.query(MpsAuction)
|
||||
.filter(MpsAuction.state == AUCTION_STATE_SCHEDULED)
|
||||
.filter(MpsAuction.start_timestamp <= now_ms)
|
||||
.all()
|
||||
)
|
||||
for auction in scheduled:
|
||||
auction.state = AUCTION_STATE_ACTIVE
|
||||
auction.updated_timestamp = now_ms
|
||||
activated += 1
|
||||
|
||||
active = (
|
||||
dbsession.query(MpsAuction)
|
||||
.filter(MpsAuction.state == AUCTION_STATE_ACTIVE)
|
||||
.filter(MpsAuction.end_timestamp <= now_ms)
|
||||
.all()
|
||||
)
|
||||
for auction in active:
|
||||
auction.state = AUCTION_STATE_ENDED
|
||||
auction.updated_timestamp = now_ms
|
||||
|
||||
# Record winner from is_winning bid (if any).
|
||||
winning_bid = (
|
||||
dbsession.query(MpsBid)
|
||||
.filter_by(auction_id=auction.id, is_winning=True)
|
||||
.one_or_none()
|
||||
)
|
||||
if winning_bid is not None:
|
||||
auction.winner_user_id = winning_bid.bidder_user_id
|
||||
auction.winning_bid_id = winning_bid.id
|
||||
# Set payment deadline to end + 48h by default.
|
||||
auction.payment_deadline_timestamp = now_ms + 48 * 3600 * 1000
|
||||
|
||||
ended += 1
|
||||
|
||||
return {"activated": activated, "ended": ended}
|
||||
32
make_post_sell/lib/offer_tick.py
Normal file
32
make_post_sell/lib/offer_tick.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""offer_tick — auto-expiration for offers past expires_timestamp.
|
||||
|
||||
Idempotent: running twice on an already-expired offer is a no-op.
|
||||
"""
|
||||
|
||||
from ..models.offer import (
|
||||
MpsOffer,
|
||||
OFFER_STATE_PENDING,
|
||||
OFFER_STATE_COUNTERED,
|
||||
now_timestamp,
|
||||
)
|
||||
from .offer import expire_offer
|
||||
|
||||
|
||||
def tick(dbsession, now_ms=None):
|
||||
"""Expire any non-terminal offer past expires_timestamp.
|
||||
Returns a dict {expired: int}. Caller manages its txn boundary.
|
||||
"""
|
||||
if now_ms is None:
|
||||
now_ms = now_timestamp()
|
||||
|
||||
candidates = (
|
||||
dbsession.query(MpsOffer)
|
||||
.filter(MpsOffer.state.in_([OFFER_STATE_PENDING, OFFER_STATE_COUNTERED]))
|
||||
.filter(MpsOffer.expires_timestamp <= now_ms)
|
||||
.all()
|
||||
)
|
||||
expired = 0
|
||||
for offer in candidates:
|
||||
expire_offer(offer, now_ms=now_ms)
|
||||
expired += 1
|
||||
return {"expired": expired}
|
||||
42
make_post_sell/scripts/auction_tick.py
Normal file
42
make_post_sell/scripts/auction_tick.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Cron entry point: run auction state transitions.
|
||||
|
||||
Usage:
|
||||
env/bin/python -m make_post_sell.scripts.auction_tick development.ini
|
||||
|
||||
Cron suggestion: every minute (* * * * *). Soft-close gives 60s default
|
||||
windows so 1-minute granularity is plenty.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from pyramid.paster import bootstrap, get_appsettings, setup_logging
|
||||
|
||||
from .. import models
|
||||
from ..lib.auction_tick import tick
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"config_uri",
|
||||
help="Configuration file, e.g., development.ini",
|
||||
)
|
||||
return parser.parse_args(argv[1:])
|
||||
|
||||
|
||||
def main(argv=sys.argv):
|
||||
args = parse_args(argv)
|
||||
setup_logging(args.config_uri)
|
||||
env = bootstrap(args.config_uri)
|
||||
|
||||
settings = get_appsettings(args.config_uri) # noqa: F841
|
||||
with env["request"].tm:
|
||||
result = tick(env["request"].dbsession)
|
||||
print(
|
||||
f"auction_tick: activated={result['activated']} ended={result['ended']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
40
make_post_sell/scripts/offer_tick.py
Normal file
40
make_post_sell/scripts/offer_tick.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Cron entry point: run offer auto-expiration.
|
||||
|
||||
Usage:
|
||||
env/bin/python -m make_post_sell.scripts.offer_tick development.ini
|
||||
|
||||
Cron suggestion: every 15 minutes — offers expire at hour granularity
|
||||
so this is fine.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from pyramid.paster import bootstrap, get_appsettings, setup_logging
|
||||
|
||||
from .. import models
|
||||
from ..lib.offer_tick import tick
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"config_uri",
|
||||
help="Configuration file, e.g., development.ini",
|
||||
)
|
||||
return parser.parse_args(argv[1:])
|
||||
|
||||
|
||||
def main(argv=sys.argv):
|
||||
args = parse_args(argv)
|
||||
setup_logging(args.config_uri)
|
||||
env = bootstrap(args.config_uri)
|
||||
|
||||
settings = get_appsettings(args.config_uri) # noqa: F841
|
||||
with env["request"].tm:
|
||||
result = tick(env["request"].dbsession)
|
||||
print(f"offer_tick: expired={result['expired']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -4974,3 +4974,222 @@ class TestCartTotalOverride(DatabaseIntegrationTests):
|
|||
# Override: $80 not $100.
|
||||
self.assertEqual(cart.auction_offer_override_in_cents, 8000)
|
||||
self.assertEqual(cart.total_price_in_cents, 8000)
|
||||
|
||||
|
||||
class TestAuctionTickIntegration(DatabaseIntegrationTests):
|
||||
"""MPS-20: lib/auction_tick.tick state transitions over time."""
|
||||
|
||||
def _shop_product(self):
|
||||
from ..models.product import Product
|
||||
shop = Shop(
|
||||
name="Tick Shop", phone_number="x", billing_address="x",
|
||||
description="x",
|
||||
)
|
||||
shop.stripe_public_api_key = "pk_test"
|
||||
shop.stripe_secret_api_key = "sk_test"
|
||||
shop.domain_name = "tick.test"
|
||||
self.dbsession.add(shop)
|
||||
product = Product(title="Tick item", description="...")
|
||||
product.shop = shop
|
||||
product.price_in_cents = 5000
|
||||
product.is_physical = False
|
||||
product.is_sellable = True
|
||||
product.pricing_mode = 1
|
||||
self.dbsession.add(product)
|
||||
self.dbsession.flush()
|
||||
return shop, product
|
||||
|
||||
def test_scheduled_auction_activates_when_start_passes(self):
|
||||
from ..lib.auction_tick import tick
|
||||
from ..models.auction import (
|
||||
MpsAuction, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
|
||||
now_timestamp,
|
||||
)
|
||||
shop, product = self._shop_product()
|
||||
auction = MpsAuction(
|
||||
product=product, shop=shop, start_price_in_cents=1000,
|
||||
)
|
||||
auction.state = AUCTION_STATE_SCHEDULED
|
||||
auction.start_timestamp = now_timestamp() - 1000 # passed
|
||||
auction.end_timestamp = now_timestamp() + 60_000
|
||||
self.dbsession.add(auction)
|
||||
self.dbsession.flush()
|
||||
|
||||
result = tick(self.dbsession)
|
||||
self.assertEqual(result["activated"], 1)
|
||||
self.assertEqual(result["ended"], 0)
|
||||
self.assertEqual(auction.state, AUCTION_STATE_ACTIVE)
|
||||
|
||||
def test_active_auction_ends_when_end_passes_and_records_winner(self):
|
||||
from ..lib.auction_tick import tick
|
||||
from ..models.auction import (
|
||||
MpsAuction, MpsBid, AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
|
||||
now_timestamp,
|
||||
)
|
||||
shop, product = self._shop_product()
|
||||
bidder = get_or_create_user_by_email(self.dbsession, "bidder@tick.test")
|
||||
self.dbsession.add(bidder)
|
||||
self.dbsession.flush()
|
||||
|
||||
auction = MpsAuction(
|
||||
product=product, shop=shop, start_price_in_cents=1000,
|
||||
)
|
||||
auction.state = AUCTION_STATE_ACTIVE
|
||||
auction.start_timestamp = now_timestamp() - 60_000
|
||||
auction.end_timestamp = now_timestamp() - 1000 # passed
|
||||
self.dbsession.add(auction)
|
||||
bid = MpsBid(auction=auction, bidder=bidder, amount_in_cents=2000)
|
||||
bid.is_winning = True
|
||||
self.dbsession.add(bid)
|
||||
self.dbsession.flush()
|
||||
|
||||
result = tick(self.dbsession)
|
||||
self.assertEqual(result["ended"], 1)
|
||||
self.assertEqual(auction.state, AUCTION_STATE_ENDED)
|
||||
self.assertEqual(auction.winner_user_id, bidder.id)
|
||||
self.assertEqual(auction.winning_bid_id, bid.id)
|
||||
self.assertIsNotNone(auction.payment_deadline_timestamp)
|
||||
|
||||
def test_active_auction_ends_with_no_bids_no_winner(self):
|
||||
from ..lib.auction_tick import tick
|
||||
from ..models.auction import (
|
||||
MpsAuction, AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
|
||||
now_timestamp,
|
||||
)
|
||||
shop, product = self._shop_product()
|
||||
auction = MpsAuction(
|
||||
product=product, shop=shop, start_price_in_cents=1000,
|
||||
)
|
||||
auction.state = AUCTION_STATE_ACTIVE
|
||||
auction.start_timestamp = now_timestamp() - 60_000
|
||||
auction.end_timestamp = now_timestamp() - 1000
|
||||
self.dbsession.add(auction)
|
||||
self.dbsession.flush()
|
||||
|
||||
result = tick(self.dbsession)
|
||||
self.assertEqual(result["ended"], 1)
|
||||
self.assertEqual(auction.state, AUCTION_STATE_ENDED)
|
||||
self.assertIsNone(auction.winner_user_id)
|
||||
self.assertIsNone(auction.winning_bid_id)
|
||||
|
||||
def test_tick_idempotent(self):
|
||||
from ..lib.auction_tick import tick
|
||||
from ..models.auction import (
|
||||
MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp,
|
||||
)
|
||||
shop, product = self._shop_product()
|
||||
auction = MpsAuction(
|
||||
product=product, shop=shop, start_price_in_cents=1000,
|
||||
)
|
||||
auction.state = AUCTION_STATE_ACTIVE
|
||||
auction.start_timestamp = now_timestamp() - 60_000
|
||||
auction.end_timestamp = now_timestamp() - 1000
|
||||
self.dbsession.add(auction)
|
||||
self.dbsession.flush()
|
||||
|
||||
result1 = tick(self.dbsession)
|
||||
self.assertEqual(result1["ended"], 1)
|
||||
# Second tick on already-ended auction should be a no-op.
|
||||
result2 = tick(self.dbsession)
|
||||
self.assertEqual(result2["ended"], 0)
|
||||
self.assertEqual(result2["activated"], 0)
|
||||
|
||||
|
||||
class TestOfferTickIntegration(DatabaseIntegrationTests):
|
||||
"""MPS-21: lib/offer_tick.tick auto-expiration."""
|
||||
|
||||
def _shop_product_buyer(self):
|
||||
from ..models.product import Product
|
||||
shop = Shop(
|
||||
name="Offer Tick Shop", phone_number="x", billing_address="x",
|
||||
description="x",
|
||||
)
|
||||
shop.stripe_public_api_key = "pk_test"
|
||||
shop.stripe_secret_api_key = "sk_test"
|
||||
shop.domain_name = "ot.test"
|
||||
self.dbsession.add(shop)
|
||||
product = Product(title="Offer tick", description="...")
|
||||
product.shop = shop
|
||||
product.price_in_cents = 10000
|
||||
product.is_physical = False
|
||||
product.is_sellable = True
|
||||
product.pricing_mode = 3
|
||||
self.dbsession.add(product)
|
||||
buyer = get_or_create_user_by_email(self.dbsession, "buyer@ot.test")
|
||||
self.dbsession.add(buyer)
|
||||
self.dbsession.flush()
|
||||
return shop, product, buyer
|
||||
|
||||
def test_expired_pending_offer_flips_to_expired(self):
|
||||
from ..lib.offer_tick import tick
|
||||
from ..models.offer import (
|
||||
MpsOffer, OFFER_STATE_PENDING, OFFER_STATE_EXPIRED,
|
||||
now_timestamp,
|
||||
)
|
||||
shop, product, buyer = self._shop_product_buyer()
|
||||
offer = MpsOffer(
|
||||
product=product, shop=shop, buyer=buyer,
|
||||
amount_in_cents=5000,
|
||||
expires_timestamp=now_timestamp() - 1000, # already past
|
||||
)
|
||||
self.dbsession.add(offer)
|
||||
self.dbsession.flush()
|
||||
self.assertEqual(offer.state, OFFER_STATE_PENDING)
|
||||
|
||||
result = tick(self.dbsession)
|
||||
self.assertEqual(result["expired"], 1)
|
||||
self.assertEqual(offer.state, OFFER_STATE_EXPIRED)
|
||||
|
||||
def test_live_offer_not_touched(self):
|
||||
from ..lib.offer_tick import tick
|
||||
from ..models.offer import (
|
||||
MpsOffer, OFFER_STATE_PENDING, now_timestamp,
|
||||
)
|
||||
shop, product, buyer = self._shop_product_buyer()
|
||||
offer = MpsOffer(
|
||||
product=product, shop=shop, buyer=buyer,
|
||||
amount_in_cents=5000,
|
||||
expires_timestamp=now_timestamp() + 86_400_000, # 1 day from now
|
||||
)
|
||||
self.dbsession.add(offer)
|
||||
self.dbsession.flush()
|
||||
|
||||
result = tick(self.dbsession)
|
||||
self.assertEqual(result["expired"], 0)
|
||||
self.assertEqual(offer.state, OFFER_STATE_PENDING)
|
||||
|
||||
def test_terminal_offer_not_touched(self):
|
||||
from ..lib.offer_tick import tick
|
||||
from ..models.offer import (
|
||||
MpsOffer, OFFER_STATE_DECLINED, now_timestamp,
|
||||
)
|
||||
shop, product, buyer = self._shop_product_buyer()
|
||||
offer = MpsOffer(
|
||||
product=product, shop=shop, buyer=buyer,
|
||||
amount_in_cents=5000,
|
||||
expires_timestamp=now_timestamp() - 1000,
|
||||
)
|
||||
offer.state = OFFER_STATE_DECLINED # terminal
|
||||
self.dbsession.add(offer)
|
||||
self.dbsession.flush()
|
||||
|
||||
result = tick(self.dbsession)
|
||||
self.assertEqual(result["expired"], 0)
|
||||
self.assertEqual(offer.state, OFFER_STATE_DECLINED)
|
||||
|
||||
def test_tick_idempotent(self):
|
||||
from ..lib.offer_tick import tick
|
||||
from ..models.offer import MpsOffer, now_timestamp
|
||||
shop, product, buyer = self._shop_product_buyer()
|
||||
offer = MpsOffer(
|
||||
product=product, shop=shop, buyer=buyer,
|
||||
amount_in_cents=5000,
|
||||
expires_timestamp=now_timestamp() - 1000,
|
||||
)
|
||||
self.dbsession.add(offer)
|
||||
self.dbsession.flush()
|
||||
|
||||
result1 = tick(self.dbsession)
|
||||
self.assertEqual(result1["expired"], 1)
|
||||
result2 = tick(self.dbsession)
|
||||
self.assertEqual(result2["expired"], 0)
|
||||
|
|
|
|||
|
|
@ -4754,3 +4754,47 @@ class TestOfferLibPureFunctions(unittest.TestCase):
|
|||
self.assertEqual(
|
||||
auto_resolve_open(100, 0, 95, 50), "queue",
|
||||
)
|
||||
|
||||
|
||||
class TestAuctionTickPureFunctions(unittest.TestCase):
|
||||
"""MPS-20: lib/auction_tick.py pure transition checks (no DB)."""
|
||||
|
||||
def _stub_auction(self, state, start_timestamp=None, end_timestamp=None):
|
||||
from types import SimpleNamespace
|
||||
return SimpleNamespace(
|
||||
state=state,
|
||||
start_timestamp=start_timestamp,
|
||||
end_timestamp=end_timestamp,
|
||||
)
|
||||
|
||||
def test_scheduled_to_active_when_start_passed(self):
|
||||
from ..lib.auction_tick import transition_scheduled_to_active
|
||||
from ..models.auction import (
|
||||
AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
|
||||
)
|
||||
# Start in past → True.
|
||||
a = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=1000)
|
||||
self.assertTrue(transition_scheduled_to_active(a, now_ms=2000))
|
||||
# Start in future → False.
|
||||
a2 = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=2000)
|
||||
self.assertFalse(transition_scheduled_to_active(a2, now_ms=1000))
|
||||
# Wrong state → False.
|
||||
a3 = self._stub_auction(AUCTION_STATE_ACTIVE, start_timestamp=1000)
|
||||
self.assertFalse(transition_scheduled_to_active(a3, now_ms=2000))
|
||||
# Missing start_timestamp → False.
|
||||
a4 = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=None)
|
||||
self.assertFalse(transition_scheduled_to_active(a4, now_ms=2000))
|
||||
|
||||
def test_active_to_ended_when_end_passed(self):
|
||||
from ..lib.auction_tick import transition_active_to_ended
|
||||
from ..models.auction import (
|
||||
AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
|
||||
)
|
||||
a = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=1000)
|
||||
self.assertTrue(transition_active_to_ended(a, now_ms=2000))
|
||||
a2 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=2000)
|
||||
self.assertFalse(transition_active_to_ended(a2, now_ms=1000))
|
||||
a3 = self._stub_auction(AUCTION_STATE_ENDED, end_timestamp=1000)
|
||||
self.assertFalse(transition_active_to_ended(a3, now_ms=2000))
|
||||
a4 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=None)
|
||||
self.assertFalse(transition_active_to_ended(a4, now_ms=2000))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue