osm-git

A WIP POC based on the idea from https://blog.andygol.co.ua/en/2023/05/07/osm-2-0-api-using-git/
git clone git://archive.git.mtrnord.blog/MTRNord/osm-git.git
Log | Files | Refs | LICENSE

commit 95d2e15e370d51c762a37eda4613acbef229ac4e
parent 47198d5d6b1fa5db80778b07e8cec69a6f87f588
Author: MTRNord <mtrnord1@gmail.com>
Date:   Mon, 22 May 2023 12:48:31 +0200

Make sure that we write the commits properly and make sure to not load all changesets at once into ram since that will be ~200GB. Instead we load single files at a time

Diffstat:
MCargo.lock | 10++++++++++
MCargo.toml | 1+
Msrc/git/mod.rs | 1+
Msrc/main.rs | 224+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Msrc/osm/changesets.rs | 5++---
Msrc/osm/osm_data.rs | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
6 files changed, 294 insertions(+), 188 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -751,6 +751,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" [[package]] +name = "memmap2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aa1b505aeecb0adb017db2b6a79a17a38e64f882a201f05e9de8a982cd6096" +dependencies = [ + "libc", +] + +[[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -848,6 +857,7 @@ dependencies = [ "color-eyre", "flate2", "git2", + "memmap2", "quick-xml", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml @@ -11,6 +11,7 @@ clap = { version = "4.3.0", features = ["derive"] } color-eyre = "0.6.2" flate2 = "1.0.26" git2 = "0.17.1" +memmap2 = "0.6.1" quick-xml = { version = "0.28.2", features = ["async-tokio", "encoding", "escape-html", "overlapped-lists"] } reqwest = { version = "0.11.18", default-features = false, features = ["rustls-tls", "gzip", "stream", "trust-dns"] } serde = { version = "1.0.163", features = ["derive"] } diff --git a/src/git/mod.rs b/src/git/mod.rs @@ -107,6 +107,7 @@ pub fn commit( for file in removed_files { index.remove_path(std::path::Path::new(&file))?; } + index.write()?; index.write_tree()? }; let tree = repository.find_tree(tree_id)?; diff --git a/src/main.rs b/src/main.rs @@ -1,14 +1,12 @@ -use std::time::Duration; +use std::{fs::File, time::Duration}; use clap::Parser; use color_eyre::eyre::Result; use git2::Signature; +use memmap2::Mmap; use tracing::{info, warn}; -use crate::{ - git::init_git_repository, - osm::{changesets::parse_changeset, osm_data::convert_objects_to_git}, -}; +use crate::{git::init_git_repository, osm::osm_data::convert_objects_to_git}; mod git; mod osm; @@ -88,7 +86,6 @@ async fn main() -> Result<()> { let mut changeset_position_middle_incremented = false; let mut changeset_position_top_incremented = false; - let mut changesets = Vec::new(); loop { // Check for cache and use it if it exists let cache_file_path = format!( @@ -100,68 +97,74 @@ async fn main() -> Result<()> { ); if std::path::Path::new(&cache_file_path).exists() { - info!("Using cached changeset file at {}", cache_file_path); - let changeset_data = std::fs::read(&cache_file_path)?; - let parsed_changeset = parse_changeset(changeset_data.into())?; - info!("Changeset file parsed"); - changesets.extend(parsed_changeset); + info!( + "We already got the changeset file at {}. Skipping", + cache_file_path + ); // Increment the changeset position changeset_position_bottom += 1; changeset_position_middle_incremented = false; changeset_position_top_incremented = false; } else { - // First we download the changeset files - let changeset_url = format!( - "{}/{:03}/{:03}/{:03}.osm.gz", - cli.changeset_server, - changeset_position_top, - changeset_position_middle, - changeset_position_bottom - ); - info!("Downloading changeset file from {}", changeset_url); - let changeset_response: reqwest::Response = client.get(&changeset_url).send().await?; - if changeset_response.status() == reqwest::StatusCode::NOT_FOUND { - warn!("Changeset file not found at {}", changeset_url); - // We've reached the end of the changesets for this bottom position. - // If we incremented top and failed again, we're done. - if changeset_position_top_incremented { - info!("Finished or failed downloading changesets"); - info!( - "Changeset position: {} {} {}", - changeset_position_top, - changeset_position_middle, - changeset_position_bottom - ); - warn!("Response body: {:?}", changeset_response.text().await?); - // TODO: We want to have an endless loop here optionally so that we can keep trying to download changesets. - break; + { + // First we download the changeset files + let changeset_url = format!( + "{}/{:03}/{:03}/{:03}.osm.gz", + cli.changeset_server, + changeset_position_top, + changeset_position_middle, + changeset_position_bottom + ); + info!("Downloading changeset file from {}", changeset_url); + let changeset_response: reqwest::Response = + client.get(&changeset_url).send().await?; + if changeset_response.status() == reqwest::StatusCode::NOT_FOUND { + warn!("Changeset file not found at {}", changeset_url); + // We've reached the end of the changesets for this bottom position. + // If we incremented top and failed again, we're done. + if changeset_position_top_incremented { + info!("Finished or failed downloading changesets"); + info!( + "Changeset position: {} {} {}", + changeset_position_top, + changeset_position_middle, + changeset_position_bottom + ); + warn!("Response body: {:?}", changeset_response.text().await?); + // TODO: We want to have an endless loop here optionally so that we can keep trying to download changesets. + break; + } + // We reset bottom to 0 and increment middle. + // We also mark middle as incremented so that we increment top on the next failure. + if !changeset_position_middle_incremented && changeset_position_bottom != 0 { + changeset_position_bottom = 0; + changeset_position_middle += 1; + changeset_position_middle_incremented = true; + changeset_position_top_incremented = false; + } else { + changeset_position_middle_incremented = false; + changeset_position_top += 1; + changeset_position_top_incremented = true; + changeset_position_middle = 0; + changeset_position_bottom = 0; + } + continue; } - // We reset bottom to 0 and increment middle. - // We also mark middle as incremented so that we increment top on the next failure. - if !changeset_position_middle_incremented && changeset_position_bottom != 0 { - changeset_position_bottom = 0; - changeset_position_middle += 1; - changeset_position_middle_incremented = true; - changeset_position_top_incremented = false; - } else { - changeset_position_middle_incremented = false; - changeset_position_top += 1; - changeset_position_top_incremented = true; - changeset_position_middle = 0; - changeset_position_bottom = 0; - } - continue; - } - let changeset_data = changeset_response.bytes().await?; - info!("Caching changeset file to disk"); - std::fs::create_dir_all(std::path::Path::new(&cache_file_path).parent().unwrap())?; - std::fs::write(&cache_file_path, &changeset_data)?; - info!("Changeset file downloaded"); + let changeset_data = changeset_response.bytes().await?; + info!("Caching changeset file to disk"); + std::fs::create_dir_all(std::path::Path::new(&cache_file_path).parent().unwrap())?; + std::fs::write(&cache_file_path, &changeset_data)?; + info!("Changeset file downloaded"); + }; + + // TODO: We need to dynamically do this based on the data instead. Otherwise we dont have ram larrge enough + // let file = File::open(cache_file_path)?; + // let changeset_data = unsafe { Mmap::map(&file)? }; - let parsed_changeset = parse_changeset(changeset_data)?; - info!("Changeset file parsed"); - changesets.extend(parsed_changeset); + // let parsed_changeset = parse_changeset(&changeset_data)?; + // info!("Changeset file parsed"); + // changesets.extend(parsed_changeset); // Increment the changeset position changeset_position_bottom += 1; @@ -193,8 +196,9 @@ async fn main() -> Result<()> { if std::path::Path::new(&cache_file_path).exists() { info!("Using cached data file at {}", cache_file_path); - let data = std::fs::read(&cache_file_path)?; - convert_objects_to_git(&repository, &author, &changesets, &data)?; + let file = File::open(&cache_file_path)?; + let data = unsafe { Mmap::map(&file)? }; + convert_objects_to_git(&repository, &author, &data, cli.cache_path.clone())?; info!("Data file parsed"); // Increment the data position @@ -202,55 +206,61 @@ async fn main() -> Result<()> { data_position_middle_incremented = false; data_position_top_incremented = false; } else { - // Download minute replication files and find the changesets that were modified in that minute - let data_url = format!( - "{}/{:03}/{:03}/{:03}.osc.gz", - cli.replication_server, - data_position_top, - data_position_middle, - data_position_bottom - ); - info!("Downloading data file from {}", data_url); - let data_response: reqwest::Response = client.get(&data_url).send().await?; + { + // Download minute replication files and find the changesets that were modified in that minute + let data_url = format!( + "{}/{:03}/{:03}/{:03}.osc.gz", + cli.replication_server, + data_position_top, + data_position_middle, + data_position_bottom + ); + info!("Downloading data file from {}", data_url); + let data_response: reqwest::Response = client.get(&data_url).send().await?; - if data_response.status() == reqwest::StatusCode::NOT_FOUND { - warn!("data file not found at {}", data_url); - // We've reached the end of the data for this bottom position. - // If we incremented top and failed again, we're done. - if data_position_top_incremented { - info!("Finished or failed downloading data"); - info!( - "Data position: {} {} {}", - data_position_top, data_position_middle, data_position_bottom - ); - warn!("Response body: {:?}", data_response.text().await?); + if data_response.status() == reqwest::StatusCode::NOT_FOUND { + warn!("data file not found at {}", data_url); + // We've reached the end of the data for this bottom position. + // If we incremented top and failed again, we're done. + if data_position_top_incremented { + info!("Finished or failed downloading data"); + info!( + "Data position: {} {} {}", + data_position_top, data_position_middle, data_position_bottom + ); + warn!("Response body: {:?}", data_response.text().await?); - // TODO: We want to have an endless loop here optionally so that we can keep trying to download changesets. - break; - } - // We reset bottom to 0 and increment middle. - // We also mark middle as incremented so that we increment top on the next failure. - if !data_position_middle_incremented && data_position_bottom != 0 { - data_position_bottom = 0; - data_position_middle += 1; - data_position_middle_incremented = true; - data_position_top_incremented = false; - } else { - data_position_middle_incremented = false; - data_position_top += 1; - data_position_top_incremented = true; - data_position_middle = 0; - data_position_bottom = 0; + // TODO: We want to have an endless loop here optionally so that we can keep trying to download changesets. + break; + } + // We reset bottom to 0 and increment middle. + // We also mark middle as incremented so that we increment top on the next failure. + if !data_position_middle_incremented && data_position_bottom != 0 { + data_position_bottom = 0; + data_position_middle += 1; + data_position_middle_incremented = true; + data_position_top_incremented = false; + } else { + data_position_middle_incremented = false; + data_position_top += 1; + data_position_top_incremented = true; + data_position_middle = 0; + data_position_bottom = 0; + } + continue; } - continue; - } - let data = data_response.bytes().await?; - info!("Caching Data file to disk"); - std::fs::create_dir_all(std::path::Path::new(&cache_file_path).parent().unwrap())?; - std::fs::write(&cache_file_path, &data)?; - info!("Data file downloaded"); - convert_objects_to_git(&repository, &author, &changesets, &data)?; + let data = data_response.bytes().await?; + info!("Caching Data file to disk"); + std::fs::create_dir_all(std::path::Path::new(&cache_file_path).parent().unwrap())?; + std::fs::write(&cache_file_path, &data)?; + info!("Data file downloaded"); + }; + + let file = File::open(cache_file_path)?; + let data = unsafe { Mmap::map(&file)? }; + + convert_objects_to_git(&repository, &author, &data, cli.cache_path.clone())?; // Increment the data position data_position_bottom += 1; diff --git a/src/osm/changesets.rs b/src/osm/changesets.rs @@ -1,4 +1,3 @@ -use bytes::Bytes; use color_eyre::eyre::Result; use flate2::bufread::GzDecoder; use quick_xml::{ @@ -137,13 +136,13 @@ impl Changeset { } } -pub fn parse_changeset(changeset_data: Bytes) -> Result<Vec<Changeset>> { +pub fn parse_changeset(changeset_data: &[u8]) -> Result<Vec<Changeset>> { // If file is empty, return an empty vector if changeset_data.is_empty() { return Ok(Vec::new()); } // Decompress the changeset file - let mut changeset_data_reader = GzDecoder::new(&changeset_data[..]); + let mut changeset_data_reader = GzDecoder::new(changeset_data); let mut changeset_data = String::new(); changeset_data_reader.read_to_string(&mut changeset_data)?; debug!( diff --git a/src/osm/osm_data.rs b/src/osm/osm_data.rs @@ -11,14 +11,16 @@ use std::{ borrow::Cow, collections::BTreeMap, convert::Infallible, + fs::File, io::{Read, Write}, + path::Path, }; use time::{format_description::well_known::Iso8601, OffsetDateTime}; use tracing::{debug, error, info, warn}; use crate::git::commit; -use super::changesets::Changeset; +use super::changesets::{parse_changeset, Changeset}; const FILE_VERSION: &str = "0.1.0"; @@ -468,8 +470,8 @@ pub enum OSMObject { pub fn convert_objects_to_git( repository: &Repository, committer: &Signature, - changesets: &[Changeset], data: &[u8], + cache_folder: String, ) -> Result<()> { // If the file is empty we skip it if data.is_empty() { @@ -845,83 +847,166 @@ pub fn convert_objects_to_git( .collect(); for changeset in changeset_list { - // Construct the commit and apply it - let changeset = changesets - .iter() - .find(|c| c.id == *changeset) - .expect("Unable to find changeset"); - - // Get comment tag if it exists and trim it - let comment = changeset - .tags - .get("comment") - .map(|s| s.trim()) - .unwrap_or(""); - - // Parse changeset time (ISO 8601) to git time (seconds since epoch) with offset 0 (UTC) using `time` - let changeset_time = changeset - .closed_at - .clone() - .unwrap_or(changeset.created_at.clone()); - let commit_time = - OffsetDateTime::parse(changeset_time.as_str(), &Iso8601::DEFAULT)?.unix_timestamp(); - - let author = git2::Signature::new( - &changeset.user, - &format!("{}@osm", changeset.user), - &Time::new(commit_time, 0), - ) - .expect("Unable to create author signature"); - - let repository_folder = repository.path().parent().unwrap(); - - let added_or_changed_files = created_or_modified_objects_for_changeset - .get(&changeset.id) - .unwrap_or(&Vec::new()) - .iter() - .map(|object| match object { - OSMObject::Node(ref node) => repository_folder.join(format!("{}.yaml", node.id)), - OSMObject::Way(ref way) => repository_folder.join(format!("{}.yaml", way.id)), - OSMObject::Relation(ref relation) => { - repository_folder.join(format!("{}.yaml", relation.id)) - } - }) - .map(|path| path.to_string_lossy().to_string()) - .collect::<Vec<String>>(); - - let removed_files = deleted_objects_for_changeset - .get(&changeset.id) - .unwrap_or(&Vec::new()) - .iter() - .map(|object| match object { - OSMObject::Node(ref node) => repository_folder.join(format!("{}.yaml", node.id)), - OSMObject::Way(ref way) => repository_folder.join(format!("{}.yaml", way.id)), - OSMObject::Relation(ref relation) => { - repository_folder.join(format!("{}.yaml", relation.id)) - } - }) - .map(|path| path.to_string_lossy().to_string()) - .collect::<Vec<String>>(); - - let oid = commit( - repository, - added_or_changed_files, - removed_files, - comment, - &author, - committer, - )?; - - // Convert tags to "Key: Value" strings separated by newlines for the note - let note = changeset - .tags - .iter() - .map(|(key, value)| format!("{}: {}", key, value)) - .collect::<Vec<String>>() - .join("\n"); - - repository.note(&author, committer, None, oid, &note, false)?; + // Find the changeset within the files of the cache + let changeset = + find_changesets_in_cache(cache_folder.clone(), *changeset, None, None, None)?; + + if let Some(changeset) = changeset { + // Get comment tag if it exists and trim it + let comment = changeset + .tags + .get("comment") + .map(|s| s.trim()) + .unwrap_or(""); + + // Parse changeset time (ISO 8601) to git time (seconds since epoch) with offset 0 (UTC) using `time` + let changeset_time = changeset + .closed_at + .clone() + .unwrap_or(changeset.created_at.clone()); + let commit_time = + OffsetDateTime::parse(changeset_time.as_str(), &Iso8601::DEFAULT)?.unix_timestamp(); + + let author = git2::Signature::new( + &changeset.user, + &format!("{}@osm", changeset.user), + &Time::new(commit_time, 0), + ) + .expect("Unable to create author signature"); + + let repository_folder = repository.path().parent().unwrap(); + + let added_or_changed_files = created_or_modified_objects_for_changeset + .get(&changeset.id) + .unwrap_or(&Vec::new()) + .iter() + .map(|object| match object { + OSMObject::Node(ref node) => { + repository_folder.join(format!("{}.yaml", node.id)) + } + OSMObject::Way(ref way) => repository_folder.join(format!("{}.yaml", way.id)), + OSMObject::Relation(ref relation) => { + repository_folder.join(format!("{}.yaml", relation.id)) + } + }) + .map(|path| path.to_string_lossy().to_string()) + .collect::<Vec<String>>(); + + let removed_files = deleted_objects_for_changeset + .get(&changeset.id) + .unwrap_or(&Vec::new()) + .iter() + .map(|object| match object { + OSMObject::Node(ref node) => { + repository_folder.join(format!("{}.yaml", node.id)) + } + OSMObject::Way(ref way) => repository_folder.join(format!("{}.yaml", way.id)), + OSMObject::Relation(ref relation) => { + repository_folder.join(format!("{}.yaml", relation.id)) + } + }) + .map(|path| path.to_string_lossy().to_string()) + .collect::<Vec<String>>(); + + let oid = commit( + repository, + added_or_changed_files, + removed_files, + comment, + &author, + committer, + )?; + + // Convert tags to "Key: Value" strings separated by newlines for the note + let note = changeset + .tags + .iter() + .map(|(key, value)| format!("{}: {}", key, value)) + .collect::<Vec<String>>() + .join("\n"); + + repository.note(&author, committer, None, oid, &note, false)?; + + // We make sure to not keep it around since this would get large quite quickly + drop(changeset); + } } Ok(()) } + +/// Scans the files in the cache folder and returns the requested changeset +/// +/// # Arguments +/// +/// * `cache_folder` - The folder where the changesets are stored +/// * `changeset_id` - The id of the changeset to find +/// +/// # Returns +/// +/// The changeset if found +fn find_changesets_in_cache( + cache_folder: String, + changeset_id: u64, + changeset_position_top: Option<u64>, + changeset_position_middle: Option<u64>, + changeset_position_bottom: Option<u64>, +) -> Result<Option<Changeset>> { + // Changesets are of the format "{cache_folder}/changesets/{:03}/{:03}/{:03}.osm.gz". + // + // Each file has to be parsed using "parse_changeset(&changeset_data)?;" + + let changeset_folder = format!("{}/changesets", cache_folder); + let mut changeset_position_top = changeset_position_top.unwrap_or(0); + let mut changeset_position_middle = changeset_position_middle.unwrap_or(0); + let mut changeset_position_bottom = changeset_position_bottom.unwrap_or(0); + let changeset_file = format!( + "{:03}/{:03}/{:03}.osm.gz", + changeset_position_top, changeset_position_middle, changeset_position_bottom + ); + let changeset_path = format!("{}/{}", changeset_folder, changeset_file); + + if !Path::new(&changeset_path).exists() { + return Ok(None); + } + + let mut changeset_file = File::open(changeset_path)?; + let mut changeset_data = Vec::new(); + changeset_file.read_to_end(&mut changeset_data)?; + + let changesets = parse_changeset(&changeset_data)?; + + // Check if the file has the correct changeset id in vector otherwise we recurse to the next file + if changesets.iter().any(|c| c.id == changeset_id) { + return Ok(changesets.into_iter().find(|c| c.id == changeset_id)); + } + + // We recurse to the next file since we found no changeset with the correct id + + if changeset_position_top == 999 + && changeset_position_middle == 999 + && changeset_position_bottom == 999 + { + // Uhhhhhh?! + return Ok(None); + } + + if changeset_position_middle == 999 && changeset_position_bottom == 999 { + changeset_position_middle = 0; + changeset_position_bottom = 0; + changeset_position_top += 1; + } + + if changeset_position_bottom == 999 { + changeset_position_bottom = 0; + changeset_position_middle += 1; + } + + find_changesets_in_cache( + cache_folder, + changeset_id, + Some(changeset_position_top), + Some(changeset_position_middle), + Some(changeset_position_bottom), + ) +}