diff --git a/.gitignore b/.gitignore index ff41b4b5a..4679cd5e3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ gha-creds-*.json ## vscode settings /.vscode +*.db.init.lock diff --git a/changelog.d/local-db-init-race.fixed.md b/changelog.d/local-db-init-race.fixed.md new file mode 100644 index 000000000..7ad389d6c --- /dev/null +++ b/changelog.d/local-db-init-race.fixed.md @@ -0,0 +1 @@ +Serialize local sqlite initialization under a file lock so concurrent gunicorn worker boots cannot race the seed inserts or observe a partially initialized database. diff --git a/policyengine_api/data/data.py b/policyengine_api/data/data.py index 958021985..83f7eb820 100644 --- a/policyengine_api/data/data.py +++ b/policyengine_api/data/data.py @@ -1,3 +1,4 @@ +import fcntl import sqlite3 from policyengine_api.constants import REPO, COUNTRY_PACKAGE_VERSIONS from policyengine_api.utils import hash_object @@ -106,8 +107,20 @@ def __init__( if local: # Local development uses a sqlite database. self.db_url = REPO / "policyengine_api" / "data" / "policyengine.db" - if initialize or not Path(self.db_url).exists(): - self.initialize() + # Serialize the exists-check + initialize under an exclusive file + # lock: with multiple gunicorn workers importing concurrently on a + # fresh instance, both can otherwise pass the exists() check and + # race initialize() (seed INSERTs collide -> worker dies at boot), + # or one can observe a created-but-unseeded file and skip + # initialization entirely. + lock_path = str(self.db_url) + ".init.lock" + with open(lock_path, "w") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + if initialize or not Path(self.db_url).exists(): + self.initialize() + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) else: self._create_pool() if initialize: