Params refactor, simplified (#2300)

* always c++

* Create C++ params class

* get works

* tests hang now

* passes tests

* cleanup string formatting

* handle interrupt in blocking read

* fix memory leak

* remove unused constructor

* Use delete_db_value directly

* Rename put -> write_db_value

* filename cleanup

* no semicolons in cython

* Update common/SConscript

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>

* add std::string version of delete_db_value

* This is handled

* cleanup encoding

* Add clear method to clear all

* add persistent params

* fix android build

* Should be called clear_all

* only import params when needed

* set params path on manager import

* recusrively create directories

* Fix function order

* cleanup mkdirp

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Comma Device <device@comma.ai>
old-commit-hash: 2e182e5c574ea1468678b5fd8d0597e34ed5898d
This commit is contained in:
Willem Melching
2020-10-13 16:23:23 +02:00
committed by GitHub
parent a331861873
commit 498ecbdf3c
20 changed files with 417 additions and 577 deletions
+8
View File
@@ -4,3 +4,11 @@ Import('env', 'cython_dependencies')
env.Command(['common_pyx.so', 'clock.cpp'],
cython_dependencies + ['common_pyx_setup.py', 'clock.pyx'],
"cd common && python3 common_pyx_setup.py build_ext --inplace")
# Build cython params module
env.Command(['params_pyx.so', 'params_pyx.cpp'],
cython_dependencies + [
'params_pyx_setup.py', 'params_pyx.pyx', 'params_pxd.pxd',
'#selfdrive/common/params.cc', '#selfdrive/common/params.h',
'#selfdrive/common/util.c', '#selfdrive/common/util.h'],
"cd common && python3 params_pyx_setup.py build_ext --inplace")
Executable → Regular
+4 -409
View File
@@ -1,409 +1,4 @@
#!/usr/bin/env python3
"""ROS has a parameter server, we have files.
The parameter store is a persistent key value store, implemented as a directory with a writer lock.
On Android, we store params under params_dir = /data/params. The writer lock is a file
"<params_dir>/.lock" taken using flock(), and data is stored in a directory symlinked to by
"<params_dir>/d".
Each key, value pair is stored as a file with named <key> with contents <value>, located in
<params_dir>/d/<key>
Readers of a single key can just open("<params_dir>/d/<key>") and read the file contents.
Readers who want a consistent snapshot of multiple keys should take the lock.
Writers should take the lock before modifying anything. Writers should also leave the DB in a
consistent state after a crash. The implementation below does this by copying all params to a temp
directory <params_dir>/<tmp>, then atomically symlinking <params_dir>/<d> to <params_dir>/<tmp>
before deleting the old <params_dir>/<d> directory.
Writers that only modify a single key can simply take the lock, then swap the corresponding value
file in place without messing with <params_dir>/d.
"""
import time
import os
import errno
import shutil
import fcntl
import tempfile
import threading
from enum import Enum
from common.basedir import PARAMS
def mkdirs_exists_ok(path):
try:
os.makedirs(path)
except OSError:
if not os.path.isdir(path):
raise
class TxType(Enum):
PERSISTENT = 1
CLEAR_ON_MANAGER_START = 2
CLEAR_ON_PANDA_DISCONNECT = 3
class UnknownKeyName(Exception):
pass
keys = {
"AccessToken": [TxType.CLEAR_ON_MANAGER_START],
"AthenadPid": [TxType.PERSISTENT],
"CalibrationParams": [TxType.PERSISTENT],
"CarBatteryCapacity": [TxType.PERSISTENT],
"CarParams": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
"CarParamsCache": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
"CarVin": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
"CommunityFeaturesToggle": [TxType.PERSISTENT],
"CompletedTrainingVersion": [TxType.PERSISTENT],
"DisablePowerDown": [TxType.PERSISTENT],
"DisableUpdates": [TxType.PERSISTENT],
"DoUninstall": [TxType.CLEAR_ON_MANAGER_START],
"DongleId": [TxType.PERSISTENT],
"GitBranch": [TxType.PERSISTENT],
"GitCommit": [TxType.PERSISTENT],
"GitRemote": [TxType.PERSISTENT],
"GithubSshKeys": [TxType.PERSISTENT],
"HasAcceptedTerms": [TxType.PERSISTENT],
"HasCompletedSetup": [TxType.PERSISTENT],
"IsDriverViewEnabled": [TxType.CLEAR_ON_MANAGER_START],
"IsLdwEnabled": [TxType.PERSISTENT],
"IsMetric": [TxType.PERSISTENT],
"IsOffroad": [TxType.CLEAR_ON_MANAGER_START],
"IsRHD": [TxType.PERSISTENT],
"IsTakingSnapshot": [TxType.CLEAR_ON_MANAGER_START],
"IsUpdateAvailable": [TxType.CLEAR_ON_MANAGER_START],
"IsUploadRawEnabled": [TxType.PERSISTENT],
"LastAthenaPingTime": [TxType.PERSISTENT],
"LastUpdateTime": [TxType.PERSISTENT],
"LastUpdateException": [TxType.PERSISTENT],
"LiveParameters": [TxType.PERSISTENT],
"OpenpilotEnabledToggle": [TxType.PERSISTENT],
"LaneChangeEnabled": [TxType.PERSISTENT],
"PandaFirmware": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
"PandaFirmwareHex": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
"PandaDongleId": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
"Passive": [TxType.PERSISTENT],
"RecordFront": [TxType.PERSISTENT],
"ReleaseNotes": [TxType.PERSISTENT],
"ShouldDoUpdate": [TxType.CLEAR_ON_MANAGER_START],
"SubscriberInfo": [TxType.PERSISTENT],
"TermsVersion": [TxType.PERSISTENT],
"TrainingVersion": [TxType.PERSISTENT],
"UpdateAvailable": [TxType.CLEAR_ON_MANAGER_START],
"UpdateFailedCount": [TxType.CLEAR_ON_MANAGER_START],
"Version": [TxType.PERSISTENT],
"Offroad_ChargeDisabled": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
"Offroad_ConnectivityNeeded": [TxType.CLEAR_ON_MANAGER_START],
"Offroad_ConnectivityNeededPrompt": [TxType.CLEAR_ON_MANAGER_START],
"Offroad_TemperatureTooHigh": [TxType.CLEAR_ON_MANAGER_START],
"Offroad_PandaFirmwareMismatch": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
"Offroad_InvalidTime": [TxType.CLEAR_ON_MANAGER_START],
"Offroad_IsTakingSnapshot": [TxType.CLEAR_ON_MANAGER_START],
"Offroad_NeosUpdate": [TxType.CLEAR_ON_MANAGER_START],
"Offroad_UpdateFailed": [TxType.CLEAR_ON_MANAGER_START],
}
def fsync_dir(path):
fd = os.open(path, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
class FileLock():
def __init__(self, path, create, lock_ex):
self._path = path
self._create = create
self._fd = None
self._lock_ex = lock_ex
def acquire(self):
self._fd = os.open(self._path, os.O_CREAT if self._create else 0)
fcntl.flock(self._fd, fcntl.LOCK_EX if self._lock_ex else fcntl.LOCK_SH)
def release(self):
if self._fd is not None:
os.close(self._fd)
self._fd = None
class DBAccessor():
def __init__(self, path):
self._path = path
self._vals = None
def keys(self):
self._check_entered()
return self._vals.keys()
def get(self, key):
self._check_entered()
if self._vals is None:
return None
try:
return self._vals[key]
except KeyError:
return None
def _get_lock(self, create, lock_ex):
lock = FileLock(os.path.join(self._path, ".lock"), create, lock_ex)
lock.acquire()
return lock
def _read_values_locked(self):
"""Callers should hold a lock while calling this method."""
vals = {}
try:
data_path = self._data_path()
keys = os.listdir(data_path)
for key in keys:
with open(os.path.join(data_path, key), "rb") as f:
vals[key] = f.read()
except (OSError, IOError) as e:
# Either the DB hasn't been created yet, or somebody wrote a bug and left the DB in an
# inconsistent state. Either way, return empty.
if e.errno == errno.ENOENT:
return {}
return vals
def _data_path(self):
return os.path.join(self._path, "d")
def _check_entered(self):
if self._vals is None:
raise Exception("Must call __enter__ before using DB")
class DBReader(DBAccessor):
def __enter__(self):
try:
lock = self._get_lock(False, False)
except OSError as e:
# Do not create lock if it does not exist.
if e.errno == errno.ENOENT:
self._vals = {}
return self
try:
# Read everything.
self._vals = self._read_values_locked()
return self
finally:
lock.release()
def __exit__(self, exc_type, exc_value, traceback):
pass
class DBWriter(DBAccessor):
def __init__(self, path):
super(DBWriter, self).__init__(path)
self._lock = None
self._prev_umask = None
def put(self, key, value):
self._vals[key] = value
def delete(self, key):
self._vals.pop(key, None)
def __enter__(self):
mkdirs_exists_ok(self._path)
# Make sure we can write and that permissions are correct.
self._prev_umask = os.umask(0)
try:
os.chmod(self._path, 0o777)
self._lock = self._get_lock(True, True)
self._vals = self._read_values_locked()
except Exception:
os.umask(self._prev_umask)
self._prev_umask = None
raise
return self
def __exit__(self, exc_type, exc_value, traceback):
self._check_entered()
try:
# data_path refers to the externally used path to the params. It is a symlink.
# old_data_path is the path currently pointed to by data_path.
# tempdir_path is a path where the new params will go, which the new data path will point to.
# new_data_path is a temporary symlink that will atomically overwrite data_path.
#
# The current situation is:
# data_path -> old_data_path
# We're going to write params data to tempdir_path
# tempdir_path -> params data
# Then point new_data_path to tempdir_path
# new_data_path -> tempdir_path
# Then atomically overwrite data_path with new_data_path
# data_path -> tempdir_path
old_data_path = None
new_data_path = None
tempdir_path = tempfile.mkdtemp(prefix=".tmp", dir=self._path)
try:
# Write back all keys.
os.chmod(tempdir_path, 0o777)
for k, v in self._vals.items():
with open(os.path.join(tempdir_path, k), "wb") as f:
f.write(v)
f.flush()
os.fsync(f.fileno())
fsync_dir(tempdir_path)
data_path = self._data_path()
try:
old_data_path = os.path.join(self._path, os.readlink(data_path))
except (OSError, IOError):
# NOTE(mgraczyk): If other DB implementations have bugs, this could cause
# copies to be left behind, but we still want to overwrite.
pass
new_data_path = "{}.link".format(tempdir_path)
os.symlink(os.path.basename(tempdir_path), new_data_path)
os.rename(new_data_path, data_path)
fsync_dir(self._path)
finally:
# If the rename worked, we can delete the old data. Otherwise delete the new one.
success = new_data_path is not None and os.path.exists(data_path) and (
os.readlink(data_path) == os.path.basename(tempdir_path))
if success:
if old_data_path is not None:
shutil.rmtree(old_data_path)
else:
shutil.rmtree(tempdir_path)
# Regardless of what happened above, there should be no link at new_data_path.
if new_data_path is not None and os.path.islink(new_data_path):
os.remove(new_data_path)
finally:
os.umask(self._prev_umask)
self._prev_umask = None
# Always release the lock.
self._lock.release()
self._lock = None
def read_db(params_path, key):
path = "%s/d/%s" % (params_path, key)
try:
with open(path, "rb") as f:
return f.read()
except IOError:
return None
def write_db(params_path, key, value):
if isinstance(value, str):
value = value.encode('utf8')
prev_umask = os.umask(0)
lock = FileLock(params_path + "/.lock", True, True)
lock.acquire()
try:
tmp_path = tempfile.NamedTemporaryFile(mode="wb", prefix=".tmp", dir=params_path, delete=False)
with tmp_path as f:
f.write(value)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp_path.name, 0o666)
path = "%s/d/%s" % (params_path, key)
os.rename(tmp_path.name, path)
fsync_dir(os.path.dirname(path))
finally:
os.umask(prev_umask)
lock.release()
class Params():
def __init__(self, db=PARAMS):
self.db = db
# create the database if it doesn't exist...
if not os.path.exists(self.db + "/d"):
with self.transaction(write=True):
pass
def clear_all(self):
shutil.rmtree(self.db, ignore_errors=True)
with self.transaction(write=True):
pass
def transaction(self, write=False):
if write:
return DBWriter(self.db)
else:
return DBReader(self.db)
def _clear_keys_with_type(self, tx_type):
with self.transaction(write=True) as txn:
for key in keys:
if tx_type in keys[key]:
txn.delete(key)
def manager_start(self):
self._clear_keys_with_type(TxType.CLEAR_ON_MANAGER_START)
def panda_disconnect(self):
self._clear_keys_with_type(TxType.CLEAR_ON_PANDA_DISCONNECT)
def delete(self, key):
with self.transaction(write=True) as txn:
txn.delete(key)
def get(self, key, block=False, encoding=None):
if key not in keys:
raise UnknownKeyName(key)
while 1:
ret = read_db(self.db, key)
if not block or ret is not None:
break
# is polling really the best we can do?
time.sleep(0.05)
if ret is not None and encoding is not None:
ret = ret.decode(encoding)
return ret
def put(self, key, dat):
"""
Warning: This function blocks until the param is written to disk!
In very rare cases this can take over a second, and your code will hang.
Use the put_nonblocking helper function in time sensitive code, but
in general try to avoid writing params as much as possible.
"""
if key not in keys:
raise UnknownKeyName(key)
write_db(self.db, key, dat)
def put_nonblocking(key, val):
def f(key, val):
params = Params()
params.put(key, val)
t = threading.Thread(target=f, args=(key, val))
t.start()
return t
from common.params_pyx import Params, UnknownKeyName, put_nonblocking # pylint: disable=no-name-in-module, import-error
assert Params
assert UnknownKeyName
assert put_nonblocking
+16
View File
@@ -0,0 +1,16 @@
from libcpp.string cimport string
from libcpp cimport bool
cdef extern from "selfdrive/common/params.cc":
pass
cdef extern from "selfdrive/common/util.c":
pass
cdef extern from "selfdrive/common/params.h":
cdef cppclass Params:
Params(bool)
Params(string)
string get(string, bool) nogil
int delete_db_value(string)
int write_db_value(string, string)
+157
View File
@@ -0,0 +1,157 @@
# distutils: langauge = c++
import threading
import os
from libcpp cimport bool
from libcpp.string cimport string
from params_pxd cimport Params as c_Params
cdef enum TxType:
PERSISTENT = 1
CLEAR_ON_MANAGER_START = 2
CLEAR_ON_PANDA_DISCONNECT = 3
keys = {
b"AccessToken": [TxType.CLEAR_ON_MANAGER_START],
b"AthenadPid": [TxType.PERSISTENT],
b"CalibrationParams": [TxType.PERSISTENT],
b"CarBatteryCapacity": [TxType.PERSISTENT],
b"CarParams": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
b"CarParamsCache": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
b"CarVin": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
b"CommunityFeaturesToggle": [TxType.PERSISTENT],
b"CompletedTrainingVersion": [TxType.PERSISTENT],
b"DisablePowerDown": [TxType.PERSISTENT],
b"DisableUpdates": [TxType.PERSISTENT],
b"DoUninstall": [TxType.CLEAR_ON_MANAGER_START],
b"DongleId": [TxType.PERSISTENT],
b"GitBranch": [TxType.PERSISTENT],
b"GitCommit": [TxType.PERSISTENT],
b"GitRemote": [TxType.PERSISTENT],
b"GithubSshKeys": [TxType.PERSISTENT],
b"HasAcceptedTerms": [TxType.PERSISTENT],
b"HasCompletedSetup": [TxType.PERSISTENT],
b"IsDriverViewEnabled": [TxType.CLEAR_ON_MANAGER_START],
b"IsLdwEnabled": [TxType.PERSISTENT],
b"IsMetric": [TxType.PERSISTENT],
b"IsOffroad": [TxType.CLEAR_ON_MANAGER_START],
b"IsRHD": [TxType.PERSISTENT],
b"IsTakingSnapshot": [TxType.CLEAR_ON_MANAGER_START],
b"IsUpdateAvailable": [TxType.CLEAR_ON_MANAGER_START],
b"IsUploadRawEnabled": [TxType.PERSISTENT],
b"LastAthenaPingTime": [TxType.PERSISTENT],
b"LastUpdateTime": [TxType.PERSISTENT],
b"LastUpdateException": [TxType.PERSISTENT],
b"LiveParameters": [TxType.PERSISTENT],
b"OpenpilotEnabledToggle": [TxType.PERSISTENT],
b"LaneChangeEnabled": [TxType.PERSISTENT],
b"PandaFirmware": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
b"PandaFirmwareHex": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
b"PandaDongleId": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
b"Passive": [TxType.PERSISTENT],
b"RecordFront": [TxType.PERSISTENT],
b"ReleaseNotes": [TxType.PERSISTENT],
b"ShouldDoUpdate": [TxType.CLEAR_ON_MANAGER_START],
b"SubscriberInfo": [TxType.PERSISTENT],
b"TermsVersion": [TxType.PERSISTENT],
b"TrainingVersion": [TxType.PERSISTENT],
b"UpdateAvailable": [TxType.CLEAR_ON_MANAGER_START],
b"UpdateFailedCount": [TxType.CLEAR_ON_MANAGER_START],
b"Version": [TxType.PERSISTENT],
b"Offroad_ChargeDisabled": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
b"Offroad_ConnectivityNeeded": [TxType.CLEAR_ON_MANAGER_START],
b"Offroad_ConnectivityNeededPrompt": [TxType.CLEAR_ON_MANAGER_START],
b"Offroad_TemperatureTooHigh": [TxType.CLEAR_ON_MANAGER_START],
b"Offroad_PandaFirmwareMismatch": [TxType.CLEAR_ON_MANAGER_START, TxType.CLEAR_ON_PANDA_DISCONNECT],
b"Offroad_InvalidTime": [TxType.CLEAR_ON_MANAGER_START],
b"Offroad_IsTakingSnapshot": [TxType.CLEAR_ON_MANAGER_START],
b"Offroad_NeosUpdate": [TxType.CLEAR_ON_MANAGER_START],
b"Offroad_UpdateFailed": [TxType.CLEAR_ON_MANAGER_START],
}
def ensure_bytes(v):
if isinstance(v, str):
return v.encode()
else:
return v
class UnknownKeyName(Exception):
pass
cdef class Params:
cdef c_Params* p
def __cinit__(self, d=None, persistent_params=False):
if d is not None:
self.p = new c_Params(<string>d.encode())
else:
self.p = new c_Params(<bool>persistent_params)
def __dealloc__(self):
del self.p
def clear_all(self, tx_type=None):
for key in keys:
if tx_type is None or tx_type in keys[key]:
self.delete(key)
def manager_start(self):
self.clear_all(TxType.CLEAR_ON_MANAGER_START)
def panda_disconnect(self):
self.clear_all(TxType.CLEAR_ON_PANDA_DISCONNECT)
def get(self, key, block=False, encoding=None):
key = ensure_bytes(key)
if key not in keys:
raise UnknownKeyName(key)
cdef string k = key
cdef bool b = block
cdef string val
with nogil:
val = self.p.get(k, b)
if val == b"":
if block:
# If we got no value while running in blocked mode
# it means we got an interrupt while waiting
raise KeyboardInterrupt
else:
return None
if encoding is not None:
return val.decode(encoding)
else:
return val
def put(self, key, dat):
"""
Warning: This function blocks until the param is written to disk!
In very rare cases this can take over a second, and your code will hang.
Use the put_nonblocking helper function in time sensitive code, but
in general try to avoid writing params as much as possible.
"""
key = ensure_bytes(key)
dat = ensure_bytes(dat)
if key not in keys:
raise UnknownKeyName(key)
self.p.write_db_value(key, dat)
def delete(self, key):
key = ensure_bytes(key)
self.p.delete_db_value(key)
def put_nonblocking(key, val, d=None):
def f(key, val):
params = Params(d)
params.put(key, val)
t = threading.Thread(target=f, args=(key, val))
t.start()
return t
+21
View File
@@ -0,0 +1,21 @@
import os
from distutils.core import Extension, setup
from Cython.Build import cythonize
from common.cython_hacks import BuildExtWithoutPlatformSuffix
from common.basedir import BASEDIR
sourcefiles = ['params_pyx.pyx']
extra_compile_args = ["-std=c++11"]
setup(name='common',
cmdclass={'build_ext': BuildExtWithoutPlatformSuffix},
ext_modules=cythonize(
Extension(
"params_pyx",
language="c++",
sources=sourcefiles,
include_dirs=[BASEDIR, os.path.join(BASEDIR, 'selfdrive')],
extra_compile_args=extra_compile_args
)
)
)
+16 -1
View File
@@ -6,7 +6,7 @@ import shutil
import stat
import unittest
from common.params import Params, UnknownKeyName
from common.params import Params, UnknownKeyName, put_nonblocking
class TestParams(unittest.TestCase):
def setUp(self):
@@ -67,5 +67,20 @@ class TestParams(unittest.TestCase):
st_mode = os.stat(f"{self.tmpdir}/d/DongleId").st_mode
assert (st_mode & permissions) == permissions
def test_delete_not_there(self):
assert self.params.get("CarParams") is None
self.params.delete("CarParams")
assert self.params.get("CarParams") is None
def test_put_non_blocking_with_get_block(self):
q = Params(self.tmpdir)
def _delayed_writer():
time.sleep(0.1)
put_nonblocking("CarParams", "test", self.tmpdir)
threading.Thread(target=_delayed_writer).start()
assert q.get("CarParams") is None
assert q.get("CarParams", True) == b"test"
if __name__ == "__main__":
unittest.main()