syncstorage-rs/tools/tokenserver/test_database.py
Taddes 8995db6268
Some checks are pending
Glean probe-scraper / glean-probe-scraper (push) Waiting to run
feat: ruff for python lint and format (#1742)
feat: ruff for python lint and format
2025-08-12 20:26:55 -04:00

473 lines
22 KiB
Python

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.
import time
import unittest
from collections import defaultdict
from database import MAX_GENERATION, Database
from util import get_timestamp
class TestDatabase(unittest.TestCase):
def setUp(self):
super(TestDatabase, self).setUp()
self.database = Database()
# Start each test with a blank slate.
cursor = self.database._execute_sql(("DELETE FROM users"), ())
cursor.close()
cursor = self.database._execute_sql(("DELETE FROM nodes"), ())
cursor.close()
cursor = self.database._execute_sql(("DELETE FROM services"), ())
cursor.close()
self.database.add_service("sync-1.5", r"{node}/1.5/{uid}")
self.database.add_node("https://phx12", 100)
def tearDown(self):
super(TestDatabase, self).tearDown()
# And clean up at the end, for good measure.
cursor = self.database._execute_sql(("DELETE FROM users"), ())
cursor.close()
cursor = self.database._execute_sql(("DELETE FROM nodes"), ())
cursor.close()
cursor = self.database._execute_sql(("DELETE FROM services"), ())
cursor.close()
self.database.close()
def test_node_allocation(self):
user = self.database.get_user("test1@example.com")
self.assertEqual(user, None)
user = self.database.allocate_user("test1@example.com")
wanted = "https://phx12"
self.assertEqual(user["node"], wanted)
user = self.database.get_user("test1@example.com")
self.assertEqual(user["node"], wanted)
def test_allocation_to_least_loaded_node(self):
self.database.add_node("https://phx13", 100)
user1 = self.database.allocate_user("test1@mozilla.com")
user2 = self.database.allocate_user("test2@mozilla.com")
self.assertNotEqual(user1["node"], user2["node"])
def test_allocation_is_not_allowed_to_downed_nodes(self):
self.database.update_node("https://phx12", downed=True)
with self.assertRaises(Exception):
self.database.allocate_user("test1@mozilla.com")
def test_allocation_is_not_allowed_to_backoff_nodes(self):
self.database.update_node("https://phx12", backoff=True)
with self.assertRaises(Exception):
self.database.allocate_user("test1@mozilla.com")
def test_update_generation_number(self):
user = self.database.allocate_user("test1@example.com")
self.assertEqual(user["generation"], 0)
self.assertEqual(user["client_state"], "")
orig_uid = user["uid"]
orig_node = user["node"]
# Changing generation should leave other properties unchanged.
self.database.update_user(user, generation=42)
self.assertEqual(user["uid"], orig_uid)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 42)
self.assertEqual(user["client_state"], "")
user = self.database.get_user("test1@example.com")
self.assertEqual(user["uid"], orig_uid)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 42)
self.assertEqual(user["client_state"], "")
# It's not possible to move generation number backwards.
self.database.update_user(user, generation=17)
self.assertEqual(user["uid"], orig_uid)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 42)
self.assertEqual(user["client_state"], "")
user = self.database.get_user("test1@example.com")
self.assertEqual(user["uid"], orig_uid)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 42)
self.assertEqual(user["client_state"], "")
def test_update_client_state(self):
user = self.database.allocate_user("test1@example.com")
self.assertEqual(user["generation"], 0)
self.assertEqual(user["client_state"], "")
self.assertEqual(set(user["old_client_states"]), set(()))
seen_uids = set((user["uid"],))
orig_node = user["node"]
# Changing client-state allocates a new userid.
self.database.update_user(user, client_state="aaaa")
self.assertTrue(user["uid"] not in seen_uids)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 0)
self.assertEqual(user["client_state"], "aaaa")
self.assertEqual(set(user["old_client_states"]), set(("",)))
user = self.database.get_user("test1@example.com")
self.assertTrue(user["uid"] not in seen_uids)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 0)
self.assertEqual(user["client_state"], "aaaa")
self.assertEqual(set(user["old_client_states"]), set(("",)))
seen_uids.add(user["uid"])
# It's possible to change client-state and generation at once.
self.database.update_user(user, client_state="bbbb", generation=12)
self.assertTrue(user["uid"] not in seen_uids)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 12)
self.assertEqual(user["client_state"], "bbbb")
self.assertEqual(set(user["old_client_states"]), set(("", "aaaa")))
user = self.database.get_user("test1@example.com")
self.assertTrue(user["uid"] not in seen_uids)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 12)
self.assertEqual(user["client_state"], "bbbb")
self.assertEqual(set(user["old_client_states"]), set(("", "aaaa")))
# You can't got back to an old client_state.
orig_uid = user["uid"]
with self.assertRaises(Exception):
self.database.update_user(user, client_state="aaaa")
user = self.database.get_user("test1@example.com")
self.assertEqual(user["uid"], orig_uid)
self.assertEqual(user["node"], orig_node)
self.assertEqual(user["generation"], 12)
self.assertEqual(user["client_state"], "bbbb")
self.assertEqual(set(user["old_client_states"]), set(("", "aaaa")))
def test_user_retirement(self):
self.database.allocate_user("test@mozilla.com")
user1 = self.database.get_user("test@mozilla.com")
self.database.retire_user("test@mozilla.com")
user2 = self.database.get_user("test@mozilla.com")
self.assertTrue(user2["generation"] > user1["generation"])
def test_cleanup_of_old_records(self):
# Create 6 user records for the first user.
# Do a sleep halfway through so we can test use of grace period.
email1 = "test1@mozilla.com"
user1 = self.database.allocate_user(email1)
# We have to sleep between every user create/update operation: if two
# users are created with the same timestamp, it can lead to a
# situation where two active user records exist for a single email.
time.sleep(0.1)
self.database.update_user(user1, client_state="aaaa")
time.sleep(0.1)
self.database.update_user(user1, client_state="bbbb")
time.sleep(0.1)
self.database.update_user(user1, client_state="cccc")
time.sleep(0.1)
break_time = time.time()
time.sleep(0.1)
self.database.update_user(user1, client_state="dddd")
time.sleep(0.1)
self.database.update_user(user1, client_state="eeee")
time.sleep(0.1)
records = list(self.database.get_user_records(email1))
self.assertEqual(len(records), 6)
# Create 3 user records for the second user.
email2 = "test2@mozilla.com"
user2 = self.database.allocate_user(email2)
time.sleep(0.1)
self.database.update_user(user2, client_state="aaaa")
time.sleep(0.1)
self.database.update_user(user2, client_state="bbbb")
time.sleep(0.1)
records = list(self.database.get_user_records(email2))
self.assertEqual(len(records), 3)
# That should be a total of 7 old records.
old_records = list(self.database.get_old_user_records(0))
self.assertEqual(len(old_records), 7)
# And with max_offset of 3, the first record should be id 4
old_records = list(self.database.get_old_user_records(0, 100, 3))
# The 'limit' parameter should be respected.
old_records = list(self.database.get_old_user_records(0, 2))
self.assertEqual(len(old_records), 2)
# The default grace period is too big to pick them up.
old_records = list(self.database.get_old_user_records())
self.assertEqual(len(old_records), 0)
# The grace period can select a subset of the records.
grace = time.time() - break_time
old_records = list(self.database.get_old_user_records(grace))
self.assertEqual(len(old_records), 3)
# Old records can be successfully deleted:
for record in old_records:
self.database.delete_user_record(record.uid)
old_records = list(self.database.get_old_user_records(0))
self.assertEqual(len(old_records), 4)
def test_node_reassignment_when_records_are_replaced(self):
self.database.allocate_user(
"test@mozilla.com", generation=42, keys_changed_at=12, client_state="aaaa"
)
user1 = self.database.get_user("test@mozilla.com")
self.database.replace_user_records("test@mozilla.com")
user2 = self.database.get_user("test@mozilla.com")
# They should have got a new uid.
self.assertNotEqual(user2["uid"], user1["uid"])
# But their account metadata should have been preserved.
self.assertEqual(user2["generation"], user1["generation"])
self.assertEqual(user2["keys_changed_at"], user1["keys_changed_at"])
self.assertEqual(user2["client_state"], user1["client_state"])
def test_node_reassignment_not_done_for_retired_users(self):
self.database.allocate_user(
"test@mozilla.com", generation=42, client_state="aaaa"
)
user1 = self.database.get_user("test@mozilla.com")
self.database.retire_user("test@mozilla.com")
user2 = self.database.get_user("test@mozilla.com")
self.assertEqual(user2["uid"], user1["uid"])
self.assertEqual(user2["generation"], MAX_GENERATION)
self.assertEqual(user2["client_state"], user2["client_state"])
def test_recovery_from_racy_record_creation(self):
timestamp = get_timestamp()
# Simulate race for forcing creation of two rows with same timestamp.
user1 = self.database.allocate_user("test@mozilla.com", timestamp=timestamp)
user2 = self.database.allocate_user("test@mozilla.com", timestamp=timestamp)
self.assertNotEqual(user1["uid"], user2["uid"])
# Neither is marked replaced initially.
old_records = list(self.database.get_old_user_records(0))
self.assertEqual(len(old_records), 0)
# Reading current details will detect the problem and fix it.
self.database.get_user("test@mozilla.com")
old_records = list(self.database.get_old_user_records(0))
self.assertEqual(len(old_records), 1)
def test_that_race_recovery_respects_generation_number_monotonicity(self):
timestamp = get_timestamp()
# Simulate race between clients with different generation numbers,
# in which the out-of-date client gets a higher timestamp.
user1 = self.database.allocate_user(
"test@mozilla.com", generation=1, timestamp=timestamp
)
user2 = self.database.allocate_user(
"test@mozilla.com", generation=2, timestamp=timestamp - 1
)
self.assertNotEqual(user1["uid"], user2["uid"])
# Reading current details should promote the higher-generation one.
user = self.database.get_user("test@mozilla.com")
self.assertEqual(user["generation"], 2)
self.assertEqual(user["uid"], user2["uid"])
# And the other record should get marked as replaced.
old_records = list(self.database.get_old_user_records(0))
self.assertEqual(len(old_records), 1)
def test_node_reassignment_and_removal(self):
NODE1 = "https://phx12"
NODE2 = "https://phx13"
# note that NODE1 is created by default for all tests.
self.database.add_node(NODE2, 100)
# Assign four users, we should get two on each node.
user1 = self.database.allocate_user("test1@mozilla.com")
user2 = self.database.allocate_user("test2@mozilla.com")
user3 = self.database.allocate_user("test3@mozilla.com")
user4 = self.database.allocate_user("test4@mozilla.com")
node_counts = defaultdict(lambda: 0)
for user in (user1, user2, user3, user4):
node_counts[user["node"]] += 1
self.assertEqual(node_counts[NODE1], 2)
self.assertEqual(node_counts[NODE2], 2)
# Clear the assignments for NODE1, and re-assign.
# The users previously on NODE1 should balance across both nodes,
# giving 1 on NODE1 and 3 on NODE2.
self.database.unassign_node(NODE1)
node_counts = defaultdict(lambda: 0)
for user in (user1, user2, user3, user4):
new_user = self.database.get_user(user["email"])
if user["node"] == NODE2:
self.assertEqual(new_user["node"], NODE2)
node_counts[new_user["node"]] += 1
self.assertEqual(node_counts[NODE1], 1)
self.assertEqual(node_counts[NODE2], 3)
# Remove NODE2. Everyone should wind up on NODE1.
self.database.remove_node(NODE2)
for user in (user1, user2, user3, user4):
new_user = self.database.get_user(user["email"])
self.assertEqual(new_user["node"], NODE1)
# The old users records pointing to NODE2 should have a NULL 'node'
# property since it has been removed from the db.
null_node_count = 0
for row in self.database.get_old_user_records(0):
if row.node is None:
null_node_count += 1
else:
self.assertEqual(row.node, NODE1)
self.assertEqual(null_node_count, 3)
def test_that_race_recovery_respects_generation_after_reassignment(self):
timestamp = get_timestamp()
# Simulate race between clients with different generation numbers,
# in which the out-of-date client gets a higher timestamp.
user1 = self.database.allocate_user(
"test@mozilla.com", generation=1, timestamp=timestamp
)
user2 = self.database.allocate_user(
"test@mozilla.com", generation=2, timestamp=timestamp - 1
)
self.assertNotEqual(user1["uid"], user2["uid"])
# Force node re-assignment by marking all records as replaced.
self.database.replace_user_records("test@mozilla.com", timestamp=timestamp + 1)
# The next client to show up should get a new assignment, marked
# with the correct generation number.
user = self.database.get_user("test@mozilla.com")
self.assertEqual(user["generation"], 2)
self.assertNotEqual(user["uid"], user1["uid"])
self.assertNotEqual(user["uid"], user2["uid"])
def test_that_we_can_allocate_users_to_a_specific_node(self):
node = "https://phx13"
self.database.add_node(node, 50)
# The new node is not selected by default, because of lower capacity.
user = self.database.allocate_user("test1@mozilla.com")
self.assertNotEqual(user["node"], node)
# But we can force it using keyword argument.
user = self.database.allocate_user("test2@mozilla.com", node=node)
self.assertEqual(user["node"], node)
def test_that_we_can_move_users_to_a_specific_node(self):
node = "https://phx13"
self.database.add_node(node, 50)
# The new node is not selected by default, because of lower capacity.
user = self.database.allocate_user("test@mozilla.com")
self.assertNotEqual(user["node"], node)
# But we can move them there explicitly using keyword argument.
self.database.update_user(user, node=node)
self.assertEqual(user["node"], node)
# Sanity-check by re-reading it from the db.
user = self.database.get_user("test@mozilla.com")
self.assertEqual(user["node"], node)
# Check that it properly respects client-state and generation.
self.database.update_user(user, generation=12)
self.database.update_user(user, client_state="XXX")
self.database.update_user(
user, generation=42, client_state="YYY", node="https://phx12"
)
self.assertEqual(user["node"], "https://phx12")
self.assertEqual(user["generation"], 42)
self.assertEqual(user["client_state"], "YYY")
self.assertEqual(sorted(user["old_client_states"]), ["", "XXX"])
# Sanity-check by re-reading it from the db.
user = self.database.get_user("test@mozilla.com")
self.assertEqual(user["node"], "https://phx12")
self.assertEqual(user["generation"], 42)
self.assertEqual(user["client_state"], "YYY")
self.assertEqual(sorted(user["old_client_states"]), ["", "XXX"])
def test_that_record_cleanup_frees_slots_on_the_node(self):
node = "https://phx12"
self.database.update_node(node, capacity=10, available=1, current_load=9)
# We should only be able to allocate one more user to that node.
user = self.database.allocate_user("test1@mozilla.com")
self.assertEqual(user["node"], node)
with self.assertRaises(Exception):
self.database.allocate_user("test2@mozilla.com")
# But when we clean up the user's record, it frees up the slot.
self.database.retire_user("test1@mozilla.com")
self.database.delete_user_record(user["uid"])
user = self.database.allocate_user("test2@mozilla.com")
self.assertEqual(user["node"], node)
def test_gradual_release_of_node_capacity(self):
node1 = "https://phx12"
self.database.update_node(node1, capacity=8, available=1, current_load=4)
node2 = "https://phx13"
self.database.add_node(node2, capacity=6, available=1, current_load=4)
# Two allocations should succeed without update, one on each node.
user = self.database.allocate_user("test1@mozilla.com")
self.assertEqual(user["node"], node1)
user = self.database.allocate_user("test2@mozilla.com")
self.assertEqual(user["node"], node2)
# The next allocation attempt will release 10% more capacity,
# which is one more slot for each node.
user = self.database.allocate_user("test3@mozilla.com")
self.assertEqual(user["node"], node1)
user = self.database.allocate_user("test4@mozilla.com")
self.assertEqual(user["node"], node2)
# Now node2 is full, so further allocations all go to node1.
user = self.database.allocate_user("test5@mozilla.com")
self.assertEqual(user["node"], node1)
user = self.database.allocate_user("test6@mozilla.com")
self.assertEqual(user["node"], node1)
# Until it finally reaches capacity.
with self.assertRaises(Exception):
self.database.allocate_user("test7@mozilla.com")
def test_count_users(self):
user = self.database.allocate_user("test1@example.com")
self.assertEqual(self.database.count_users(), 1)
old_timestamp = get_timestamp()
time.sleep(0.01)
# Adding users increases the count.
user = self.database.allocate_user("rfkelly@mozilla.com")
self.assertEqual(self.database.count_users(), 2)
# Updating a user doesn't change the count.
self.database.update_user(user, client_state="aaaa")
self.assertEqual(self.database.count_users(), 2)
# Looking back in time doesn't count newer users.
self.assertEqual(self.database.count_users(old_timestamp), 1)
# Retiring a user decreases the count.
self.database.retire_user("test1@example.com")
self.assertEqual(self.database.count_users(), 1)
def test_first_seen_at(self):
EMAIL = "test1@example.com"
user0 = self.database.allocate_user(EMAIL)
user1 = self.database.get_user(EMAIL)
self.assertEqual(user1["uid"], user0["uid"])
self.assertEqual(user1["first_seen_at"], user0["first_seen_at"])
# It should stay consistent if we re-allocate the user's node.
time.sleep(0.1)
self.database.update_user(user1, client_state="aaaa")
user2 = self.database.get_user(EMAIL)
self.assertNotEqual(user2["uid"], user0["uid"])
self.assertEqual(user2["first_seen_at"], user0["first_seen_at"])
# Until we purge their old node-assignment records.
self.database.delete_user_record(user0["uid"])
user3 = self.database.get_user(EMAIL)
self.assertEqual(user3["uid"], user2["uid"])
self.assertNotEqual(user3["first_seen_at"], user2["first_seen_at"])
def test_build_old_range(self):
params = dict()
sql = self.database._build_old_user_query(None, params)
self.assertTrue(sql.text.find("uid > :start") < 0)
self.assertTrue(sql.text.find("uid < :end") < 0)
self.assertIsNone(params.get("start"))
self.assertIsNone(params.get("end"))
params = dict()
rrange = (None, "abcd")
sql = self.database._build_old_user_query(rrange, params)
self.assertTrue(sql.text.find("uid > :start") < 0)
self.assertTrue(sql.text.find("uid < :end") > 0)
self.assertIsNone(params.get("start"))
self.assertEqual(params.get("end"), rrange[1])
params = dict()
rrange = ("1234", "abcd")
sql = self.database._build_old_user_query(rrange, params)
self.assertTrue(sql.text.find("uid > :start") > 0)
self.assertTrue(sql.text.find("uid < :end") > 0)
self.assertEqual(params.get("start"), rrange[0])
self.assertEqual(params.get("end"), rrange[1])