sugar_cli/upload/
uploader.rs

1use std::{
2    cmp,
3    collections::HashMap,
4    sync::{
5        atomic::{AtomicBool, Ordering},
6        Arc,
7    },
8};
9
10use anyhow::Result;
11use async_trait::async_trait;
12use console::style;
13use futures::future::select_all;
14pub use indicatif::ProgressBar;
15use tokio::task::JoinHandle;
16
17use crate::{
18    cache::Cache,
19    config::{ConfigData, SugarConfig, UploadMethod},
20    constants::PARALLEL_LIMIT,
21    upload::{
22        assets::{AssetPair, DataType},
23        methods::*,
24        UploadError,
25    },
26};
27
28// Size of the mock media URI for cost calculations.
29pub const MOCK_URI_SIZE: usize = 100;
30
31/// Struct representing an asset ready for upload. An `AssetInfo` can represent
32/// a physical file, in which case the `content` will correspond to the name
33/// of the file; or an in-memory asset, in which case the `content` will correspond
34/// to the content of the asset.
35///
36/// For example, for image files, the `content` contains the path of the file on the
37/// file system. In the case of json metadata files, the `content` contains the string
38/// representation of the json metadata.
39pub struct AssetInfo {
40    /// Id of the asset in the cache.
41    pub asset_id: String,
42    /// Name (file name) of the asset.
43    pub name: String,
44    /// Content of the asset - either a file path or the string representation of the content.
45    pub content: String,
46    /// Type of the asset.
47    pub data_type: DataType,
48    /// MIME content type.
49    pub content_type: String,
50}
51
52/// Types that can be prepared to upload assets (files).
53///
54/// All implementation of [`Uploader`](Uploader) need to implement this trait.
55#[async_trait]
56pub trait Prepare {
57    /// Prepare the upload of the specified media/metadata files, e.g.:
58    /// - check if any file exceeds a size limit;
59    /// - check if there is storage space for the upload;
60    /// - check/add funds for the upload.
61    ///
62    /// The `prepare` receives the information of all files that will be upload.
63    ///
64    /// # Arguments
65    ///
66    /// * `sugar_config` - The current sugar configuration
67    /// * `asset_pairs` - Mapping of `index` to an `AssetPair`
68    /// * `asset_indices` - Vector with the information of which asset pair indices will be upload grouped by type.
69    ///
70    /// The `asset_pairs` contain the complete information of the assets, but only the assets specified in the
71    /// `asset_indices` will be uploaded. E.g., if index `1` is only present in the `DataType::Image` indices' array,
72    /// only the image of asset `1` will the uploaded.
73    async fn prepare(
74        &self,
75        sugar_config: &SugarConfig,
76        asset_pairs: &HashMap<isize, AssetPair>,
77        asset_indices: Vec<(DataType, &[isize])>,
78    ) -> Result<()>;
79}
80
81/// Types that can upload assets (files).
82///
83/// This trait should be implemented directly by upload methods that require full control on how the upload
84/// is performed. For methods that support parallel uploads (threading), consider implementing
85/// [`ParallelUploader`](ParallelUploader) instead.
86#[async_trait]
87pub trait Uploader: Prepare {
88    /// Returns a vector [`UploadError`](super::errors::UploadError) with the errors (if any) after uploading all
89    /// assets to the storage.
90    ///
91    /// This function will be called to upload each type of asset separately.
92    ///
93    /// # Arguments
94    ///
95    /// * `sugar_config` - The current sugar configuration
96    /// * `cache` - Asset [`cache`](crate::cache::Cache) object (mutable)
97    /// * `data_type` - Type of the asset being uploaded
98    /// * `assets` - Vector of [`assets`](AssetInfo) to upload (mutable)
99    /// * `progress` - Reference to the [`progress bar`](indicatif::ProgressBar) to provide feedback to
100    ///                the console
101    /// * `interrupted` - Reference to the shared interruption handler [`flag`](std::sync::atomic::AtomicBool)
102    ///                   to receive notifications
103    ///
104    /// # Examples
105    ///
106    /// Implementations are expected to use the `interrupted` to control when the user aborts the upload process.
107    /// In general, this would involve using it as a control of a loop:
108    ///
109    /// ```ignore
110    /// while !interrupted.load(Ordering::SeqCst) {
111    ///     // continue with the upload
112    /// }
113    /// ```
114    ///
115    /// After uploading an asset, its information need to be updated in the cache and the cache
116    /// [`sync`](crate::cache::Cache#method.sync_file)ed to the file system. Syncing the cache to the file system
117    /// might be slow for large collections, therefore it should be done as frequent as practical to avoid slowing
118    /// down the upload process and, at the same time, minimizing the chances of information loss in case
119    /// the user aborts the upload.
120    ///
121    /// ```ignore
122    /// ...
123    /// // once an asset has been upload
124    ///
125    /// let id = asset_info.asset_id.clone();
126    /// let uri = "URI of the asset after upload";
127    /// // cache item to update
128    /// let item = cache.items.get_mut(&id).unwrap();
129    ///
130    /// match data_type {
131    ///     DataType::Image => item.image_link = uri,
132    ///     DataType::Metadata => item.metadata_link = uri,
133    ///     DataType::Animation => item.animation_link = Some(uri),
134    /// }
135    /// // updates the progress bar
136    /// progress.inc(1);
137    ///
138    /// ...
139    ///
140    /// // after several uploads
141    /// cache.sync_file()?;
142    /// ```
143    async fn upload(
144        &self,
145        sugar_config: &SugarConfig,
146        cache: &mut Cache,
147        data_type: DataType,
148        assets: &mut Vec<AssetInfo>,
149        progress: &ProgressBar,
150        interrupted: Arc<AtomicBool>,
151    ) -> Result<Vec<UploadError>>;
152}
153
154/// Types that can upload assets in parallel.
155///
156/// This trait abstracts the threading logic and allows methods to focus on the logic of uploading a single
157/// asset (file).
158#[async_trait]
159pub trait ParallelUploader: Uploader + Send + Sync {
160    /// Returns a [`JoinHandle`](tokio::task::JoinHandle) to the task responsible to upload the specified asset.
161    ///
162    /// # Arguments
163    ///
164    /// * `asset` - The [`asset`](AssetInfo) to upload
165    ///
166    /// # Example
167    ///
168    /// In most cases, the function will return the value from [`tokio::spawn`](tokio::spawn):
169    ///
170    /// ```ignore
171    /// tokio::spawn(async move {
172    ///     // code responsible to upload a single asset
173    /// });
174    /// ```
175    ///
176    fn upload_asset(&self, asset: AssetInfo) -> JoinHandle<Result<(String, String)>>;
177
178    /// Return the number of concurrent uploads allowed. The default implementation returns
179    /// the value [PARALLEL_LIMIT].
180    fn parallel_limit(&self) -> usize {
181        PARALLEL_LIMIT
182    }
183}
184
185/// Default implementation of the trait ['Uploader'](Uploader) for all ['ParallelUploader'](ParallelUploader).
186#[async_trait]
187impl<T: ParallelUploader> Uploader for T {
188    /// Uploads assets in parallel. It creates [`self::parallel_limit()`] tasks at a time to avoid
189    /// reaching the limit of concurrent files open and it syncs the cache file at every `self.parallel_limit() / 2`
190    /// step.
191    async fn upload(
192        &self,
193        _sugar_config: &SugarConfig,
194        cache: &mut Cache,
195        data_type: DataType,
196        assets: &mut Vec<AssetInfo>,
197        progress: &ProgressBar,
198        interrupted: Arc<AtomicBool>,
199    ) -> Result<Vec<UploadError>> {
200        let limit = self.parallel_limit();
201        let mut handles = Vec::new();
202
203        for task in assets.drain(0..cmp::min(assets.len(), limit)) {
204            handles.push(self.upload_asset(task));
205        }
206
207        let mut errors = Vec::new();
208
209        while !interrupted.load(Ordering::SeqCst) && !handles.is_empty() {
210            match select_all(handles).await {
211                (Ok(res), _index, remaining) => {
212                    // independently if the upload was successful or not
213                    // we continue to try the remaining ones
214                    handles = remaining;
215                    if res.is_ok() {
216                        let val = res?;
217                        let link = val.clone().1;
218                        // cache item to update
219                        let item = cache.items.0.get_mut(&val.0).unwrap();
220                        match data_type {
221                            DataType::Image => item.image_link = link,
222                            DataType::Metadata => item.metadata_link = link,
223                            DataType::Animation => item.animation_link = Some(link),
224                        }
225                        // updates the progress bar
226                        progress.inc(1);
227                    } else {
228                        // user will need to retry the upload
229                        errors.push(UploadError::SendDataFailed(format!(
230                            "Upload error: {:?}",
231                            res.err().unwrap()
232                        )));
233                    }
234                }
235                (Err(err), _index, remaining) => {
236                    errors.push(UploadError::SendDataFailed(format!(
237                        "Upload error: {:?}",
238                        err
239                    )));
240                    // ignoring all errors
241                    handles = remaining;
242                }
243            }
244            if !assets.is_empty() {
245                // if we are half way through, let spawn more transactions
246                if (limit - handles.len()) > (limit / 2) {
247                    // syncs cache (checkpoint)
248                    cache.sync_file()?;
249                    // determine the number of task to release
250                    let task_count =
251                        cmp::min(assets.len(), if limit < 2 { limit } else { limit / 2 });
252
253                    for task in assets.drain(0..task_count) {
254                        handles.push(self.upload_asset(task));
255                    }
256                }
257            }
258        }
259
260        if errors.is_empty() && !assets.is_empty() {
261            progress.abandon_with_message(format!("{}", style("Upload aborted ").red().bold()));
262            return Err(
263                UploadError::SendDataFailed("Not all files were uploaded.".to_string()).into(),
264            );
265        }
266
267        Ok(errors)
268    }
269}
270
271/// Returns a new uploader trait object based on the configuration `uploadMethod`.
272///
273/// This function acts as a *factory* function for uploader objects.
274pub async fn initialize(
275    sugar_config: &SugarConfig,
276    config_data: &ConfigData,
277) -> Result<Box<dyn Uploader>> {
278    Ok(match config_data.upload_method {
279        UploadMethod::AWS => Box::new(AWSMethod::new(config_data).await?) as Box<dyn Uploader>,
280        UploadMethod::Bundlr => {
281            Box::new(BundlrMethod::new(sugar_config, config_data).await?) as Box<dyn Uploader>
282        }
283        UploadMethod::NftStorage => {
284            Box::new(NftStorageMethod::new(config_data).await?) as Box<dyn Uploader>
285        }
286        UploadMethod::SHDW => {
287            Box::new(SHDWMethod::new(sugar_config, config_data).await?) as Box<dyn Uploader>
288        }
289        UploadMethod::Pinata => {
290            Box::new(PinataMethod::new(config_data).await?) as Box<dyn Uploader>
291        }
292    })
293}