1051 lines
39 KiB
Rust
1051 lines
39 KiB
Rust
//! Durable FIFA 17 transfer-market **listing** store.
|
|
//!
|
|
//! This is host-owned FIFA policy state, NOT generic Core inventory. Core stays
|
|
//! the sole authority for coins and item ownership; the market layer owns only
|
|
//! the durable *listing* lifecycle (who is selling what, at what price, and in
|
|
//! which state). It is backed by its own SQLite file so it survives restart.
|
|
//!
|
|
//! ## Concurrency model (load-bearing)
|
|
//!
|
|
//! The pool is opened exactly like [`openfut_core::db::init_pool`]: WAL is
|
|
//! established once on the file before the pool opens, every pooled connection
|
|
//! carries `foreign_keys=ON` and a 5s `busy_timeout`, and **every write runs
|
|
//! inside a `BEGIN IMMEDIATE` transaction**. Immediate transactions take the
|
|
//! write lock up front, so the reserve compare-and-swap is genuinely atomic
|
|
//! across connections — the exact class of bug (deferred transactions racing a
|
|
//! read-then-write) that was just fixed in Core. Two buyers reserving the same
|
|
//! active listing therefore resolve to exactly one winner.
|
|
//!
|
|
//! ## State machine
|
|
//!
|
|
//! ```text
|
|
//! active ──reserve──▶ reserved ──complete_sale──▶ sold
|
|
//! │ │
|
|
//! │ └──rollback_reservation──▶ active
|
|
//! └──cancel──▶ cancelled
|
|
//! ```
|
|
//!
|
|
//! `reserved` is a real state (a listing being paid for), so it is part of the
|
|
//! `CHECK` constraint even though it is a transient intermediate — omitting it
|
|
//! would make [`MarketStore::reserve_listing`] fail the constraint.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use parking_lot::Mutex;
|
|
|
|
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
|
use sqlx::{ConnectOptions, Connection, Row, SqlitePool};
|
|
|
|
/// Test-only durable-store fault injector, shared (cheap `Arc` clone) between a
|
|
/// store and the failure-injection tests. It is **inert in production**: nothing
|
|
/// arms it, so each guarded op reads one relaxed atomic and behaves exactly as
|
|
/// before. The economy failure tests use it to force a durable-store write to
|
|
/// fail at a chosen point (market complete-sale / reserve, pile write) — the
|
|
/// only way to exercise those recovery paths, since the stores are concrete
|
|
/// types wired straight into the handlers (no trait seam to substitute).
|
|
#[derive(Clone, Default)]
|
|
pub struct StoreFault {
|
|
inner: Arc<StoreFaultInner>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct StoreFaultInner {
|
|
any: AtomicBool,
|
|
armed: Mutex<HashMap<&'static str, u32>>,
|
|
}
|
|
|
|
impl StoreFault {
|
|
/// Arm `op` to fail its next `times` invocations, then heal automatically.
|
|
pub fn arm(&self, op: &'static str, times: u32) {
|
|
self.inner.armed.lock().insert(op, times);
|
|
self.inner.any.store(true, Ordering::SeqCst);
|
|
}
|
|
|
|
/// Consume one armed unit for `op`, returning whether it should fail now.
|
|
/// Fast path (unarmed): a single relaxed atomic load, no lock taken.
|
|
pub fn tripped(&self, op: &'static str) -> bool {
|
|
if !self.inner.any.load(Ordering::Relaxed) {
|
|
return false;
|
|
}
|
|
let mut armed = self.inner.armed.lock();
|
|
let fire = match armed.get_mut(op) {
|
|
Some(n) if *n > 0 => {
|
|
*n -= 1;
|
|
true
|
|
}
|
|
_ => false,
|
|
};
|
|
if armed.values().all(|&n| n == 0) {
|
|
self.inner.any.store(false, Ordering::SeqCst);
|
|
}
|
|
fire
|
|
}
|
|
}
|
|
|
|
/// Typed failure of a listing operation. `Db` wraps an infrastructure error
|
|
/// (transport/encoding); everything else is a modelled lifecycle outcome.
|
|
#[derive(Debug)]
|
|
pub enum MarketError {
|
|
/// No listing with that id exists.
|
|
NotFound,
|
|
/// The listing has already been sold.
|
|
Sold,
|
|
/// The listing has already been cancelled.
|
|
Cancelled,
|
|
/// The caller is not the owner of the listing.
|
|
WrongOwner,
|
|
/// The listing was not in the state the transition required (e.g. a
|
|
/// reserved listing asked to cancel, or a duplicate id on insert).
|
|
Conflict,
|
|
/// SQLite / transport failure.
|
|
Db(String),
|
|
}
|
|
|
|
impl std::fmt::Display for MarketError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
MarketError::NotFound => write!(f, "listing not found"),
|
|
MarketError::Sold => write!(f, "listing already sold"),
|
|
MarketError::Cancelled => write!(f, "listing already cancelled"),
|
|
MarketError::WrongOwner => write!(f, "listing owned by another seller"),
|
|
MarketError::Conflict => write!(f, "listing state conflict"),
|
|
MarketError::Db(e) => write!(f, "market store db error: {e}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for MarketError {}
|
|
|
|
fn db(e: sqlx::Error) -> MarketError {
|
|
MarketError::Db(e.to_string())
|
|
}
|
|
|
|
/// One transfer-market listing row.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Listing {
|
|
pub listing_id: String,
|
|
/// Authoritative Core content/card id — what a synthetic buy MINTS. Distinct
|
|
/// from the FIFA wire `resourceId` (see `wire_resource_id`).
|
|
pub card_id: String,
|
|
/// Set for a seller-listed owned item; `None` for a synthetic-seller
|
|
/// listing (the buy path mints a fresh Core item instead of transferring).
|
|
pub core_item_id: Option<String>,
|
|
/// The FIFA wire item id of a seller-listed owned item, if any.
|
|
pub wire_item_id: Option<i64>,
|
|
/// The FIFA wire `resourceId` (versioned) the client listed, echoed back in
|
|
/// the auction record. Never used to mint — the mint uses `card_id`.
|
|
pub wire_resource_id: Option<i64>,
|
|
pub start_price: i64,
|
|
pub buy_now_price: i64,
|
|
/// Opaque seller identity; `None` for synthetic listings.
|
|
pub owner: Option<String>,
|
|
/// `active` | `reserved` | `sold` | `cancelled`.
|
|
pub state: String,
|
|
/// Creation time, unix-epoch milliseconds as a string (sortable).
|
|
pub created_at: String,
|
|
/// The FIFA card object (`itemData`) as shaped at listing time, serialized.
|
|
/// A listing is a SNAPSHOT: the auction record must carry the full card the
|
|
/// client can render (rating/position/attributes/rareflag/assetId), not a
|
|
/// stub — a stub leaves the Transfer List with an unrenderable row. `None`
|
|
/// only for rows written before this column existed (renders as a stub).
|
|
pub item_json: Option<String>,
|
|
/// Listing duration in SECONDS, as sent by the client in the `ISStart` body
|
|
/// (`duration`). With `created_at` this is the whole auction clock: FIFA 17
|
|
/// renders a live countdown from `expires` and expects it to reach 0, so a
|
|
/// listing has to know when it ends. `None` for rows written before this
|
|
/// column existed, which fall back to the default duration.
|
|
pub duration_secs: Option<i64>,
|
|
}
|
|
|
|
/// FIFA 17 auction durations, in seconds: 3600, 10800, 21600, 43200, 86400,
|
|
/// 259200. One hour is the shortest, and the fallback when a client body omits it
|
|
/// or a pre-column row is read.
|
|
pub const DEFAULT_DURATION_SECS: i64 = 3600;
|
|
|
|
/// Seconds since the unix epoch.
|
|
pub fn now_secs() -> i64 {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
impl Listing {
|
|
/// SECONDS REMAINING on this auction at `now` (unix seconds), clamped at 0 —
|
|
/// the wire semantics of `expires`, which is never an absolute epoch.
|
|
///
|
|
/// A closed/sold/cancelled listing reads 0: there is no time left on an
|
|
/// auction that has already ended.
|
|
pub fn expires_in_secs(&self, now: i64) -> i64 {
|
|
if self.state != "active" {
|
|
return 0;
|
|
}
|
|
let created_secs = self
|
|
.created_at
|
|
.parse::<i64>()
|
|
.map(|ms| ms / 1000)
|
|
.unwrap_or(now);
|
|
let duration = self.duration_secs.unwrap_or(DEFAULT_DURATION_SECS);
|
|
(created_secs + duration - now).max(0)
|
|
}
|
|
}
|
|
|
|
const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
|
listing_id TEXT PRIMARY KEY,
|
|
card_id TEXT NOT NULL,
|
|
core_item_id TEXT,
|
|
wire_item_id INTEGER,
|
|
wire_resource_id INTEGER,
|
|
start_price INTEGER NOT NULL,
|
|
buy_now_price INTEGER NOT NULL,
|
|
owner TEXT,
|
|
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
|
created_at TEXT NOT NULL,
|
|
item_json TEXT,
|
|
duration_secs INTEGER,
|
|
-- Seller acknowledgement of a SOLD row, separate from the sale itself. Declared
|
|
-- here so a fresh store never needs the ALTER path below; the additive
|
|
-- migration exists only for stores created before this column.
|
|
cleared_at TEXT
|
|
)";
|
|
|
|
fn now_millis() -> String {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis())
|
|
.unwrap_or(0)
|
|
.to_string()
|
|
}
|
|
|
|
fn row_to_listing(row: &sqlx::sqlite::SqliteRow) -> Listing {
|
|
Listing {
|
|
listing_id: row.get("listing_id"),
|
|
card_id: row.get("card_id"),
|
|
core_item_id: row.get("core_item_id"),
|
|
wire_item_id: row.get("wire_item_id"),
|
|
wire_resource_id: row.get("wire_resource_id"),
|
|
start_price: row.get("start_price"),
|
|
buy_now_price: row.get("buy_now_price"),
|
|
owner: row.get("owner"),
|
|
state: row.get("state"),
|
|
created_at: row.get("created_at"),
|
|
item_json: row.get("item_json"),
|
|
duration_secs: row.get("duration_secs"),
|
|
}
|
|
}
|
|
|
|
/// Durable listing store over an sqlx SQLite pool. Cheap to clone (the pool is
|
|
/// an `Arc` internally), so the same store can be shared across tasks.
|
|
#[derive(Clone)]
|
|
pub struct MarketStore {
|
|
pool: SqlitePool,
|
|
fault: StoreFault,
|
|
}
|
|
|
|
impl MarketStore {
|
|
/// Open (creating if missing) the market DB at `path`, mirroring Core's
|
|
/// `init_pool`: establish WAL once on the file, then open a multi-connection
|
|
/// pool where every connection carries foreign_keys + a busy_timeout.
|
|
pub async fn open(path: &str) -> Result<Self, MarketError> {
|
|
let opts = SqliteConnectOptions::new()
|
|
.filename(path)
|
|
.create_if_missing(true)
|
|
.journal_mode(SqliteJournalMode::Wal)
|
|
.foreign_keys(true)
|
|
.busy_timeout(Duration::from_secs(5));
|
|
// Establish WAL on the file via ONE connection BEFORE the pool opens, so
|
|
// pooled connections only ever re-assert an already-WAL file (see Core).
|
|
{
|
|
let mut conn = opts.clone().connect().await.map_err(db)?;
|
|
sqlx::query("PRAGMA journal_mode=WAL")
|
|
.execute(&mut conn)
|
|
.await
|
|
.map_err(db)?;
|
|
conn.close().await.map_err(db)?;
|
|
}
|
|
let pool = SqlitePoolOptions::new()
|
|
.max_connections(8)
|
|
.connect_with(opts)
|
|
.await
|
|
.map_err(db)?;
|
|
sqlx::query(CREATE_LISTINGS)
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(db)?;
|
|
// Additive migration: `item_json` was added after the first stores shipped,
|
|
// and `CREATE TABLE IF NOT EXISTS` will not add a column to an existing
|
|
// file. Add it when absent so an existing market DB keeps working (old
|
|
// rows read back `None` and render the stub card).
|
|
let existing: Vec<String> = sqlx::query("PRAGMA table_info(listings)")
|
|
.fetch_all(&pool)
|
|
.await
|
|
.map_err(db)?
|
|
.iter()
|
|
.map(|r| r.get::<String, _>("name"))
|
|
.collect();
|
|
for (col, decl) in [
|
|
("item_json", "TEXT"),
|
|
("duration_secs", "INTEGER"),
|
|
// A SOLD listing is not the end of the seller's involvement: FIFA 17 has
|
|
// a bulk `DELETE …/trade/sold` verb (builder 0x1801647c0, request name
|
|
// RemoveAllSoldFromTradePile), which only makes sense if sold rows
|
|
// PERSIST in the seller's pile until cleared. `cleared_at` records that
|
|
// acknowledgement separately from the sale itself, so clearing a row can
|
|
// never be mistaken for re-settling it.
|
|
("cleared_at", "TEXT"),
|
|
] {
|
|
if !existing.iter().any(|c| c == col) {
|
|
sqlx::query(&format!("ALTER TABLE listings ADD COLUMN {col} {decl}"))
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(db)?;
|
|
}
|
|
}
|
|
Ok(MarketStore {
|
|
pool,
|
|
fault: StoreFault::default(),
|
|
})
|
|
}
|
|
|
|
/// A shared handle to this store's test-only fault switch (inert unless a
|
|
/// test arms it). Production never calls it.
|
|
pub fn fault(&self) -> StoreFault {
|
|
self.fault.clone()
|
|
}
|
|
|
|
/// Insert a new `active` listing. `listing_id` is the numeric-string trade id
|
|
/// the client keys the auction on (the caller allocates it). Duplicate id ->
|
|
/// [`MarketError::Conflict`].
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn create_listing(
|
|
&self,
|
|
listing_id: &str,
|
|
card_id: &str,
|
|
core_item_id: Option<&str>,
|
|
wire_item_id: Option<i64>,
|
|
wire_resource_id: Option<i64>,
|
|
start_price: i64,
|
|
buy_now_price: i64,
|
|
owner: Option<&str>,
|
|
// The shaped FIFA card snapshot (`itemData`) for the auction record.
|
|
item_json: Option<&str>,
|
|
// Listing duration in seconds from the client's `ISStart` body; `None`
|
|
// falls back to [`DEFAULT_DURATION_SECS`].
|
|
duration_secs: Option<i64>,
|
|
) -> Result<Listing, MarketError> {
|
|
let created_at = now_millis();
|
|
let mut conn = self.pool.acquire().await.map_err(db)?;
|
|
sqlx::query("BEGIN IMMEDIATE")
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
let res = sqlx::query(
|
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, \
|
|
wire_resource_id, start_price, buy_now_price, owner, state, created_at, item_json, \
|
|
duration_secs) \
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
|
|
)
|
|
.bind(listing_id)
|
|
.bind(card_id)
|
|
.bind(core_item_id)
|
|
.bind(wire_item_id)
|
|
.bind(wire_resource_id)
|
|
.bind(start_price)
|
|
.bind(buy_now_price)
|
|
.bind(owner)
|
|
.bind(&created_at)
|
|
.bind(item_json)
|
|
.bind(duration_secs)
|
|
.execute(&mut *conn)
|
|
.await;
|
|
match res {
|
|
Ok(_) => {
|
|
sqlx::query("COMMIT")
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
Ok(Listing {
|
|
listing_id: listing_id.to_string(),
|
|
card_id: card_id.to_string(),
|
|
core_item_id: core_item_id.map(str::to_string),
|
|
wire_item_id,
|
|
wire_resource_id,
|
|
start_price,
|
|
buy_now_price,
|
|
owner: owner.map(str::to_string),
|
|
state: "active".to_string(),
|
|
created_at,
|
|
item_json: item_json.map(str::to_string),
|
|
duration_secs,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
|
// A PK clash is a caller-level conflict, not an infra failure.
|
|
if matches!(&e, sqlx::Error::Database(dbe) if dbe.is_unique_violation()) {
|
|
Err(MarketError::Conflict)
|
|
} else {
|
|
Err(db(e))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch one listing, or [`MarketError::NotFound`].
|
|
pub async fn get_listing(&self, listing_id: &str) -> Result<Listing, MarketError> {
|
|
let row = sqlx::query("SELECT * FROM listings WHERE listing_id = ?")
|
|
.bind(listing_id)
|
|
.fetch_optional(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
row.as_ref()
|
|
.map(row_to_listing)
|
|
.ok_or(MarketError::NotFound)
|
|
}
|
|
|
|
/// All listings in `state`, oldest first.
|
|
pub async fn query_listings(&self, state: &str) -> Result<Vec<Listing>, MarketError> {
|
|
let rows = sqlx::query("SELECT * FROM listings WHERE state = ? ORDER BY created_at ASC")
|
|
.bind(state)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
Ok(rows.iter().map(row_to_listing).collect())
|
|
}
|
|
|
|
/// Atomic compare-and-swap of a single listing's state inside a
|
|
/// `BEGIN IMMEDIATE` transaction. `Ok(true)` = the row was in `from` and is
|
|
/// now `to`; `Ok(false)` = the row exists but was not in `from` (lost race /
|
|
/// wrong state); `Err(NotFound)` = no such row.
|
|
async fn cas(&self, listing_id: &str, from: &str, to: &str) -> Result<bool, MarketError> {
|
|
let mut conn = self.pool.acquire().await.map_err(db)?;
|
|
sqlx::query("BEGIN IMMEDIATE")
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
let outcome: Result<bool, MarketError> = async {
|
|
let current: Option<String> =
|
|
sqlx::query("SELECT state FROM listings WHERE listing_id = ?")
|
|
.bind(listing_id)
|
|
.fetch_optional(&mut *conn)
|
|
.await
|
|
.map_err(db)?
|
|
.map(|r| r.get::<String, _>("state"));
|
|
match current {
|
|
None => Err(MarketError::NotFound),
|
|
Some(s) if s == from => {
|
|
sqlx::query("UPDATE listings SET state = ? WHERE listing_id = ? AND state = ?")
|
|
.bind(to)
|
|
.bind(listing_id)
|
|
.bind(from)
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
Ok(true)
|
|
}
|
|
Some(_) => Ok(false),
|
|
}
|
|
}
|
|
.await;
|
|
match &outcome {
|
|
Ok(_) => {
|
|
sqlx::query("COMMIT")
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
}
|
|
Err(_) => {
|
|
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
|
}
|
|
}
|
|
outcome
|
|
}
|
|
|
|
/// Reserve an `active` listing (`active -> reserved`). Returns whether this
|
|
/// caller won the reservation. Exactly one of two concurrent callers wins.
|
|
pub async fn reserve_listing(&self, listing_id: &str) -> Result<bool, MarketError> {
|
|
if self.fault.tripped("reserve") {
|
|
return Err(MarketError::Db("injected reserve fault".into()));
|
|
}
|
|
self.cas(listing_id, "active", "reserved").await
|
|
}
|
|
|
|
/// Finalise a won reservation (`reserved -> sold`). A listing not in
|
|
/// `reserved` is a [`MarketError::Conflict`].
|
|
pub async fn complete_sale(&self, listing_id: &str) -> Result<(), MarketError> {
|
|
if self.fault.tripped("complete_sale") {
|
|
return Err(MarketError::Db("injected complete_sale fault".into()));
|
|
}
|
|
if self.cas(listing_id, "reserved", "sold").await? {
|
|
Ok(())
|
|
} else {
|
|
Err(MarketError::Conflict)
|
|
}
|
|
}
|
|
|
|
/// Mark a live listing SOLD in one step (`active | reserved -> sold`), for a
|
|
/// sale driven by a counterparty rather than by this client's own buy-now.
|
|
/// Returns whether this call was the one that sold it, so a replay is visible
|
|
/// to the caller instead of silently settling twice.
|
|
pub async fn mark_sold(&self, listing_id: &str) -> Result<bool, MarketError> {
|
|
let affected = sqlx::query(
|
|
"UPDATE listings SET state = 'sold' \
|
|
WHERE listing_id = ? AND state IN ('active', 'reserved')",
|
|
)
|
|
.bind(listing_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(db)?
|
|
.rows_affected();
|
|
Ok(affected == 1)
|
|
}
|
|
|
|
/// Sold listings the seller has NOT yet cleared, newest first.
|
|
///
|
|
/// Separate from [`Self::query_listings`] because "sold" and "still shown to
|
|
/// the seller" are different facts: a sold row stays in the pile until the
|
|
/// client acknowledges it via the bulk clear verb.
|
|
pub async fn uncleared_sold(&self) -> Result<Vec<Listing>, MarketError> {
|
|
let rows = sqlx::query(
|
|
"SELECT * FROM listings WHERE state = 'sold' AND cleared_at IS NULL \
|
|
ORDER BY created_at DESC",
|
|
)
|
|
.fetch_all(&self.pool)
|
|
.await
|
|
.map_err(db)?;
|
|
Ok(rows.iter().map(row_to_listing).collect())
|
|
}
|
|
|
|
/// Acknowledge every uncleared sold listing (the bulk `DELETE …/trade/sold`).
|
|
/// Returns how many rows were cleared.
|
|
///
|
|
/// This is PRESENTATION ONLY. It records that the seller has seen the sale; it
|
|
/// moves no coins and no ownership, because settlement already happened when
|
|
/// the sale completed. Clearing must never be able to pay anyone twice.
|
|
pub async fn clear_sold(&self) -> Result<u64, MarketError> {
|
|
Ok(sqlx::query(
|
|
"UPDATE listings SET cleared_at = ? \
|
|
WHERE state = 'sold' AND cleared_at IS NULL",
|
|
)
|
|
.bind(now_millis())
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(db)?
|
|
.rows_affected())
|
|
}
|
|
|
|
/// Acknowledge ONE sold listing by id (the per-id `DELETE …/trade/{id}` form,
|
|
/// if the client turns out to use it for sold rows). Same presentation-only
|
|
/// contract as [`Self::clear_sold`].
|
|
pub async fn clear_sold_one(&self, listing_id: &str) -> Result<u64, MarketError> {
|
|
Ok(sqlx::query(
|
|
"UPDATE listings SET cleared_at = ? \
|
|
WHERE listing_id = ? AND state = 'sold' AND cleared_at IS NULL",
|
|
)
|
|
.bind(now_millis())
|
|
.bind(listing_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(db)?
|
|
.rows_affected())
|
|
}
|
|
|
|
/// Undo a reservation on a downstream failure (`reserved -> active`), so the
|
|
/// listing becomes buyable again. Not in `reserved` -> [`MarketError::Conflict`].
|
|
pub async fn rollback_reservation(&self, listing_id: &str) -> Result<(), MarketError> {
|
|
if self.cas(listing_id, "reserved", "active").await? {
|
|
Ok(())
|
|
} else {
|
|
Err(MarketError::Conflict)
|
|
}
|
|
}
|
|
|
|
/// Cancel an `active` listing once (`active -> cancelled`). If `owner` is
|
|
/// supplied it must match the listing's owner. Returns typed errors for
|
|
/// every non-active state so a double cancel is observable.
|
|
pub async fn cancel_listing(
|
|
&self,
|
|
listing_id: &str,
|
|
owner: Option<&str>,
|
|
) -> Result<(), MarketError> {
|
|
let mut conn = self.pool.acquire().await.map_err(db)?;
|
|
sqlx::query("BEGIN IMMEDIATE")
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
let outcome: Result<(), MarketError> = async {
|
|
let row = sqlx::query("SELECT state, owner FROM listings WHERE listing_id = ?")
|
|
.bind(listing_id)
|
|
.fetch_optional(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
let row = row.ok_or(MarketError::NotFound)?;
|
|
let state: String = row.get("state");
|
|
let stored_owner: Option<String> = row.get("owner");
|
|
if let Some(want) = owner {
|
|
if stored_owner.as_deref() != Some(want) {
|
|
return Err(MarketError::WrongOwner);
|
|
}
|
|
}
|
|
match state.as_str() {
|
|
"active" => {
|
|
sqlx::query(
|
|
"UPDATE listings SET state = 'cancelled' \
|
|
WHERE listing_id = ? AND state = 'active'",
|
|
)
|
|
.bind(listing_id)
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
Ok(())
|
|
}
|
|
"sold" => Err(MarketError::Sold),
|
|
"cancelled" => Err(MarketError::Cancelled),
|
|
_ => Err(MarketError::Conflict),
|
|
}
|
|
}
|
|
.await;
|
|
match &outcome {
|
|
Ok(_) => {
|
|
sqlx::query("COMMIT")
|
|
.execute(&mut *conn)
|
|
.await
|
|
.map_err(db)?;
|
|
}
|
|
Err(_) => {
|
|
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
|
|
}
|
|
}
|
|
outcome
|
|
}
|
|
|
|
/// RE-LIST an existing auction row for the same item: reset the clock to now
|
|
/// and take the new prices/duration.
|
|
///
|
|
/// FIFA 17 relists by sending a fresh `ISStart` POST for an item that already
|
|
/// has a listing row, so the primary-key conflict is EXPECTED and means
|
|
/// "relist", not "error". Treating that conflict as success is how a relist
|
|
/// silently did nothing: the client was acked while the stale, already-expired
|
|
/// row kept its old `created_at` and stayed expired.
|
|
///
|
|
/// Only an `active` (including aged-out) or `cancelled` row may be relisted. A
|
|
/// `sold` or `reserved` row is NEVER resurrected — the card is gone or in
|
|
/// flight, and re-opening that auction would sell a card twice.
|
|
pub async fn relist_listing(
|
|
&self,
|
|
listing_id: &str,
|
|
start_price: i64,
|
|
buy_now_price: i64,
|
|
duration_secs: Option<i64>,
|
|
item_json: Option<&str>,
|
|
) -> Result<Listing, MarketError> {
|
|
let created_at = now_millis();
|
|
let affected = sqlx::query(
|
|
"UPDATE listings SET state = 'active', created_at = ?, start_price = ?, \
|
|
buy_now_price = ?, duration_secs = ?, item_json = COALESCE(?, item_json) \
|
|
WHERE listing_id = ? AND state IN ('active', 'cancelled')",
|
|
)
|
|
.bind(&created_at)
|
|
.bind(start_price)
|
|
.bind(buy_now_price)
|
|
.bind(duration_secs)
|
|
.bind(item_json)
|
|
.bind(listing_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(db)?
|
|
.rows_affected();
|
|
if affected == 0 {
|
|
// Either no such row, or it is sold/reserved and must not be revived.
|
|
return Err(match self.get_listing(listing_id).await {
|
|
Ok(l) if l.state == "sold" => MarketError::Sold,
|
|
Ok(_) => MarketError::Conflict,
|
|
Err(e) => e,
|
|
});
|
|
}
|
|
self.get_listing(listing_id).await
|
|
}
|
|
|
|
/// Cancel any `active` listing held by a Core owned item, returning how many
|
|
/// rows were cancelled (0 when the item has no live auction).
|
|
///
|
|
/// This is the RETURN-TO-CLUB transition: the client sends a pile move for an
|
|
/// expired transfer-list item, and the auction that put it there has to end with
|
|
/// it. Without this the pile says `club` while the listing row stays `active`,
|
|
/// so the card is still filtered out of `/club` AND still rendered in the
|
|
/// Transfer List — the item appears not to move at all.
|
|
///
|
|
/// Deliberately scoped to `active`: a `reserved` row is mid-sale and a `sold`
|
|
/// row is already gone, and cancelling either would let a card be both sold and
|
|
/// returned.
|
|
pub async fn cancel_active_for_core_item(
|
|
&self,
|
|
core_item_id: &str,
|
|
) -> Result<u64, MarketError> {
|
|
Ok(sqlx::query(
|
|
"UPDATE listings SET state = 'cancelled' \
|
|
WHERE core_item_id = ? AND state = 'active'",
|
|
)
|
|
.bind(core_item_id)
|
|
.execute(&self.pool)
|
|
.await
|
|
.map_err(db)?
|
|
.rows_affected())
|
|
}
|
|
|
|
pub async fn has_active_for_core_item(&self, core_item_id: &str) -> Result<bool, MarketError> {
|
|
sqlx::query_scalar(
|
|
"SELECT EXISTS( \
|
|
SELECT 1 FROM listings WHERE core_item_id = ? AND state = 'active' \
|
|
)",
|
|
)
|
|
.bind(core_item_id)
|
|
.fetch_one(&self.pool)
|
|
.await
|
|
.map_err(db)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
/// A unique temp DB path that deletes its file (and WAL/SHM sidecars) on
|
|
/// drop. Holding the guard keeps the file alive across store reopens.
|
|
struct TempDb(String);
|
|
impl TempDb {
|
|
fn new() -> Self {
|
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
let n = N.fetch_add(1, Ordering::SeqCst);
|
|
let path =
|
|
std::env::temp_dir().join(format!("ofut-market-{}-{n}.db", std::process::id()));
|
|
TempDb(path.to_string_lossy().into_owned())
|
|
}
|
|
fn path(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
impl Drop for TempDb {
|
|
fn drop(&mut self) {
|
|
for suffix in ["", "-wal", "-shm"] {
|
|
let _ = std::fs::remove_file(format!("{}{suffix}", self.0));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relist_resets_the_clock_and_takes_the_new_prices() {
|
|
// FIFA 17 relists by re-sending ISStart for an item that already has a row,
|
|
// so the PK conflict is the relist path. Before this existed the conflict was
|
|
// acked as success and the stale expired row kept its old created_at, so the
|
|
// card never came back to the market.
|
|
let (store, _d) = temp_store().await;
|
|
let first = seed(&store, "900000001").await;
|
|
// Age it out by rewriting created_at to well past its default duration.
|
|
let stale = (now_secs() - DEFAULT_DURATION_SECS - 600) * 1000;
|
|
sqlx::query("UPDATE listings SET created_at = ? WHERE listing_id = ?")
|
|
.bind(stale.to_string())
|
|
.bind("900000001")
|
|
.execute(&store.pool)
|
|
.await
|
|
.unwrap();
|
|
let expired = store.get_listing("900000001").await.unwrap();
|
|
assert_eq!(
|
|
expired.expires_in_secs(now_secs()),
|
|
0,
|
|
"precondition: the listing has run out"
|
|
);
|
|
|
|
let relisted = store
|
|
.relist_listing("900000001", 250, 5000, Some(10_800), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(relisted.state, "active");
|
|
assert_eq!(relisted.start_price, 250, "new start price applied");
|
|
assert_eq!(relisted.buy_now_price, 5000, "new buy-now applied");
|
|
assert_eq!(relisted.duration_secs, Some(10_800));
|
|
assert!(
|
|
relisted.expires_in_secs(now_secs()) > 0,
|
|
"the clock actually restarted"
|
|
);
|
|
assert_ne!(
|
|
relisted.created_at, expired.created_at,
|
|
"created_at moved forward"
|
|
);
|
|
// The snapshot is preserved when the relist does not supply a new one.
|
|
assert_eq!(relisted.item_json, first.item_json);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relist_never_revives_a_sold_or_reserved_auction() {
|
|
// Re-opening a sold auction would sell the same card twice.
|
|
let (store, _d) = temp_store().await;
|
|
seed(&store, "900000001").await;
|
|
assert!(store.reserve_listing("900000001").await.unwrap());
|
|
assert!(
|
|
matches!(
|
|
store.relist_listing("900000001", 1, 2, None, None).await,
|
|
Err(MarketError::Conflict)
|
|
),
|
|
"a reserved (in-flight) auction is not relistable"
|
|
);
|
|
store.complete_sale("900000001").await.unwrap();
|
|
assert!(
|
|
matches!(
|
|
store.relist_listing("900000001", 1, 2, None, None).await,
|
|
Err(MarketError::Sold)
|
|
),
|
|
"a sold auction is never resurrected"
|
|
);
|
|
assert_eq!(store.get_listing("900000001").await.unwrap().state, "sold");
|
|
|
|
// A cancelled listing IS relistable (the card came back to the pile).
|
|
seed(&store, "900000002").await;
|
|
store.cancel_listing("900000002", None).await.unwrap();
|
|
let back = store
|
|
.relist_listing("900000002", 300, 900, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(back.state, "active");
|
|
assert_eq!(back.start_price, 300);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relist_of_a_missing_row_is_not_found() {
|
|
let (store, _d) = temp_store().await;
|
|
assert!(matches!(
|
|
store.relist_listing("900000999", 1, 2, None, None).await,
|
|
Err(MarketError::NotFound)
|
|
));
|
|
}
|
|
|
|
async fn temp_store() -> (MarketStore, TempDb) {
|
|
let db = TempDb::new();
|
|
let store = MarketStore::open(db.path()).await.unwrap();
|
|
(store, db)
|
|
}
|
|
|
|
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
|
store
|
|
.create_listing(
|
|
id,
|
|
"card_pl_001",
|
|
None,
|
|
None,
|
|
None,
|
|
900,
|
|
2500,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_get_query_roundtrip() {
|
|
let (store, _d) = temp_store().await;
|
|
let created = seed(&store, "900000001").await;
|
|
assert_eq!(created.state, "active");
|
|
assert_eq!(created.buy_now_price, 2500);
|
|
|
|
let got = store.get_listing("900000001").await.unwrap();
|
|
assert_eq!(got, created);
|
|
|
|
assert!(matches!(
|
|
store.get_listing("nope").await,
|
|
Err(MarketError::NotFound)
|
|
));
|
|
|
|
let active = store.query_listings("active").await.unwrap();
|
|
assert_eq!(active.len(), 1);
|
|
assert!(store.query_listings("sold").await.unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_id_is_conflict() {
|
|
let (store, _d) = temp_store().await;
|
|
seed(&store, "900000001").await;
|
|
assert!(matches!(
|
|
store
|
|
.create_listing(
|
|
"900000001",
|
|
"card_pl_002",
|
|
None,
|
|
None,
|
|
None,
|
|
1,
|
|
2,
|
|
None,
|
|
None,
|
|
None
|
|
)
|
|
.await,
|
|
Err(MarketError::Conflict)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reserve_complete_lifecycle() {
|
|
let (store, _d) = temp_store().await;
|
|
seed(&store, "900000001").await;
|
|
assert!(store.reserve_listing("900000001").await.unwrap());
|
|
// Second reserve of a now-reserved listing loses.
|
|
assert!(!store.reserve_listing("900000001").await.unwrap());
|
|
store.complete_sale("900000001").await.unwrap();
|
|
assert_eq!(store.get_listing("900000001").await.unwrap().state, "sold");
|
|
// Completing again (not reserved) is a conflict.
|
|
assert!(matches!(
|
|
store.complete_sale("900000001").await,
|
|
Err(MarketError::Conflict)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rollback_restores_active() {
|
|
let (store, _d) = temp_store().await;
|
|
seed(&store, "900000001").await;
|
|
assert!(store.reserve_listing("900000001").await.unwrap());
|
|
store.rollback_reservation("900000001").await.unwrap();
|
|
assert_eq!(
|
|
store.get_listing("900000001").await.unwrap().state,
|
|
"active"
|
|
);
|
|
// Buyable again after rollback.
|
|
assert!(store.reserve_listing("900000001").await.unwrap());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancel_once_then_errors() {
|
|
let (store, _d) = temp_store().await;
|
|
seed(&store, "900000001").await;
|
|
store.cancel_listing("900000001", None).await.unwrap();
|
|
assert_eq!(
|
|
store.get_listing("900000001").await.unwrap().state,
|
|
"cancelled"
|
|
);
|
|
assert!(matches!(
|
|
store.cancel_listing("900000001", None).await,
|
|
Err(MarketError::Cancelled)
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancel_checks_owner() {
|
|
let (store, _d) = temp_store().await;
|
|
store
|
|
.create_listing(
|
|
"900000001",
|
|
"card_pl_001",
|
|
None,
|
|
None,
|
|
None,
|
|
900,
|
|
2500,
|
|
Some("alice"),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(matches!(
|
|
store.cancel_listing("900000001", Some("mallory")).await,
|
|
Err(MarketError::WrongOwner)
|
|
));
|
|
store
|
|
.cancel_listing("900000001", Some("alice"))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
store.get_listing("900000001").await.unwrap().state,
|
|
"cancelled"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cannot_reserve_sold_or_cancelled() {
|
|
let (store, _d) = temp_store().await;
|
|
seed(&store, "sold_one").await;
|
|
store.reserve_listing("sold_one").await.unwrap();
|
|
store.complete_sale("sold_one").await.unwrap();
|
|
assert!(!store.reserve_listing("sold_one").await.unwrap());
|
|
|
|
seed(&store, "cancel_one").await;
|
|
store.cancel_listing("cancel_one", None).await.unwrap();
|
|
assert!(!store.reserve_listing("cancel_one").await.unwrap());
|
|
}
|
|
|
|
/// Two tasks reserve the SAME active listing concurrently -> exactly one wins.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn two_reservers_exactly_one_wins() {
|
|
let (store, _d) = temp_store().await;
|
|
seed(&store, "900000001").await;
|
|
let store = Arc::new(store);
|
|
|
|
let a = {
|
|
let s = store.clone();
|
|
tokio::spawn(async move { s.reserve_listing("900000001").await.unwrap() })
|
|
};
|
|
let b = {
|
|
let s = store.clone();
|
|
tokio::spawn(async move { s.reserve_listing("900000001").await.unwrap() })
|
|
};
|
|
let (ra, rb) = (a.await.unwrap(), b.await.unwrap());
|
|
assert_ne!(ra, rb, "exactly one reserver must win");
|
|
assert!(ra || rb, "one reserver must win");
|
|
assert_eq!(
|
|
store.get_listing("900000001").await.unwrap().state,
|
|
"reserved"
|
|
);
|
|
}
|
|
|
|
/// State survives closing and reopening the store file (durable, not in-memory).
|
|
#[tokio::test]
|
|
async fn state_survives_reopen() {
|
|
let db = TempDb::new();
|
|
let path = db.path();
|
|
{
|
|
let store = MarketStore::open(path).await.unwrap();
|
|
store
|
|
.create_listing(
|
|
"900000001",
|
|
"card_pl_001",
|
|
Some("core-7"),
|
|
Some(100004617),
|
|
Some(169193),
|
|
900,
|
|
2500,
|
|
Some("alice"),
|
|
Some(r#"{"rating":84}"#),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
store
|
|
.cancel_listing("900000001", Some("alice"))
|
|
.await
|
|
.unwrap();
|
|
// pool dropped at end of scope
|
|
}
|
|
let reopened = MarketStore::open(path).await.unwrap();
|
|
let got = reopened.get_listing("900000001").await.unwrap();
|
|
assert_eq!(got.state, "cancelled");
|
|
assert_eq!(got.core_item_id.as_deref(), Some("core-7"));
|
|
assert_eq!(got.wire_item_id, Some(100004617));
|
|
assert_eq!(got.owner.as_deref(), Some("alice"));
|
|
assert_eq!(
|
|
got.item_json.as_deref(),
|
|
Some(r#"{"rating":84}"#),
|
|
"card snapshot survives reopen"
|
|
);
|
|
}
|
|
}
|