Files
StarPilot/openpilot/system/loggerd/uploader.py
T

332 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
from openpilot.starpilot.galaxy.companion import phone_uplink_active
import json
import os
import random
import requests
import threading
import time
import traceback
import datetime
from collections.abc import Iterator
from openpilot.cereal import log
import openpilot.cereal.messaging as messaging
from openpilot.common.api import Api
from openpilot.common.utils import CallbackReader, get_upload_stream
from openpilot.common.params import Params
from openpilot.common.realtime import set_core_affinity
from openpilot.common.hardware.hw import Paths
from openpilot.system.loggerd.xattr_cache import getxattr, setxattr
from openpilot.common.swaglog import cloudlog
NetworkType = log.DeviceState.NetworkType
from openpilot.starpilot.connect.provider import active_provider, owns_recording
UPLOAD_ATTR_NAME = active_provider().upload_attribute
UPLOAD_ATTR_VALUE = b'1'
MAX_UPLOAD_SIZES = {
"qlog": 25*1e6, # can't be too restrictive here since we use qlogs to find
# bugs, including ones that can cause massive log sizes
"qcam": 5*1e6,
}
allow_sleep = bool(int(os.getenv("UPLOADER_SLEEP", "1")))
force_wifi = os.getenv("FORCEWIFI") is not None
fake_upload = os.getenv("FAKEUPLOAD") is not None
class FakeRequest:
def __init__(self):
self.headers = {"Content-Length": "0"}
class FakeResponse:
def __init__(self):
self.status_code = 200
self.request = FakeRequest()
def get_directory_sort(d: str) -> list[str]:
return [s.rjust(10, '0') for s in d.rsplit('--', 1)]
def listdir_by_creation(d: str) -> list[str]:
if not os.path.isdir(d):
return []
try:
paths = [f for f in os.listdir(d) if os.path.isdir(os.path.join(d, f))]
paths = sorted(paths, key=get_directory_sort)
return paths
except OSError:
cloudlog.exception("listdir_by_creation failed")
return []
def clear_locks(root: str) -> None:
for logdir in os.listdir(root):
path = os.path.join(root, logdir)
try:
for fname in os.listdir(path):
if fname.endswith(".lock"):
os.unlink(os.path.join(path, fname))
except OSError:
cloudlog.exception("clear_locks failed")
class Uploader:
def __init__(self, dongle_id: str, root: str):
self.dongle_id = dongle_id
self.api = Api(dongle_id)
self.root = root
self.params = Params()
try:
os.nice(10) # uploads are never urgent; stay out of the way of the cores AA and logging share
except OSError:
pass
# stats for last successfully uploaded file
self.last_filename = ""
self.immediate_folders = ["crash/", "boot/"]
self.immediate_priority = {"qlog": 0, "qlog.zst": 0, "qcamera.ts": 1}
# Routes whose uploadable files are all uploaded, keyed by name -> directory mtime. Rescanning
# hundreds of finished routes (stat + getxattr per file) every few seconds cost ~40% of a core.
self.settled: dict[str, float] = {}
# Seconds to yield after scanning each unsettled route (set by main() while driving).
self.scan_pause = 0.0
def list_upload_files(self, metered: bool) -> Iterator[tuple[str, str, str]]:
r = self.params.get("AthenadRecentlyViewedRoutes")
requested_routes = [] if r is None else [route for route in r.split(",") if route]
logdirs = listdir_by_creation(self.root)
recent = set(logdirs[-3:]) # the routes still being written are always rescanned
for logdir in logdirs:
path = os.path.join(self.root, logdir)
settled_mtime = self.settled.get(logdir)
if settled_mtime is not None and logdir not in recent:
try:
if os.stat(path).st_mtime == settled_mtime:
continue
except OSError:
pass
self.settled.pop(logdir, None)
if logdir != "boot" and not owns_recording(path, self.root):
continue
try:
dir_mtime = os.stat(path).st_mtime
names = os.listdir(path)
except OSError:
continue
if any(name.endswith(".lock") for name in names):
continue
if self.scan_pause:
time.sleep(self.scan_pause)
priority_files = 0
priority_pending = 0
for name in sorted(names, key=lambda n: self.immediate_priority.get(n, 1000)):
if name.startswith("."):
continue
key = os.path.join(logdir, name)
fn = os.path.join(path, name)
if not owns_recording(fn, self.root):
continue
# skip files already uploaded
try:
ctime = os.path.getctime(fn)
is_uploaded = getxattr(fn, UPLOAD_ATTR_NAME) == UPLOAD_ATTR_VALUE
except OSError:
cloudlog.event("uploader_getxattr_failed", key=key, fn=fn)
# deleter could have deleted, so skip
continue
if name in self.immediate_priority:
priority_files += 1
priority_pending += not is_uploaded
if is_uploaded:
continue
# limit uploading on metered connections
if metered:
dt = datetime.timedelta(hours=12)
if logdir in self.immediate_folders and (datetime.datetime.now() - datetime.datetime.fromtimestamp(ctime)) < dt:
continue
if name == "qcamera.ts" and not any(logdir.startswith(r.split('|')[-1]) for r in requested_routes):
continue
yield name, key, fn
if priority_files and not priority_pending and logdir not in self.immediate_folders and logdir != "boot":
self.settled[logdir] = dir_mtime
def next_file_to_upload(self, metered: bool) -> tuple[str, str, str] | None:
upload_files = list(self.list_upload_files(metered))
for name, key, fn in upload_files:
if any(f in fn for f in self.immediate_folders):
return name, key, fn
for name, key, fn in upload_files:
if name in self.immediate_priority:
return name, key, fn
return None
def do_upload(self, key: str, fn: str):
def guard(*_):
if phone_uplink_active():
raise requests.exceptions.ConnectionError("Phone uplink forbids log uploads")
guard()
url_resp = self.api.get("v1.4/" + self.dongle_id + "/upload_url/", timeout=10, path=key, access_token=self.api.get_token())
if url_resp.status_code == 412:
return url_resp
url_resp_json = json.loads(url_resp.text)
url = url_resp_json['url']
headers = url_resp_json['headers']
cloudlog.debug("upload_url v1.4 %s %s", url, str(headers))
if fake_upload:
return FakeResponse()
stream = None
try:
compress = key.endswith('.zst') and not fn.endswith('.zst')
stream, length = get_upload_stream(fn, compress)
response = requests.put(url, data=CallbackReader(stream, guard, length), headers=headers, timeout=10)
return response
finally:
if stream:
stream.close()
def upload(self, name: str, key: str, fn: str, network_type: int, metered: bool) -> bool:
try:
sz = os.path.getsize(fn)
except OSError:
cloudlog.exception("upload: getsize failed")
return False
cloudlog.event("upload_start", key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
if sz == 0:
# tag files of 0 size as uploaded
success = True
elif name in MAX_UPLOAD_SIZES and sz > MAX_UPLOAD_SIZES[name]:
cloudlog.event("uploader_too_large", key=key, fn=fn, sz=sz)
success = True
else:
start_time = time.monotonic()
stat = None
last_exc = None
try:
stat = self.do_upload(key, fn)
except Exception as e:
last_exc = (e, traceback.format_exc())
if stat is not None and stat.status_code in (200, 201, 401, 403, 412):
self.last_filename = fn
dt = time.monotonic() - start_time
if stat.status_code == 412:
cloudlog.event("upload_ignored", key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
else:
content_length = int(stat.request.headers.get("Content-Length", 0))
speed = (content_length / 1e6) / dt
cloudlog.event("upload_success", key=key, fn=fn, sz=sz, content_length=content_length,
network_type=network_type, metered=metered, speed=speed)
success = True
else:
success = False
cloudlog.event("upload_failed", stat=stat, exc=last_exc, key=key, fn=fn, sz=sz, network_type=network_type, metered=metered)
if success:
# tag file as uploaded
try:
setxattr(fn, UPLOAD_ATTR_NAME, UPLOAD_ATTR_VALUE)
except OSError:
cloudlog.event("uploader_setxattr_failed", exc=last_exc, key=key, fn=fn, sz=sz)
return success
def step(self, network_type: int, metered: bool) -> bool | None:
if phone_uplink_active():
return None
d = self.next_file_to_upload(metered)
if d is None:
return None
name, key, fn = d
# qlogs and bootlogs need to be compressed before uploading
if key.endswith(('qlog', 'rlog')) or (key.startswith('boot/') and not key.endswith('.zst')):
key += ".zst"
return self.upload(name, key, fn, network_type, metered)
def main(exit_event: threading.Event | None = None) -> None:
if exit_event is None:
exit_event = threading.Event()
try:
set_core_affinity([0, 1, 2, 3])
except Exception:
cloudlog.exception("failed to set core affinity")
clear_locks(Paths.log_root())
params = Params()
dongle_id = params.get("DongleId")
while active_provider().name == 'konik' and dongle_id in (None, 'UnregisteredDevice') and not exit_event.is_set():
exit_event.wait(10)
dongle_id = params.get('DongleId')
if exit_event.is_set():
return
if dongle_id is None:
cloudlog.info("uploader missing dongle_id")
raise Exception("uploader can't start without dongle id")
sm = messaging.SubMaster(['deviceState'])
uploader = Uploader(dongle_id, Paths.log_root())
backoff = 0.1
while not exit_event.is_set():
sm.update(0)
always_allow_uploads = params.get_bool("AlwaysAllowUploads")
offroad = params.get_bool("IsOffroad")
network_type = sm['deviceState'].networkType if not force_wifi else NetworkType.wifi
if phone_uplink_active() or network_type == NetworkType.none:
if allow_sleep:
time.sleep(60 if offroad else 5)
continue
# Android Auto shares the little cores with this process: while it is on and driving, scan gently.
from openpilot.starpilot.system.starport.settings import enabled as starport_enabled
quiet = not offroad and starport_enabled(params)
uploader.scan_pause = 0.01 if quiet else 0.0
success = uploader.step(sm['deviceState'].networkType.raw, sm['deviceState'].networkMetered and not always_allow_uploads)
if success is None:
backoff = 60 if offroad or quiet else 5 # nothing to send
elif success:
backoff = 0.1
else:
cloudlog.info("upload backoff %r", backoff)
backoff = min(backoff*2, 120)
if allow_sleep:
time.sleep(backoff + random.uniform(0, backoff))
if __name__ == "__main__":
main()