Skip to content

Commit 97917e0

Browse files
authored
Merge pull request #7699 from freedomofpress/frozen-snowflakes
feat(`/data`): enforce and document typing and interop considerations for snowflake IDs
2 parents 2023482 + f9d207e commit 97917e0

File tree

3 files changed

+61
-1
lines changed

3 files changed

+61
-1
lines changed

API2.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ Note over Client: Global version uvwxyz
8787
end
8888
```
8989

90+
#### Consistency
91+
9092
This diagram implies single-round-trip consistency. To make that expectation
9193
explicit:
9294

@@ -98,3 +100,18 @@ E_n\}$; and
98100
3. $S$ accepts $BR$ as valid and successfully processes all $E_i$; then
99101

100102
4. $C$'s index SHOULD match $S$'s index without a subsequent synchronization.
103+
104+
#### Snowflake IDs
105+
106+
The `Event.id` field is a "snowflake ID", which a client can generate using a
107+
library like [`@sapphire/snowflake`]. To avoid precision-loss problems:
108+
109+
- A client SHOULD store its IDs as opaque strings and sort them
110+
lexicographically.
111+
112+
- A client MUST encode its IDs on the wire as JSON strings.
113+
114+
- The server MAY convert IDs it receives to integers, but only for sorting and
115+
testing equality.
116+
117+
[`@sapphire/snowflake`]: https://www.npmjs.com/package/@sapphire/snowflake

securedrop/journalist_app/api2/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def data() -> Response:
113113
)
114114

115115
# Process events in snowflake order.
116-
for event in sorted(events, key=lambda e: e.id):
116+
for event in sorted(events, key=lambda e: int(e.id)):
117117
result = handler.process(event)
118118
for uuid, source in result.sources.items():
119119
response.sources[uuid] = source.to_api_v2() if source is not None else None

securedrop/tests/test_journalist_api2.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,3 +826,46 @@ def test_api2_idempotence_period(journalist_app):
826826
"""
827827

828828
assert journalist_app.config["SESSION_LIFETIME"] <= api2.events.IDEMPOTENCE_PERIOD
829+
830+
831+
def test_api2_event_ordering(journalist_app, journalist_api_token, test_files):
832+
"""
833+
If two `item_deleted` events for the same item arrive out of order, the
834+
numerically later event must observe that the item is already gone by the
835+
time it's processed.
836+
"""
837+
with journalist_app.test_client() as app:
838+
index = app.get(
839+
url_for("api2.index"),
840+
headers=get_api_headers(journalist_api_token),
841+
)
842+
assert index.status_code == 200
843+
844+
submission_uuid = test_files["submissions"][0].uuid
845+
item_version = index.json["items"][submission_uuid]
846+
847+
# Two `item_deleted` events targeting the same item:
848+
e2 = Event(
849+
id="3419026047977394171",
850+
target=ItemTarget(item_uuid=submission_uuid, version=item_version),
851+
type=EventType.ITEM_DELETED,
852+
)
853+
e1 = Event(
854+
id="3419026047977394170", # client sends as string; server orders as integer
855+
target=ItemTarget(item_uuid=submission_uuid, version=item_version),
856+
type=EventType.ITEM_DELETED,
857+
)
858+
859+
# Send them out of order:
860+
resp = app.post(
861+
url_for("api2.data"),
862+
json={"events": [asdict(e2), asdict(e1)]},
863+
headers=get_api_headers(journalist_api_token),
864+
)
865+
assert resp.status_code == 200
866+
867+
# Event `1` (sent second, processed first) deletes the item.
868+
assert resp.json["events"]["3419026047977394170"] == [200, None]
869+
870+
# Event "2" (sent first, processed second) finds it missing.
871+
assert resp.json["events"]["3419026047977394171"][0] == 410

0 commit comments

Comments
 (0)