Files
chia-blockchain/chia/data_layer/download_data.py
Matt HauffandGitHub 40db4635a8 [LABS-245] Enable PEP604 Ruff rules (#20269)
* Enable PEP604 Ruff rules

* Fix harcoded signature in test

* Hack CLVMStreamable test with note to fast follow
2025-11-18 12:34:00 -08:00

318 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
import aiohttp
from chia_rs.datalayer import DeltaReader
from chia_rs.sized_bytes import bytes32
from chia.data_layer.data_layer_util import (
PluginRemote,
Root,
ServerInfo,
get_delta_filename,
get_delta_filename_path,
get_full_tree_filename,
get_full_tree_filename_path,
)
from chia.data_layer.data_store import DataStore
from chia.util.log_exceptions import log_exceptions
def is_filename_valid(filename: str, group_by_store: bool = False) -> bool:
if group_by_store:
if filename.count("/") != 1:
return False
filename = filename.replace("/", "-")
split = filename.split("-")
try:
raw_store_id, raw_node_hash, file_type, raw_generation, raw_version, *rest = split
store_id = bytes32(bytes.fromhex(raw_store_id))
node_hash = bytes32(bytes.fromhex(raw_node_hash))
generation = int(raw_generation)
except ValueError:
return False
if len(rest) > 0:
return False
# TODO: versions should probably be centrally defined
if raw_version != "v1.0.dat":
return False
if file_type not in {"delta", "full"}:
return False
generate_file_func = get_delta_filename if file_type == "delta" else get_full_tree_filename
reformatted = generate_file_func(
store_id=store_id, node_hash=node_hash, generation=generation, group_by_store=False
)
return reformatted == filename
@dataclass
class WriteFilesResult:
result: bool
full_tree: Path | None
diff_tree: Path
async def write_files_for_root(
data_store: DataStore,
store_id: bytes32,
root: Root,
foldername: Path,
full_tree_first_publish_generation: int,
overwrite: bool = False,
group_by_store: bool = False,
) -> WriteFilesResult:
if root.node_hash is not None:
node_hash = root.node_hash
else:
node_hash = bytes32.zeros # todo change
filename_full_tree = get_full_tree_filename_path(foldername, store_id, node_hash, root.generation, group_by_store)
filename_diff_tree = get_delta_filename_path(foldername, store_id, node_hash, root.generation, group_by_store)
filename_full_tree.parent.mkdir(parents=True, exist_ok=True)
written = False
mode: Literal["wb", "xb"] = "wb" if overwrite else "xb"
written_full_file = False
if root.generation >= full_tree_first_publish_generation:
try:
with open(filename_full_tree, mode) as writer:
await data_store.write_tree_to_file(root, node_hash, store_id, False, writer)
written = True
written_full_file = True
except FileExistsError:
pass
try:
with open(filename_diff_tree, mode) as writer:
await data_store.write_tree_to_file(root, node_hash, store_id, True, writer)
written = True
except FileExistsError:
pass
return WriteFilesResult(written, filename_full_tree if written_full_file else None, filename_diff_tree)
async def download_file(
data_store: DataStore,
target_filename_path: Path,
store_id: bytes32,
root_hash: bytes32,
generation: int,
server_info: ServerInfo,
proxy_url: str | None,
downloader: PluginRemote | None,
timeout: aiohttp.ClientTimeout,
client_foldername: Path,
timestamp: int,
log: logging.Logger,
grouped_by_store: bool,
group_downloaded_files_by_store: bool,
) -> bool:
if target_filename_path.exists():
return True
filename = get_delta_filename(store_id, root_hash, generation, grouped_by_store)
if downloader is None:
# use http downloader - this raises on any error
try:
await http_download(target_filename_path, filename, proxy_url, server_info, timeout, log)
except (asyncio.TimeoutError, aiohttp.ClientError):
new_server_info = await data_store.server_misses_file(store_id, server_info, timestamp)
log.info(
f"Failed to download {filename} from {new_server_info.url}."
f"Miss {new_server_info.num_consecutive_failures}."
)
log.info(f"Next attempt from {new_server_info.url} in {new_server_info.ignore_till - timestamp}s.")
return False
return True
log.info(f"Using downloader {downloader} for store {store_id.hex()}.")
request_json = {
"url": server_info.url,
"client_folder": str(client_foldername),
"filename": filename,
"group_files_by_store": group_downloaded_files_by_store,
}
async with aiohttp.ClientSession() as session:
async with session.post(
downloader.url + "/download",
json=request_json,
headers=downloader.headers,
) as response:
res_json = await response.json()
assert isinstance(res_json["downloaded"], bool)
return res_json["downloaded"]
async def insert_from_delta_file(
data_store: DataStore,
store_id: bytes32,
existing_generation: int,
target_generation: int,
root_hashes: list[bytes32],
server_info: ServerInfo,
client_foldername: Path,
timeout: aiohttp.ClientTimeout,
log: logging.Logger,
proxy_url: str | None,
downloader: PluginRemote | None,
group_files_by_store: bool = False,
maximum_full_file_count: int = 1,
) -> bool:
if group_files_by_store:
client_foldername.joinpath(f"{store_id}").mkdir(parents=True, exist_ok=True)
delta_reader: DeltaReader | None = None
for root_hash in root_hashes:
timestamp = int(time.time())
existing_generation += 1
target_filename_path = get_delta_filename_path(
client_foldername, store_id, root_hash, existing_generation, group_files_by_store
)
filename_exists = target_filename_path.exists()
for grouped_by_store in (False, True):
success = await download_file(
data_store=data_store,
target_filename_path=target_filename_path,
store_id=store_id,
root_hash=root_hash,
generation=existing_generation,
server_info=server_info,
proxy_url=proxy_url,
downloader=downloader,
timeout=timeout,
client_foldername=client_foldername,
timestamp=timestamp,
log=log,
grouped_by_store=grouped_by_store,
group_downloaded_files_by_store=group_files_by_store,
)
if success:
break
else:
return False
log.info(f"Successfully downloaded delta file {target_filename_path.name}.")
try:
with log_exceptions(log=log, message="exception while inserting from delta file"):
filename_full_tree = get_full_tree_filename_path(
client_foldername,
store_id,
root_hash,
existing_generation,
group_files_by_store,
)
delta_reader = await data_store.insert_into_data_store_from_file(
store_id,
None if root_hash == bytes32.zeros else root_hash,
target_filename_path,
delta_reader=delta_reader,
)
log.info(
f"Successfully inserted hash {root_hash} from delta file. "
f"Generation: {existing_generation}. Store id: {store_id}."
)
if target_generation - existing_generation <= maximum_full_file_count - 1:
root = await data_store.get_tree_root(store_id=store_id)
with open(filename_full_tree, "wb") as writer:
await data_store.write_tree_to_file(root, root_hash, store_id, False, writer)
log.info(f"Successfully written full tree filename {filename_full_tree}.")
else:
log.info(f"Skipping full file generation for {existing_generation}")
await data_store.received_correct_file(store_id, server_info)
except Exception:
try:
target_filename_path.unlink()
except FileNotFoundError:
pass
try:
filename_full_tree.unlink()
except FileNotFoundError:
pass
# await data_store.received_incorrect_file(store_id, server_info, timestamp)
# incorrect file bans for 7 days which in practical usage
# is too long given this file might be incorrect for various reasons
# therefore, use the misses file logic instead
if not filename_exists:
# Don't penalize this server if we didn't download the file from it.
await data_store.server_misses_file(store_id, server_info, timestamp)
return False
return True
def delete_full_file_if_exists(foldername: Path, store_id: bytes32, root: Root) -> bool:
if root.node_hash is not None:
node_hash = root.node_hash
else:
node_hash = bytes32.zeros # todo change
not_found = 0
for group_by_store in (True, False):
filename_full_tree = get_full_tree_filename_path(
foldername, store_id, node_hash, root.generation, group_by_store
)
try:
filename_full_tree.unlink()
except FileNotFoundError:
not_found += 1
# File does not exist in both old and new path.
if not_found == 2:
return False
return True
async def http_download(
target_filename_path: Path,
filename: str,
proxy_url: str | None,
server_info: ServerInfo,
timeout: aiohttp.ClientTimeout,
log: logging.Logger,
) -> None:
"""
Download a file from a server using aiohttp.
Raises exceptions on errors
"""
async with aiohttp.ClientSession() as session:
headers = {"accept-encoding": "gzip"}
async with session.get(
server_info.url + "/" + filename,
headers=headers,
timeout=timeout,
proxy=proxy_url,
) as resp:
resp.raise_for_status()
size = int(resp.headers.get("content-length", 0))
log.debug(f"Downloading delta file {filename}. Size {size} bytes.")
progress_byte = 0
progress_percentage = f"{0:.0%}"
with target_filename_path.open(mode="wb") as f:
async for chunk, _ in resp.content.iter_chunks():
f.write(chunk)
progress_byte += len(chunk)
new_percentage = f"{progress_byte / size:.0%}"
if new_percentage != progress_percentage:
progress_percentage = new_percentage
log.info(f"Downloading delta file {filename}. {progress_percentage} of {size} bytes.")