feat(logloader): MAVLink FTP transport, request-driven fetching, and an HTTP API - #32
Merged
Conversation
This was referenced Jul 28, 2026
LOG_DATA (msg 120) carries no target_system/target_component, so a router between logloader and the autopilot has no addressing information and copies every chunk to every endpoint it serves -- the telemetry radio included. On a node running mavlink-router a log download saturates links that have no interest in it. Move both halves of the job to MAVLink FTP: the listing that finds the logs and the transfer that downloads them. FILE_TRANSFER_PROTOCOL (msg 110) is addressed and both PX4 and ArduPilot reply to the requesting sysid/compid, so everything is unicast to logloader. The classic log protocol is gone entirely -- no LOG_REQUEST_LIST, no LOG_ENTRY, no LOG_DATA, and no MAVSDK LogFiles plugin. Dropping LOG_ENTRY means dropping the only thing that identified a log, so identity moves to the listing: the path below the log root plus the size. That is also what makes ArduPilot work without special cases. Its list entry numbering, its LOG_MAX_FILES wrap and its .BIN extension were all consequences of the old protocol; a directory listing just reports files. The log root is probed at "@MAV_LOG" -- the virtual directory the MAVLink FTP specification defines for this -- then at /fs/microsd/log and /APM/LOGS for firmware that predates it. Timestamps come from the listing when the vehicle implements ListDirectoryWithTime, otherwise from the start time PX4 encodes in the path; ArduPilot logs simply have no timestamp and nothing needs one. MAVSDK's Ftp plugin drops the size and modification time from list replies, so FtpListClient runs ListDirectory / ListDirectoryWithTime over MavlinkPassthrough and keeps the whole entry. Bulk transfers still go through the plugin. Databases written by earlier versions keyed logs on the LOG_ENTRY timestamp, which FTP cannot reproduce. Their rows are set aside as logs_legacy on first start and matched to the listing by size, so a fleet upgrading to this does not re-download and re-upload its history. Also: - A log is only queued once two consecutive listings agree on its size, so the log being written right now is not downloaded at a size it will not keep. - Downloads are staged and size-checked before being moved next to the finished logs, and a log that cannot be fetched sorts to the back of the queue instead of blocking the ones behind it. - Open FTP sessions are reset at connect, which is what left PX4 refusing transfers until reboot after logloader was killed mid-download. - The databases are created before they are opened, which failed when application_directory did not exist yet.
When the local flight-review server is down, the upload loop previously probed and logged a failure for every pending log. Probe once, log a single unreachable message with a 60s cooldown, and bail the current upload batch (HTTP 503) instead of walking the queue. (cherry picked from commit 7a23c8e)
Add remote_api_key config for ARK Flight Review (Authorization: Bearer and X-API-Key per flight_review api_key.py). Empty key still attempts remote upload without auth headers for open servers. Treat any HTTP response as reachable so 302 home pages (review.arkelectron.com) are not marked dead. Improve 401/403 handling with response body and stop retry spam. Slightly longer connection timeouts for probe and upload. (cherry picked from commit adc72da)
…equest-driven Replaces ServerInterface, which was a SQLite repository and an HTTP client sharing a name, with LogDatabase (one database, the single record of what the vehicle has and what we mean to do about it) and UploadTarget (one Flight Review endpoint, no state of its own). LogLoader no longer keeps two databases in step by calling every mutation twice. Behaviour change: logloader no longer mirrors the SD card. A log is fetched because it appeared while logloader was watching -- a flight just happened -- or because something asked for it. On a database that has never seen the vehicle only the newest log is taken, so a companion powered on for the first time does not pull and upload an entire card. A listing that turns up more than max_auto_queue new logs is treated as an unfamiliar card, not a burst of flights. Intent lives in the database, so automatic and requested work share one path. Adds an HTTP API on 127.0.0.1:3005 (list, request, cancel, delete, SSE) for the ARK-OS Logs page to drive. Also fixed along the way: - A pending log whose file was missing or empty was retried forever and blocked every older log behind it; only HTTP 400 escaped. Upload outcomes are now classified (Success/Rejected/Unauthorized/Unreachable/Retry) and the loop iterates a fetched batch instead of re-counting a query that only shrinks on success. - ServerInterface::_should_exit and LogLoader::_loop_disabled were plain bools written by one thread and read by another. Both are gone; pausing is derived from arm state and shutdown goes through a Waiter. - SIGINT/SIGTERM are blocked and consumed by a sigtimedwait thread. The handler used to take a mutex and signal a condition variable, neither of which is async-signal-safe. - CMAKE_BUILD_TYPE was never set, so the shipped binary was built at -O0. - Rows are marked absent rather than deleted when a log leaves the vehicle, so a downloaded log keeps its file and its upload history. - generate_uuid hashed with std::hash, which is implementation-defined, into persistent state; identity is now (path, size) with an integer primary key. - Dead code: num_logs_to_download, the .lock file check, the logger_running branch that was hardcoded false. - The staging directory is swept at startup so a run killed mid-transfer does not leak partial files. Verified end to end against PX4 SITL and a local flight-review: probe, index, first-start policy, download, upload, and manual selection through the API.
… an OOM upload Findings from two review passes over the rewrite, verified against PX4 SITL and a local flight-review. FtpListClient handed MAVSDK a callback capturing `this`. MAVSDK removes a message handler asynchronously and does not drain callbacks it has already queued, so a reply arriving after the client was destroyed -- which is what happens on SIGTERM during a transfer, since the vehicle keeps sending -- locked a destroyed mutex and wrote 251 bytes into freed heap. The callback now holds a weak_ptr to the state it touches. This is the same shape as the progress callback fixed earlier; it was the second instance. Transaction::commit cleared _open before running COMMIT, so a COMMIT that failed (a full data partition) left the transaction open with no rollback. Every later BEGIN would then fail, every write would be silently discarded, and the daemon would re-download the same logs forever while appearing healthy. Uploads serialised the whole log into memory twice: httplib's MultipartFormDataItems overload concatenates the entire body into a second string. A 400 MB log needed most of a gigabyte on a Pi, and the resulting OOM kill retried the same log on restart. The body is now streamed off disk with a computed Content-Length. Verified byte-identical on arrival. Also: - LogDatabase::ok() reported success when only sqlite3_open had succeeded, so a read-only data directory produced a daemon that ran and stored nothing. - A row claiming downloaded=1 with no local file was reachable from neither queue. The queues now treat it as not downloaded, and a missing local file at upload time is Outcome::Missing (re-fetch) rather than Rejected (give up). - The SSE keepalive wrote 14 bytes of a 13-byte string, putting a NUL on the wire; the frontend then dropped the next real event. Transfer progress also re-serialised the whole log table twice a second per client -- status and logs are now separate events. - Each SSE stream holds an httplib worker, and the default pool is 8, so a few stale tabs starved the whole API. Pool raised, concurrent streams capped. - newest_log_id ordered ArduPilot logs by name, which stops meaning age once the counter wraps at LOG_MAX_FILES; discovered_at now takes precedence. - iso8601_utc ignored strftime's return value, so a vehicle reporting an absurd mtime caused a stack over-read into the JSON response. - FtpListClient::stop() mutated its flag outside the lock (lost wakeup), and list_directory had no bound on a remote-driven loop. - download.auto = false no longer leaves latest_on_first_start active; a free space check runs before a transfer; `make debug` builds Debug again; astyle moved out of the default target.
…se with tests The pre-FTP migration was 28% of LogDatabase and the one thing in it that was not a repository: an extra table, a fuzzy timestamp heuristic, a reconstruction of the old file-naming scheme, a member, a constructor parameter and a branch inside sync_index. It moves to LegacyImport, whose header says plainly that the file is deletable once the fleet has upgraded. LogDatabase drops from 796 lines to 560 and is now only a repository. sync_index returns a SyncResult (what it inserted, how many are present, and whether this was the first listing with anything in it) instead of writing two out-parameters and leaving the caller to own the "first index" invariant. The subtlety -- that the first listing after startup legitimately reconciles nothing, because stability takes two listings to establish -- now lives in the one function that can see it. apply_auto_policy is two obvious halves. Adds tests/test_log_database.cpp: 14 cases over the first-index rule, present/ absent transitions, a growing log, request and cancel, the download-with-no-file recovery, rejected uploads not blocking the queue, failure ordering, ArduPilot name reuse, and four covering the legacy import -- which runs once against real user data and cannot be un-run, so it is the thing least testable any other way. Plus a GitHub Actions workflow that builds, tests and checks formatting. Writing the tests turned up a real defect: ordering fell back to discovered_at, a one-second wall clock, to decide which ArduPilot log is newest once the counter wraps at LOG_MAX_FILES. Two listings in the same second were indistinguishable, and on a companion with no RTC the clock can run backwards. Replaced with a monotonic discovery sequence. Also: ATTACH is now RAII, so a failed import cannot leave the old database attached and wedge the next one; the legacy adopt path no longer produces a row claiming to be downloaded with no file behind it; and equidistant timestamp candidates leave the claim with the first rather than the last.
…w findings A second review pass over the rewrite, verified against PX4 SITL. The last commit added a discovered_seq column but no migration. CREATE TABLE IF NOT EXISTS does nothing to a table that already exists, so on any database from an earlier build every query naming the column failed to prepare and returned nothing -- while commit succeeded and the daemon reported itself healthy. The log list would simply be empty forever. There is now an ALTER TABLE path, backfilled from discovered_at, and a test that opens an older schema; the existing tests only ever created fresh databases, which is why they missed it. A log still being written is on the vehicle. sync_index was only told about stable ones, so the first listing after any restart -- which has nothing stable yet, stability taking two listings -- marked every row absent, showed "not on vehicle" across the whole UI, and emptied the download queue for an interval. The whole listing now goes over with a stable flag; only stable logs are recorded, but everything listed counts as present. Outcome::Missing on a zero-byte log was an infinite loop: clear, re-request, download nothing successfully, clear again. An empty log is a property of the log, not of the local copy, so that case is Rejected again. Missing now means only that the file is gone. clear_local_file is conditional on the path the caller saw. The upload thread works from a snapshot that can be minutes old, and clearing unconditionally would wipe the bookkeeping of a download the index thread had just finished and re-fetch the whole log. Also: the SSE stream slot is released by httplib's resource releaser, which runs on every path -- the manual decrement only covered shutdown, so a browser refresh leaked a slot and eight of them wedged /events permanently; a database write now bumps the status board, which streams block on, instead of waiting out a keepalive; upload targets ship their base url, since Flight Review returns a relative redirect and a remote plot link was resolving against the ARK-OS host; a failed BEGIN no longer leaves a durable UPDATE behind; an unchanged listing no longer counts as a change; downloaded is never reported true for a row with no file; auto_upload keeps its old key and stops hardcoding its default; connect() cannot leave a fetcher running past stop(); and free space is printed as uintmax_t in the message that exists to diagnose a full disk. Config tables are one level deep now ([upload_local] rather than [upload.local]): ARK-OS's shared config editor renders exactly one level, and a setting the operator cannot reach from the web UI may as well not exist.
The vehicle is no longer polled. The index loop sits idle until an event says the listing may have changed: the autopilot connection coming (back) up, a flight or logging session ending, or a request through the API (including the new POST /refresh). A flight ending is read from MAV_SYS_STATUS_LOGGING in SYS_STATUS, which PX4 v1.16+ raise only while the logger is actually writing -- catching logging that runs past disarm or never involved arming (SDLOG_MODE). Older firmware falls back to the disarm transition; ArduPilot raises the bit whenever logging is merely configured, so there it is ignored and disarm is the trigger. Connection loss is now tracked (set_connected was write-once), and the reconnect edge resets FTP sessions and re-lists: a rebooted vehicle has finished the log it was writing. Closes #24.
…emantics present_before was computed after UPDATE logs SET present = 0, so it was always zero: every identical listing bumped the revision and pushed the full log list to every open stream per pass, while a card wiped clean -- present_after also zero -- bumped nothing and left the UI showing logs the vehicle no longer has. Found in review; the new test pins both directions, plus that the legacy import never carries intent.
httplib::Client takes scheme://host:port and nothing more; the trailing slash the old flat config format shipped with fails its parse, and every request to the migrated remote reported the server unreachable. Found on hardware upgrading a real config.
An unauthorized account is a stable state, and each attempt uploads the entire log just to be told no again: on hardware, a vehicle with remote enabled and no approved account posted the newest log to the server every ten seconds indefinitely, and the hammering started drawing 503s from the server's edge. One attempt per five minutes now, reported once.
dakejahl
added a commit
to ARK-Electronics/ARK-OS
that referenced
this pull request
Aug 6, 2026
Points at logloader main after ARK-Electronics/logloader#32: MAVLink FTP transport, request-driven fetching, event-driven indexing via MAV_SYS_STATUS_LOGGING, the HTTP API the Logs page drives, and the review fixes.
dakejahl
added a commit
to ARK-Electronics/ARK-OS
that referenced
this pull request
Aug 6, 2026
…gs (#109) * feat(logs-page): add a Logs page for browsing and fetching vehicle logs ## Summary Adds a Logs page that lists every log logloader knows about -- on the vehicle, downloaded, uploaded -- and lets the operator pick which ones to fetch and publish. Uploaded logs link straight to the plot in the onboard Flight Review. ## Problem logloader used to decide on its own what to download and upload, and there was no way to see what it had or to ask for a particular log. Its counterpart change makes fetching request-driven, which needs somewhere to make requests from. ## Solution logloader now serves a small localhost API on :3005; the gateway proxies it at /api/logloader, including the SSE stream that drives live transfer progress. The page follows the existing house patterns: Options API, an axios service module, an inline EventSource with named events, and the SystemPage loading/error/retry triad. Select-all or a subset, then Download & Upload, Download Only, Upload, or Cancel. A log that is still being written by the vehicle never appears, so it cannot be selected at a size it will not keep. Verified against PX4 SITL and a local flight-review: first-launch queues only the newest log, select-all fetches and publishes the remaining four, and each lands byte-identical with a working plot link. * fix(packaging): keep logloader upload settings across the config restructure ## Summary Adds a one-time translation of logloader.toml from its flat keys to the nested layout, run from postinst before merge_configs.py. ## Problem merge_configs takes its structure from the new template and prunes scalars the template does not have. That is the right rule for a removed field and the wrong one for a renamed field. Simulating an upgrade of a config with remote_server = "https://review.arkelectron.com" email = "pilot@example.com" upload_enabled = true public_logs = true produced a merged file with [upload.remote] enabled = false and the stock review.px4.io url: a vehicle configured to upload to a remote Flight Review would silently stop after the upgrade. logloader still reads the flat keys as a fallback, but that does not help when the merge has already deleted them. ## Solution migrate_logloader_config.py rewrites the flat keys into their new homes, in both the live config and the pre-upgrade backup, so what reaches merge_configs is an ordinary same-shape merge. It is idempotent, leaves an already-nested file alone, and never fails an upgrade. * fix(logs-page): unbreak SSE in dev, and close the integration review findings ## Summary Follow-up to the Logs page covering an integration review, plus one bug that turns out to affect every SSE consumer in the app, not just this page. ## Problem The dev server gzips responses by default and its compression middleware buffers until it has enough bytes, which for an SSE stream is never. Measured through `npm run serve` with a browser's Accept-Encoding: 0 bytes and an EventSource stuck at readyState 0; the same request through the gateway returned immediately. That silently breaks the journal viewer, the network stats stream and the firmware progress stream in dev as well. Separately, http-proxy-middleware v3 reads handlers from `on`, so the v2 `onError`/`logLevel` options every proxy block used were dead: a stopped upstream answered with the default plain-text 504 rather than the JSON the UI parses, and the page reported "Request failed with status code 504". ## Solution `compress: false` on the dev server, and `on: { error }` for all five proxies. On the page: an upload records only the relative path Flight Review redirects to, so targets now carry their base url and a remote plot link resolves against the right origin instead of the ARK-OS host. A dropped stream is reported rather than leaving a frozen table under a green "connected" dot; confirmations expire; deleting a downloaded log asks first; an upload request names only the targets that actually need the log; the select-all box is disabled on an empty table; and the bokeh websocket is proxied in dev so a plot link works there too. logloader's config tables are one level deep now, because the shared TomlEditor renders exactly one level — with two, every upload setting silently vanished from the Services page config editor. The editor also says so now instead of rendering nothing, and the upgrade migration writes to the flattened names. * feat(logs-page): add a Refresh action and show the logging state logloader no longer polls the vehicle, so the page asks for a listing when it loads and offers a Refresh button; the new logging indicator mirrors MAV_SYS_STATUS_LOGGING so it is visible why transfers wait. * fix(logs-page): survive logloader restarts, keep use_burst across upgrades Supplying on.error in http-proxy-middleware v3 replaces its error plugin, guards included: an upstream dying mid-SSE made writeHead throw on a response whose headers were long gone, an uncaught exception in a socket callback that took the whole gateway down. The guards live in our handler now, shared by all five proxies. EventSource only retries network failures; the gateway's 502 while the daemon restarts closes it for good, so the page retries that case itself and resyncs what the dead stream missed. merge_configs prunes scalars the template lacks, and [download] use_burst was not in it -- an operator's ftp_use_burst=false survived the rename migration only to be deleted by the merge that follows it. * chore(logloader): bump the submodule to the merged rewrite Points at logloader main after ARK-Electronics/logloader#32: MAVLink FTP transport, request-driven fetching, event-driven indexing via MAV_SYS_STATUS_LOGGING, the HTTP API the Logs page drives, and the review fixes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Supersedes #29, #30 and #31 — this branch contains their commits unchanged, with authorship intact, so merging this merges all three. They can be closed.
Reviewing that stack turned up structural problems in the code underneath it, and fixing those meant rewriting the parts they sit on, so keeping four PRs would have meant reviewing #29's
ServerInterfacechanges and then reviewing their replacement. One branch instead: MAVLink FTP for transport, fetching driven by request rather than by mirroring the SD card, and an HTTP API for the ARK-OS Logs page to drive.Verified end to end against PX4 SITL and on hardware: a Jetson upgrading a real pre-FTP install against an ARK FMU v6X on PX4 1.18, exercising the legacy import, the logging-stop and reconnect triggers, armed gating, upload-server outages, and the Logs page through the ARK-OS gateway. Adds tests and CI, which the repo had none of.
Problem
Transport.
LOG_DATA(msg 120) has notarget_system/target_component, so a router between logloader and the autopilot has no addressing to work with and copies every chunk to every endpoint it serves, telemetry radio included.LOG_ENTRYcompounds it: it identifies a log by index and timestamp rather than by file, so anything downloading over FTP while listing over the log protocol has to map one onto the other by size, mtime or list ordering — and ArduPilot's numbering, itsLOG_MAX_FILESwrap and its.BINnaming all have to be modelled to do it. When that mapping guesses wrong, the wrong file transfers in full and is discarded.Structure.
ServerInterfacewas 860 lines doing two unrelated jobs: a SQLite repository and a Flight Review HTTP client. About 70% of it was database code and the name described the other 30%. Because the database lived inside the upload target, download state was duplicated acrosslocal_server.dbandremote_server.db, andLogLoaderkept them in step by calling every mutation twice. The download queue was read from whichever happened to be first.Behaviour. logloader tried to mirror the vehicle's SD card. A companion powered on for the first time, or one whose database had been reset, would pull and upload the entire history over a link with better things to do — and there was no way to see what it had or to ask for a particular log. It also polled, listing the card on a timer whether or not anything could have changed, and retried failed uploads in a tight loop: on the bench rig journald had suppressed 93,373 messages from the old daemon hammering an offline flight-review.
Defects. A pending log whose local file was missing or empty was retried forever and blocked every older log behind it; only HTTP 400 escaped the queue.
ServerInterface::_should_exitandLogLoader::_loop_disabledwere plain bools written by one thread and read by another. The signal handler took a mutex and signalled a condition variable, neither of which is async-signal-safe.CMAKE_BUILD_TYPEwas never set, so the shipped binary was built at-O0.generate_uuidhashed withstd::hash— implementation-defined — into state persisted on disk.Solution
Everything over MAVLink FTP (
FILE_TRANSFER_PROTOCOL, msg 110), for the listing and the transfer. It is addressed, and both stacks reply to the requesting sysid/compid, so transfers are unicast. Identity becomes path-below-the-log-root plus size, so there is no cross-protocol mapping left to get wrong and ArduPilot needs no special handling — a directory listing just reports files. The root is probed at@MAV_LOG, then/fs/microsd/log,/APM/LOGSand/log. MAVSDK'sFtpplugin discards size and mtime from list replies, soFtpListClientruns the listing overMavlinkPassthrough; worth upstreaming so it can be deleted.Storage split from transport.
LogDatabaseowns the inventory and the intent,UploadTargetis one Flight Review endpoint with no state of its own, andLegacyImportquarantines the pre-FTP migration behind a header saying it is deletable once the fleet has upgraded. One database, one place to look.Fetching is request-driven. A log is fetched because it appeared while logloader was watching — a flight just ended — or because something asked. On a database that has never seen the vehicle, only the newest is taken. A listing with more than
download.max_auto_queuenew logs is an unfamiliar card rather than a burst of flights, so it queues one and says so. Intent lives in the database, so automatic and requested work share one path.Indexing is event-driven (closes #24). The vehicle is never polled: it is listed when the autopilot connection comes up or comes back, when a flight or logging session ends, and when the API asks (
POST /refresh). A flight ending is read fromMAV_SYS_STATUS_LOGGINGinSYS_STATUS, which PX4 v1.16 and newer raise only while the logger is actually writing — catching logging that runs past disarm or never involved arming (SDLOG_MODE). Older firmware falls back to the disarm transition; ArduPilot raises the bit whenever logging is merely configured, so there it is ignored and disarm is the trigger. Each trigger buys a short burst of listings a few seconds apart — two must agree on a log's size before it counts — and then the loop sleeps; on the bench, 60 seconds of idle fan-out traffic contained zeroFILE_TRANSFER_PROTOCOLmessages. Armed still pauses indexing and transfers, with owed passes kept for disarm. Connection loss is now actually tracked —set_connectedwas write-once — and the reconnect edge resets FTP sessions, since a rebooted PX4 can hold a stale one open.An HTTP API on
127.0.0.1:3005:GET /logs,/status,/events(SSE),POST /logs/{download,upload,cancel},DELETE /logs/{id}/file. ARK-Electronics/ARK-OS#109 is the page that drives it.Uploads stream off disk with a computed
Content-Length. httplib'sMultipartFormDataItemsoverload concatenates the whole body into a second string; a 400 MB log needed most of a gigabyte on a Pi, and the resulting OOM kill retried the same log on restart. Measured: 16 MB RSS while uploading a 76 MB log.Also fixed, most of it found by review or by the SITL and hardware runs: a use-after-free where
FtpListClienthanded MAVSDK athis-capturing callback (MAVSDK unsubscribes asynchronously, so a reply arriving after shutdown wrote into freed heap); an ODR violation from definingCPPHTTPLIB_OPENSSL_SUPPORTper-file, which gave two translation units different socket layouts and segfaulted on the first real run;Transaction::commitclearing its flag beforeCOMMIT, so a failed commit left the transaction open and every later write was silently discarded while the daemon looked healthy; a missingALTER TABLEfor a column added to an existing schema; a log still being written being treated as absent, which marked every row "not on vehicle" for one interval after each restart; a change-detection inversion insync_index(present rows counted after the wipe, so every identical listing pushed the full log list to every open stream while an emptied card notified nobody); and upload target urls with a trailing slash — which the old flat config shipped — failing httplib'sscheme://host:portparse, so a healthy server was reported unreachable forever. That last one was found upgrading the bench rig's real config.Rows are marked absent rather than deleted when a log leaves the vehicle, so a downloaded log keeps its file and its upload history. Config tables are one level deep on purpose — ARK-OS's shared config editor renders exactly one level, and a setting the operator cannot reach from the web UI may as well not exist. The old flat keys are still read, with a deprecation warning.
Upgrades. Pre-FTP databases keyed logs on the
LOG_ENTRYtimestamp, which FTP cannot reproduce.local_server.dbandremote_server.dbare folded into onelogloader.dband matched to the listing by size, so a fleet upgrading does not re-download and re-upload its history. A log the old version had downloaded but not yet uploaded keeps its file and its state but is not queued — carrying that intent over would produce exactly the flood this version exists to avoid.tests/test_log_database.cppcovers 17 cases including the legacy import, which runs once against real user data and cannot be un-run, and the revision semantics the SSE stream depends on. Writing them found two defects on its own.Carried over from #30 and #31
The reachability cooldown and the API-key headers are preserved in
UploadTarget, with one change: 401/403 no longer blacklist a log. Those statuses mean this account is not authorized yet, not this log is bad, and the expected first-run state returns 403 — blacklisting would silently discard one pending log per cycle. The batch still stops on them, which resumes on its own once the key is valid.Worth flagging: review.arkelectron.com now answers uploads with 403 "requires a registered, approved account (login session or API key)", so the server side has grown key support since this was first written. A key set in
upload_remote.api_keyis sent as bothAuthorization: BearerandX-API-Key; whether those header names match the server's expectation is untested — no approved key was available on the bench. The 403 path itself behaved as designed there: one warning, the batch paused, and the log stayed queued to resume once a key is configured.Follow-ups
-x; FTP is now load-bearing.