Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def get_secret(env_var: str, default: str = None) -> str | None:


MIN_FRONTEND_VERSION = 71
BACKEND_VERSION = 120
BACKEND_VERSION = 121

APP_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_DIR = os.path.dirname(APP_DIR)
Expand Down
27 changes: 24 additions & 3 deletions backend/app/controller/shoppinglist/schemas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
from marshmallow import fields, Schema, EXCLUDE
from datetime import datetime, timezone

from marshmallow import fields, Schema, EXCLUDE, ValidationError


def _validate_epoch_ms(value: int) -> None:
"""Reject timestamps that are non-positive or more than 5 minutes in the future."""
if value <= 0:
raise ValidationError("Timestamp must be a positive integer.")
max_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + 300_000 # +5 min
if value > max_ms:
raise ValidationError("Timestamp is too far in the future.")


class GetShoppingLists(Schema):
Expand Down Expand Up @@ -42,24 +53,34 @@ class GetRecentItems(Schema):


class UpdateDescription(Schema):
class Meta:
unknown = EXCLUDE

description = fields.String(required=True)
client_timestamp = fields.Integer(validate=_validate_epoch_ms)


class RemoveItem(Schema):
class Meta:
unknown = EXCLUDE

item_id = fields.Integer(
required=True,
)
removed_at = fields.Integer()
removed_at = fields.Integer(validate=_validate_epoch_ms)


class RemoveItems(Schema):
class Meta:
unknown = EXCLUDE

class RecipeItem(Schema):
class Meta:
unknown = EXCLUDE

item_id = fields.Integer(
required=True,
)
removed_at = fields.Integer()
removed_at = fields.Integer(validate=_validate_epoch_ms)

items = fields.List(fields.Nested(RecipeItem))
60 changes: 49 additions & 11 deletions backend/app/controller/shoppinglist/shoppinglist_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from app.errors import NotFoundRequest, InvalidUsage
from datetime import datetime, timedelta, timezone
import app.util.description_merger as description_merger
import app.util.transaction_resolver as resolver
from app import socketio


Expand Down Expand Up @@ -128,21 +129,54 @@ def deleteShoppinglist(id):
@validate_args(UpdateDescription)
def updateItemDescription(args, id: int, item_id: int):
con = ShoppinglistItems.find_by_ids(id, item_id)
if not con:
shoppinglist = Shoppinglist.find_by_id(id)
item = Item.find_by_id(item_id)
if not item or not shoppinglist:
raise NotFoundRequest()
if shoppinglist.household_id != item.household_id:
raise InvalidUsage()
client_timestamp = args.get("client_timestamp")

shoppinglist_obj = Shoppinglist.find_by_id(id)
item = Item.find_by_id(item_id)
if not item or not shoppinglist_obj:
raise NotFoundRequest()
if shoppinglist_obj.household_id != item.household_id:
raise InvalidUsage()
shoppinglist_obj.checkAuthorized()

# Look up History DROPPED record for the resolver (needed when item is not on list)
most_recent_dropped = None
if con is None:
most_recent_dropped = History.find_most_recent_dropped(id, item_id)

result = resolver.resolve_update_description(
con, client_timestamp, most_recent_dropped
)

if result.resolution == resolver.Resolution.REJECT:
# Operation is stale — return success to the client (don't retry) but do nothing
return jsonify({"msg": "CONFLICT_REJECTED", "reason": result.reason})

if result.resolution == resolver.Resolution.NOOP:
# Item not on list and no History to update
return jsonify({"msg": "NOOP", "reason": result.reason})

if result.resolution == resolver.Resolution.UPDATE_HISTORY:
# Item was removed — update the History DROPPED record's description
most_recent_dropped.description = args["description"] or ""
most_recent_dropped.save()
return jsonify({"msg": "HISTORY_UPDATED", "reason": result.reason})

# Resolution.ACCEPT — proceed with the update (or create if not on list)
created = con is None
if con is None:
con = ShoppinglistItems()
con.shoppinglist = shoppinglist
con.shoppinglist = shoppinglist_obj
con.item = item
con.created_by = current_user.id
con.shoppinglist.checkAuthorized()

