diff --git a/newsfragments/343.feature b/newsfragments/343.feature new file mode 100644 index 000000000..64358cc1c --- /dev/null +++ b/newsfragments/343.feature @@ -0,0 +1 @@ +Estimate remaining sync time diff --git a/src/magic_folder/cli.py b/src/magic_folder/cli.py index 46f40f631..167504cf3 100644 --- a/src/magic_folder/cli.py +++ b/src/magic_folder/cli.py @@ -342,7 +342,10 @@ def message(payload, is_binary=False): print(" {}".format( ", ".join(d["relpath"] for d in folder["downloads"]) )) - print(" uploads: {}".format(len(folder["uploads"]))) + remaining = "" + if folder["remaining-upload-time"] is not None: + remaining = " ({:0.1f}s remaining)".format(folder["remaining-upload-time"]) + print(" uploads: {}{}".format(len(folder["uploads"]), remaining)) for u in folder["uploads"]: queue = humanize.naturaldelta(now - u["queued-at"]) start = " (started {} ago)".format(humanize.naturaldelta(now - u["started-at"])) if "started-at" in u else "" diff --git a/src/magic_folder/config.py b/src/magic_folder/config.py index 19774cec0..7eda5ef84 100644 --- a/src/magic_folder/config.py +++ b/src/magic_folder/config.py @@ -118,6 +118,7 @@ from .util.file import ( PathState, ns_to_seconds, + ns_to_seconds_float, seconds_to_ns, ) @@ -1294,6 +1295,38 @@ def get_tahoe_object_sizes(self, cursor): ]) return sizes + @with_cursor + def get_recent_upload_speed(self, cursor): + """ + Average some of our recently uploaded files and return the upload + speed (in bytes per second). If we've never uploaded a file + this is None. + + :returns int: bytes per second (or None if we've never uploaded) + """ + cursor.execute( + """ + SELECT + last_updated_ns, size, upload_duration_ns + FROM + [current_snapshots] + ORDER BY + last_updated_ns DESC + LIMIT + 10 + """ + ) + total_size = 0 + total_duration = 0 + for _, size, duration_ns in cursor.fetchall(): + if duration_ns is None: + continue + total_size += size + total_duration += ns_to_seconds_float(duration_ns) + if total_duration == 0 or total_size == 0: + return None + return float(total_size) / total_duration + @with_cursor def get_recent_remotesnapshot_paths(self, cursor, n): """ diff --git a/src/magic_folder/status.py b/src/magic_folder/status.py index 9a7bda26d..b2a912d77 100644 --- a/src/magic_folder/status.py +++ b/src/magic_folder/status.py @@ -326,6 +326,22 @@ def client_disconnected(self, protocol): """ self._clients.remove(protocol) + def upload_seconds_remaining(self, folder_name): + """ + Estimate the number of seconds remaining for any pending uploads. + """ + config = self._config.get_magic_folder(folder_name) + folder = self._folders[folder_name] + + remaining_size = 0 + for relpath, ps, _, _ in config.get_all_current_snapshot_pathstates(): + if relpath in folder["uploads"]: + remaining_size += ps.size + recent_speed = config.get_recent_upload_speed() + if recent_speed is None: + return None + return remaining_size / recent_speed + def _marshal_state(self): """ Internal helper. Turn our current notion of the state into a @@ -386,6 +402,7 @@ def uploads_and_downloads(): for err in self._folders.get(name, {}).get("errors", []) ], "recent": most_recent, + "remaining-upload-time": self.upload_seconds_remaining(name), "tahoe": { "happy": self._tahoe.is_happy, "connected": self._tahoe.connected, diff --git a/src/magic_folder/test/test_config.py b/src/magic_folder/test/test_config.py index 1a518bd61..607996afe 100644 --- a/src/magic_folder/test/test_config.py +++ b/src/magic_folder/test/test_config.py @@ -1196,6 +1196,46 @@ def test_limit(self): ) ) + def test_estimate_remaining_time_empty(self): + """ + If there are no recent snapshots there's no time estimate + """ + self.assertThat( + self.db.get_recent_upload_speed(), + Equals(None), + ) + + def test_estimate_remaining_time(self): + """ + Add several RemoteSnapshots and ensure times are estimated + """ + relpath = "time_estimate" + # a consistent current-time so we can compute exact duration + self.patch(self.db, '_get_current_timestamp', lambda: 42) + # 3000 bytes in each file, 30 seconds per upload + sizes = (3000, 3000, 3000) + for size in sizes: + remote = RemoteSnapshot( + relpath, + self.author, + {"relpath": relpath, "modification_time": 0}, + "URI:DIR2-CHK:", + [], + "URI:CHK:", + "URI:CHK:", + ) + self.db.store_currentsnapshot_state( + relpath, + PathState(size, seconds_to_ns(0), seconds_to_ns(0)), + ) + # "upload_started_at=12" means each snapshot took (42 - 12) seconds to upload + self.db.store_uploaded_snapshot(relpath, remote, 12) + + self.assertThat( + int(self.db.get_recent_upload_speed()), + Equals(sum(sizes) / (30 * 3)), + ) + class ConflictTests(SyncTestCase): """