Fix race condition allowing multiple people to pick up the same shift (#19) (#20)

The pickup button handler used an unconditional put_item, so concurrent
clicks would both succeed with last-write-wins. Added claim_open_shift()
which uses a DynamoDB ConditionExpression to only write if the shift is
still OPEN. The button handler now returns an ephemeral "already taken"
message when the condition fails.

Closes #19
This commit is contained in:
Adam Moussa 2026-05-01 14:09:39 -04:00 • committed by GitHub
parent fe6b780e12
commit 1e90e0eaa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 29 additions and 2 deletions

View file

@ -124,7 +124,6 @@ def create_app(bot_token: str, signing_secret: str) -> App:
def handle_pickup_button(ack, body, client):
ack()
action_id = body["actions"][0]["action_id"]
# Parse action_id: pickup_2026-04-12 or pickup_2026-04-12_day
remainder = action_id.replace("pickup_", "")
if remainder.endswith("_day"):
date_str = remainder[:-4]
@ -145,7 +144,14 @@ def create_app(bot_token: str, signing_secret: str) -> App:
)
return
schedule.set_override(date_str, employee["extension"], employee["name"], shift_type)
claimed = schedule.claim_open_shift(date_str, employee["extension"], employee["name"], shift_type)
if not claimed:
client.chat_postEphemeral(
channel=channel_id,
user=user_id,
text=f"That shift on *{date_str}* was already picked up by someone else.",
)
return
if is_today(date_str) and shift_type == "night":
invoke_3cx_scheduler(employee["extension"])

View file

@ -73,6 +73,27 @@ class ShiftSchedule:
}
)
def claim_open_shift(self, date_str: str, extension: str, name: str, shift_type: str = "night") -> bool:
"""Atomically claim a shift only if it is currently OPEN.
Returns True if the claim succeeded, False if someone else already took it.
"""
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
try:
self.table.put_item(
Item={
"PK": "OVERRIDE",
"SK": sk,
"extension": extension,
"name": name,
},
ConditionExpression="extension = :open",
ExpressionAttributeValues={":open": "OPEN"},
)
return True
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
return False
def mark_open(self, date_str: str, shift_type: str = "night") -> None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.put_item(