con.description = args["description"] or ""
resolver.set_updated_at(con, client_timestamp)
con.save()

if created:
History.create_added(shoppinglist_obj, item, con.description)

socketio.emit(
"shoppinglist_item:add",
{
Expand Down Expand Up @@ -378,14 +412,18 @@ def removeShoppinglistItemFunc(
if not item:
return None
con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id)
if not con:

# Use the transaction resolver to check if this remove should proceed
result = resolver.resolve_remove(con, removed_at)
if result.resolution != resolver.Resolution.ACCEPT:
return None

description = con.description
con.delete()

removed_at_datetime = None
if removed_at:
removed_at_datetime = datetime.fromtimestamp(removed_at / 1000, timezone.utc)
removed_at_datetime = resolver.epoch_ms_to_datetime(removed_at)

History.create_dropped(shoppinglist, item, description, removed_at_datetime)
return con
Expand Down
19 changes: 19 additions & 0 deletions backend/app/models/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,25 @@ def find_dropped_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]:
def find_by_shoppinglist_id(cls, shoppinglist_id: int) -> list[Self]:
return cls.query.filter(cls.shoppinglist_id == shoppinglist_id).all()

@classmethod
def find_most_recent_dropped(
cls, shoppinglist_id: int, item_id: int
) -> Self | None:
"""
Find the most recent History record with status=DROPPED for a given
shoppinglist + item combination. Used by the transaction resolver to
update the description on a removed item's History record.
"""
return (
cls.query.filter(
cls.shoppinglist_id == shoppinglist_id,
cls.item_id == item_id,
cls.status == Status.DROPPED,
)
.order_by(cls.created_at.desc())
.first()
)

@classmethod
def find_all(cls) -> list[Self]:
return cls.query.all()
Expand Down
208 changes: 208 additions & 0 deletions backend/app/util/transaction_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""
Transaction Resolver for Shopping List Conflict Resolution.

When clients go offline and queue operations, those operations may conflict
with changes made by other clients while they were disconnected. This module
provides guards for each mutation endpoint that evaluate whether a stale
client operation is still relevant against the current server state.

Core principle: each operation is evaluated individually against the current
server state + timestamps. The backend answers "is this operation still
relevant right now?" — not global event ordering.

When an operation wins (client_timestamp > updated_at), the server sets
updated_at = client_timestamp (not server receive time) so that subsequent
stale operations from other clients are ordered correctly against it.
"""

from __future__ import annotations

from datetime import datetime, timezone
from enum import Enum
from typing import NamedTuple, TYPE_CHECKING

if TYPE_CHECKING:
from app.models import ShoppinglistItems, History


class Resolution(Enum):
"""Outcome of a conflict resolution check."""

ACCEPT = "accept" # Operation should proceed normally
REJECT = "reject" # Operation is stale, skip it
NOOP = "noop" # Operation has no effect (item already gone, etc.)
UPDATE_HISTORY = "update_history" # Update a History record's description


class ResolveResult(NamedTuple):
"""Result of a conflict resolution check."""

resolution: Resolution
reason: str


def epoch_ms_to_datetime(epoch_ms: int) -> datetime:
"""Convert epoch milliseconds (as sent by Flutter clients) to UTC datetime."""
return datetime.fromtimestamp(epoch_ms / 1000, timezone.utc)


def _ensure_aware(dt: datetime) -> datetime:
"""
Ensure a datetime is timezone-aware (UTC).

SQLite stores datetimes as naive strings and returns them without tzinfo,
even when the original value was timezone-aware. PostgreSQL preserves
timezone info. This helper normalizes both cases to aware-UTC so
comparisons don't raise TypeError.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt


def resolve_remove(
con: "ShoppinglistItems | None",
client_timestamp: int | None,
) -> ResolveResult:
"""
Evaluate whether a remove operation should proceed.

Resolution table:
- Item not on list → NOOP
- Item on list, no ts → ACCEPT (backwards compat: no timestamp = always allow)
- Item on list, ts > updated_at → ACCEPT
- Item on list, ts <= updated_at → REJECT (item was modified after this remove was queued)

Args:
con: The ShoppinglistItems row, or None if item is not on the list.
client_timestamp: Epoch milliseconds from the client, or None for
legacy clients that don't send timestamps.

Returns:
ResolveResult with the resolution and a human-readable reason.
"""
if con is None:
return ResolveResult(Resolution.NOOP, "Item is not on the shopping list")

# No timestamp provided — backwards compatible, always allow
if client_timestamp is None:
return ResolveResult(Resolution.ACCEPT, "No client timestamp (legacy)")

client_dt = epoch_ms_to_datetime(client_timestamp)

updated_at = _ensure_aware(con.updated_at)

if client_dt > updated_at:
return ResolveResult(
Resolution.ACCEPT,
"Client timestamp is newer than last update",
)
else:
return ResolveResult(
Resolution.REJECT,
"Item was modified after this remove was queued "
f"(client_ts={client_dt.isoformat()}, updated_at={updated_at.isoformat()})",
)


def resolve_update_description(
con: "ShoppinglistItems | None",
client_timestamp: int | None,
most_recent_dropped: "History | None" = None,
) -> ResolveResult:
"""
Evaluate whether an update-description operation should proceed.

Resolution table:
- Item on list, no ts → ACCEPT (backwards compat)
- Item on list, ts > updated_at → ACCEPT
- Item on list, ts <= updated_at → REJECT
- Item NOT on list, History DROPPED exists with created_at < client_ts
→ UPDATE_HISTORY
- Item NOT on list, no matching History or History created_at >= client_ts
→ NOOP

Args:
con: The ShoppinglistItems row, or None if item is not on the list.
client_timestamp: Epoch milliseconds from the client, or None.
most_recent_dropped: The most recent History record with status=DROPPED
for this item on this shoppinglist (only needed when con is None).

Returns:
ResolveResult with the resolution and a human-readable reason.
"""
if con is not None:
# Item is currently on the shopping list
if client_timestamp is None:
return ResolveResult(Resolution.ACCEPT, "No client timestamp (legacy)")

client_dt = epoch_ms_to_datetime(client_timestamp)
updated_at = _ensure_aware(con.updated_at)

if client_dt > updated_at:
return ResolveResult(
Resolution.ACCEPT,
"Client timestamp is newer than last update",
)
else:
return ResolveResult(
Resolution.REJECT,
"Item was modified after this update was queued "
f"(client_ts={client_dt.isoformat()}, "
f"updated_at={updated_at.isoformat()})",
)
else:
# Item is NOT on the shopping list — check if we should update History
if client_timestamp is None:
# Backwards compat: no timestamp means this is a legacy client.
# The PUT endpoint historically acts as an upsert (add item to list
# if not present), so we ACCEPT to preserve that behavior.
return ResolveResult(
Resolution.ACCEPT,
"No client timestamp (legacy), upsert behavior",
)

if most_recent_dropped is None:
return ResolveResult(
Resolution.NOOP,
"Item not on list, no History DROPPED record found",
)

client_dt = epoch_ms_to_datetime(client_timestamp)
history_created_at = _ensure_aware(most_recent_dropped.created_at)

if history_created_at < client_dt:
return ResolveResult(
Resolution.UPDATE_HISTORY,
"Updating History DROPPED record description "
f"(history_created_at={history_created_at.isoformat()}, "
f"client_ts={client_dt.isoformat()})",
)
else:
return ResolveResult(
Resolution.NOOP,
"Edit predates the removal "
f"(history_created_at={history_created_at.isoformat()}, "
f"client_ts={client_dt.isoformat()})",
)


def set_updated_at(obj, client_timestamp: int | None) -> None:
"""
Set updated_at to the client timestamp if provided, bypassing SQLAlchemy's
onupdate auto-setter.

This is important for conflict resolution ordering: when an operation wins,
we record the client's timestamp (not the server receive time) so that
subsequent stale operations from other clients are correctly compared.

We use SQLAlchemy's attribute system to set the value, which will be
persisted on the next flush/commit. The onupdate lambda will be overridden
because we're explicitly setting the column value.

Args:
obj: Any model instance with an updated_at column.
client_timestamp: Epoch milliseconds, or None to use default behavior.
"""
if client_timestamp is not None:
obj.updated_at = epoch_ms_to_datetime(client_timestamp)
Loading
Loading