Skip to content

Commit fda41cb

Browse files
authored
Writing Cloud files (#9686)
- Closes #9291
1 parent 30a80db commit fda41cb

File tree

17 files changed

+722
-276
lines changed

17 files changed

+722
-276
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@
649649
- [Added `Decimal.abs`, `.negate` and `.signum`.][9641]
650650
- [Added `Decimal.min` and `.max`.][9663]
651651
- [Added `Decimal.round`.][9672]
652+
- [Implemented write support for Enso Cloud files.][9686]
652653

653654
[debug-shortcuts]:
654655
https://github.com/enso-org/enso/blob/develop/app/gui/docs/product/shortcuts.md#debug
@@ -948,6 +949,7 @@
948949
[9641]: https://github.com/enso-org/enso/pull/9641
949950
[9663]: https://github.com/enso-org/enso/pull/9663
950951
[9672]: https://github.com/enso-org/enso/pull/9672
952+
[9686]: https://github.com/enso-org/enso/pull/9686
951953

952954
#### Enso Compiler
953955

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import project.Data.Time.Duration.Duration
2+
import project.Enso_Cloud.Internal.Existing_Enso_Asset.Asset_Cache
3+
import project.Nothing.Nothing
4+
5+
polyglot java import org.enso.base.enso_cloud.CacheSettings
6+
7+
## PRIVATE
8+
UNSTABLE
9+
ADVANCED
10+
Sets for how long is Enso Cloud file information cached without checking for
11+
external updates.
12+
13+
The default TTL is 60 seconds.
14+
15+
Side effects from this Enso workflow will invalidate the cache immediately,
16+
but any external operations (done from other Enso instances) will not be
17+
visible until a cached value expires. Thus if the workflow is expected to
18+
co-operate with other workflows, it may be useful to decrease the cache TTL
19+
or disable it completely by passing `Nothing`.
20+
21+
Note that completely disabling the caching will affect performance, as some
22+
generic operations may perform multiple cloud requests.
23+
24+
Changing the TTL invalidates all existing cache entries, because their
25+
expiration time was calculated using the old TTL.
26+
set_file_cache_ttl (duration : Duration | Nothing) =
27+
CacheSettings.setFileCacheTTL duration
28+
Asset_Cache.invalidate_all
29+
30+
## PRIVATE
31+
ADVANCED
32+
Returns the current file cache TTL.
33+
get_file_cache_ttl -> Duration | Nothing = CacheSettings.getFileCacheTTL

distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Enso_File.enso

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import project.Enso_Cloud.Data_Link
1010
import project.Enso_Cloud.Enso_User.Enso_User
1111
import project.Enso_Cloud.Errors.Enso_Cloud_Error
1212
import project.Enso_Cloud.Internal.Enso_Path.Enso_Path
13+
import project.Enso_Cloud.Internal.Enso_File_Write_Strategy
1314
import project.Enso_Cloud.Internal.Existing_Enso_Asset.Existing_Enso_Asset
15+
import project.Enso_Cloud.Internal.Existing_Enso_Asset.Asset_Cache
1416
import project.Enso_Cloud.Internal.Utils
1517
import project.Error.Error
1618
import project.Errors.Common.Not_Found
@@ -21,19 +23,23 @@ import project.Errors.Time_Error.Time_Error
2123
import project.Errors.Unimplemented.Unimplemented
2224
import project.Network.HTTP.HTTP
2325
import project.Network.HTTP.HTTP_Method.HTTP_Method
26+
import project.Network.HTTP.Request_Error
2427
import project.Network.URI.URI
2528
import project.Nothing.Nothing
29+
import project.Panic.Panic
2630
import project.Runtime
2731
import project.Runtime.Context
2832
import project.System.Environment
2933
import project.System.File.Data_Link_Access.Data_Link_Access
34+
import project.System.File.File
3035
import project.System.File.File_Access.File_Access
3136
import project.System.File.Generic.File_Like.File_Like
3237
import project.System.File.Generic.Writable_File.Writable_File
3338
import project.System.File_Format_Metadata.File_Format_Metadata
3439
import project.System.Input_Stream.Input_Stream
3540
import project.System.Output_Stream.Output_Stream
3641
from project.Data.Boolean import Boolean, False, True
42+
from project.Data.Index_Sub_Range.Index_Sub_Range import Last
3743
from project.Data.Text.Extensions import all
3844
from project.Enso_Cloud.Public_Utils import get_required_field
3945
from project.System.File_Format import Auto_Detect, Bytes, File_Format, Plain_Text_Format
@@ -178,8 +184,17 @@ type Enso_File
178184
value. The value is returned from this method.
179185
with_output_stream : Vector File_Access -> (Output_Stream -> Any ! File_Error) -> Any ! File_Error
180186
with_output_stream self (open_options : Vector) action =
181-
_ = [open_options, action]
182-
Unimplemented.throw "Writing to Enso_Files is not currently implemented."
187+
Context.Output.if_enabled disabled_message="Writing to an Enso_File is forbidden as the Output context is disabled." panic=False <|
188+
open_as_data_link = (open_options.contains Data_Link_Access.No_Follow . not) && (Data_Link.is_data_link self)
189+
if open_as_data_link then Data_Link.write_data_link_as_stream self open_options action else
190+
if open_options.contains File_Access.Append then Unimplemented.throw "Enso_File currently does not support appending to a file. Instead you may read it, modify and then write the new contents." else
191+
File_Access.ensure_only_allowed_options "with_output_stream" [File_Access.Write, File_Access.Create_New, File_Access.Truncate_Existing, File_Access.Create, Data_Link_Access.No_Follow] open_options <|
192+
allow_existing = open_options.contains File_Access.Create_New . not
193+
tmp_file = File.create_temporary_file "enso-cloud-write-tmp"
194+
Panic.with_finalizer tmp_file.delete <|
195+
perform_upload self allow_existing <|
196+
result = tmp_file.with_output_stream [File_Access.Write] action
197+
result.if_not_error [tmp_file, result]
183198

184199
## PRIVATE
185200
ADVANCED
@@ -240,7 +255,6 @@ type Enso_File
240255
Enso_Asset_Type.File -> File_Format.handle_format_missing_arguments format <|
241256
read_with_format effective_format =
242257
metadata = File_Format_Metadata.from self
243-
# TODO maybe avoid fetching asset id twice here? maybe move with_input_stream to asset? or helper?
244258
self.with_input_stream [File_Access.Read] (stream-> effective_format.read_stream stream metadata)
245259

246260
if format != Auto_Detect then read_with_format format else
@@ -292,33 +306,40 @@ type Enso_File
292306
# Remove secrets from the list - they are handled separately in `Enso_Secret.list`.
293307
assets = list_assets self . filter f-> f.asset_type != Enso_Asset_Type.Secret
294308
assets.map asset->
295-
# TODO we could cache the asset maybe? but for how long should we do that?
296-
Enso_File.Value (self.enso_path.resolve asset.name)
309+
file = Enso_File.Value (self.enso_path.resolve asset.name)
310+
Asset_Cache.update file asset
311+
file
297312

298313
## UNSTABLE
299314
GROUP Output
300315
Creates a subdirectory in a specified directory.
301316
create_directory : Text -> Enso_File
302317
create_directory self (name : Text) =
318+
effective_name = if name.ends_with "/" then name.drop (Last 1) else name
303319
asset = Existing_Enso_Asset.get_asset_reference_for self
304320
if asset.is_directory.not then Error.throw (Illegal_Argument.Error "Only directories can contain subdirectories.") else
305321
parent_field = if self.is_current_user_root then [] else
306-
[["parentId", [asset.id]]]
307-
body = JS_Object.from_pairs [["title", name]]+parent_field
322+
[["parentId", asset.id]]
323+
body = JS_Object.from_pairs [["title", effective_name]]+parent_field
324+
created_directory = Enso_File.Value (self.enso_path.resolve name)
325+
Asset_Cache.invalidate created_directory
308326
response = Utils.http_request_as_json HTTP_Method.Post Utils.directory_api body
309-
# TODO we could cache the asset maybe? but for how long should we do that?
310327
created_asset = Existing_Enso_Asset.from_json response
311328
created_asset.if_not_error <|
312-
Enso_File.Value (self.enso_path.resolve name)
329+
Asset_Cache.update created_directory created_asset
330+
created_directory
313331

314332
## UNSTABLE
315333
GROUP Output
316334
Deletes the file or directory.
317335
delete : Nothing
318336
delete self = if self.enso_path.is_root then Error.throw (Illegal_Argument.Error "The root directory cannot be deleted.") else
319337
asset = Existing_Enso_Asset.get_asset_reference_for self
320-
uri = URI.from asset.asset_uri . add_query_argument "force" "true"
338+
# The `if_not_error` here is a workaround for bug https://github.com/enso-org/enso/issues/9669
339+
uri = asset.if_not_error <|
340+
URI.from asset.asset_uri . add_query_argument "force" "true"
321341
response = Utils.http_request HTTP_Method.Delete uri
342+
if asset.is_directory then Asset_Cache.invalidate_subtree self else Asset_Cache.invalidate self
322343
response.if_not_error Nothing
323344

324345
## ICON data_output
@@ -350,9 +371,9 @@ type Enso_File
350371
destination file already exists. Defaults to `False`.
351372
move_to : Writable_File -> Boolean -> Nothing ! File_Error
352373
move_to self (destination : Writable_File) (replace_existing : Boolean = False) =
353-
_ = [destination, replace_existing]
354-
Context.Output.if_enabled disabled_message="File moving is forbidden as the Output context is disabled." panic=False <|
355-
Unimplemented.throw "Enso_File.move_to is not implemented"
374+
# TODO we could have a fast path if Cloud will support renaming files
375+
generic_copy self destination.file replace_existing . if_not_error <|
376+
self.delete . if_not_error <| destination.file
356377

357378
## GROUP Operators
358379
ICON folder
@@ -371,6 +392,11 @@ type Enso_File
371392
to_text self -> Text =
372393
"Enso_File "+self.path
373394

395+
## PRIVATE
396+
to_js_object : JS_Object
397+
to_js_object self =
398+
JS_Object.from_pairs [["type", "Enso_File"], ["constructor", "new"], ["path", self.path.to_text]]
399+
374400
## PRIVATE
375401
list_assets (parent : Enso_File) -> Vector Existing_Enso_Asset =
376402
Existing_Enso_Asset.get_asset_reference_for parent . list_directory
@@ -402,16 +428,53 @@ Enso_Asset_Type.from (that:Text) = case that of
402428

403429
## PRIVATE
404430
File_Format_Metadata.from (that:Enso_File) =
405-
# TODO this is just a placeholder, until we implement the proper path
406-
path = Nothing
407-
case that.asset_type of
408-
Enso_Asset_Type.Data_Link ->
409-
File_Format_Metadata.Value path=path name=that.name content_type=Data_Link.data_link_content_type
410-
Enso_Asset_Type.Directory ->
411-
File_Format_Metadata.Value path=path name=that.name extension=(that.extension.catch _->Nothing)
412-
Enso_Asset_Type.File ->
413-
File_Format_Metadata.Value path=path name=that.name extension=(that.extension.catch _->Nothing)
414-
other_asset_type -> Error.throw (Illegal_Argument.Error "`File_Format_Metadata` is not available for: "+other_asset_type.to_text+".")
431+
asset_type = that.asset_type.catch File_Error _->Nothing
432+
if asset_type == Enso_Asset_Type.Data_Link then File_Format_Metadata.Value path=that.path name=that.name content_type=Data_Link.data_link_content_type else
433+
File_Format_Metadata.Value path=that.path name=that.name extension=(that.extension.catch _->Nothing)
415434

416435
## PRIVATE
417436
File_Like.from (that : Enso_File) = File_Like.Value that
437+
438+
## PRIVATE
439+
Writable_File.from (that : Enso_File) =
440+
Writable_File.Value that Enso_File_Write_Strategy.instance
441+
442+
## PRIVATE
443+
upload_file (local_file : File) (destination : Enso_File) (replace_existing : Boolean) -> Enso_File =
444+
result = perform_upload destination replace_existing [local_file, destination]
445+
result.catch Enso_Cloud_Error error->
446+
is_source_file_not_found = case error of
447+
Enso_Cloud_Error.Connection_Error cause -> case cause of
448+
request_error : Request_Error -> request_error.error_type == 'java.io.FileNotFoundException'
449+
_ -> False
450+
_ -> False
451+
if is_source_file_not_found then Error.throw (File_Error.Not_Found local_file) else result
452+
453+
## PRIVATE
454+
`generate_request_body_and_result` should return a pair,
455+
where the first element is the request body and the second element is the result to be returned.
456+
It is executed lazily, only after all pre-conditions are successfully met.
457+
perform_upload (destination : Enso_File) (allow_existing : Boolean) (~generate_request_body_and_result) =
458+
parent_directory = destination.parent
459+
if parent_directory.is_nothing then Error.throw (Illegal_Argument.Error "The root directory cannot be a destination for upload. The destination must be a path to a file.") else
460+
parent_directory_asset = Existing_Enso_Asset.get_asset_reference_for parent_directory
461+
# If the parent directory does not exist, we fail.
462+
parent_directory_asset.if_not_error <|
463+
existing_asset = Existing_Enso_Asset.get_asset_reference_for destination
464+
. catch File_Error _->Nothing
465+
if existing_asset.is_nothing.not && allow_existing.not then Error.throw (File_Error.Already_Exists destination) else
466+
if existing_asset.is_nothing.not && existing_asset.asset_type != Enso_Asset_Type.File then Error.throw (Illegal_Argument.Error "The destination must be a path to a file, not "+existing_asset.asset_type.to_text+".") else
467+
existing_asset_id = existing_asset.if_not_nothing <| existing_asset.id
468+
file_name = destination.name
469+
base_uri = URI.from Utils.files_api
470+
. add_query_argument "parent_directory_id" parent_directory_asset.id
471+
. add_query_argument "file_name" file_name
472+
full_uri = case existing_asset_id of
473+
Nothing -> base_uri
474+
_ -> base_uri . add_query_argument "file_id" existing_asset_id
475+
pair = generate_request_body_and_result
476+
Asset_Cache.invalidate destination
477+
response = Utils.http_request HTTP_Method.Post full_uri pair.first
478+
response.if_not_error <|
479+
Asset_Cache.update destination (Existing_Enso_Asset.from_json response)
480+
pair.second

distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Internal/Authentication.enso

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import project.Network.HTTP.Response.Response
2727
import project.Network.URI.URI
2828
import project.Nothing.Nothing
2929
import project.Panic.Panic
30+
import project.Runtime.Context
3031
import project.Runtime.Ref.Ref
3132
import project.System.Environment
3233
import project.System.File.File
@@ -135,7 +136,7 @@ type Refresh_Token_Data
135136
headers = [Header.content_type "application/x-amz-json-1.1", Header.new "X-Amz-Target" "AWSCognitoIdentityProviderService.InitiateAuth"]
136137
auth_parameters = JS_Object.from_pairs [["REFRESH_TOKEN", self.refresh_token], ["DEVICE_KEY", Nothing]]
137138
payload = JS_Object.from_pairs [["ClientId", self.client_id], ["AuthFlow", "REFRESH_TOKEN_AUTH"], ["AuthParameters", auth_parameters]]
138-
response = HTTP.post self.refresh_url body=(Request_Body.Json payload) headers=headers
139+
response = Context.Output.with_enabled <| HTTP.post self.refresh_url body=(Request_Body.Json payload) headers=headers
139140
. catch HTTP_Error error-> case error of
140141
HTTP_Error.Status_Error status _ _ ->
141142
# If the status code is 400-499, then most likely the reason is that the session has expired, so we ask the user to log in again.
@@ -179,4 +180,6 @@ file_get_required_string_field json prefix field_name = case json of
179180
_ ->
180181
got_type = Meta.type_of result . to_display_text
181182
Panic.throw (Illegal_State.Error prefix+"expected `"+field_name+"` to be a string, but got "+got_type+".")
182-
_ -> Panic.throw (Illegal_State.Error prefix+"expected an object, got "+(Meta.type_of json))
183+
_ ->
184+
got_type = Meta.type_of json . to_display_text
185+
Panic.throw (Illegal_State.Error prefix+"expected an object, got "+got_type)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
private
2+
3+
import project.Error.Error
4+
import project.Errors.Common.Forbidden_Operation
5+
import project.Errors.Unimplemented.Unimplemented
6+
import project.System.File.Existing_File_Behavior.Existing_File_Behavior
7+
import project.System.File.File
8+
from project.Data.Boolean import Boolean, False, True
9+
from project.Enso_Cloud.Enso_File import Enso_File, upload_file
10+
from project.System.File.Generic.File_Write_Strategy import default_append, default_overwrite, default_raise_error, File_Write_Strategy, generic_remote_write_with_local_file
11+
12+
## PRIVATE
13+
In the Enso_File, we use the Overwrite strategy for Backup.
14+
That is because, the Cloud keeps versions of the file by itself,
15+
so there is no need to duplicate its work on our own - just overwriting the
16+
file still ensures we have a backup.
17+
instance =
18+
File_Write_Strategy.Value default_overwrite default_append default_raise_error write_backing_up=default_overwrite create_dry_run_file remote_write_with_local_file copy_from_local
19+
20+
21+
## PRIVATE
22+
create_dry_run_file file copy_original =
23+
_ = [file, copy_original]
24+
Error.throw (Forbidden_Operation.Error "Currently dry-run is not supported for Enso_File, so writing to an Enso_File is forbidden if the Output context is disabled.")
25+
26+
27+
## PRIVATE
28+
remote_write_with_local_file file existing_file_behavior action =
29+
if existing_file_behavior == Existing_File_Behavior.Append then Unimplemented.throw "Enso Cloud does not yet support appending to a file. Instead you may read it, modify and then write the new contents." else
30+
generic_remote_write_with_local_file file existing_file_behavior action
31+
32+
## PRIVATE
33+
copy_from_local (source : File) (destination : Enso_File) (replace_existing : Boolean) =
34+
upload_file source destination replace_existing

distribution/lib/Standard/Base/0.0.0-dev/src/Enso_Cloud/Internal/Existing_Enso_Asset.enso

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import project.Data.Text.Text_Sub_Range.Text_Sub_Range
77
import project.Data.Time.Date_Time.Date_Time
88
import project.Data.Time.Date_Time_Formatter.Date_Time_Formatter
99
import project.Data.Vector.Vector
10+
import project.Enso_Cloud.Cloud_Caching_Settings
1011
import project.Enso_Cloud.Enso_User.Enso_User
1112
import project.Enso_Cloud.Enso_File.Enso_Asset_Type
1213
import project.Enso_Cloud.Enso_File.Enso_File
@@ -73,9 +74,7 @@ type Existing_Enso_Asset
7374
Fetches the basic information about an existing file from the Cloud.
7475
It will fail if the file does not exist.
7576
get_asset_reference_for (file : Enso_File) -> Existing_Enso_Asset ! File_Error =
76-
# TODO remove workaround for bug https://github.com/enso-org/cloud-v2/issues/1173
77-
path = if file.enso_path.is_root then file.enso_path.to_text + "/" else file.enso_path.to_text
78-
Existing_Enso_Asset.resolve_path path if_not_found=(Error.throw (File_Error.Not_Found file))
77+
fetch_asset_reference file
7978

8079
## PRIVATE
8180
Resolves a path to an existing asset in the cloud.
@@ -111,3 +110,31 @@ type Existing_Enso_Asset
111110
#org = json.get "organizationId" ""
112111
asset_type = (id.take (Text_Sub_Range.Before "-")):Enso_Asset_Type
113112
Existing_Enso_Asset.Value title id asset_type
113+
114+
115+
## PRIVATE
116+
type Asset_Cache
117+
asset_prefix = "asset:"
118+
119+
asset_key (file : Enso_File) -> Text = Asset_Cache.asset_prefix+file.enso_path.to_text
120+
121+
## PRIVATE
122+
invalidate (file : Enso_File) =
123+
Utils.invalidate_cache (Asset_Cache.asset_key file)
124+
125+
invalidate_subtree (file : Enso_File) =
126+
Utils.invalidate_caches_with_prefix (Asset_Cache.asset_key file)
127+
128+
invalidate_all =
129+
Utils.invalidate_caches_with_prefix Asset_Cache.asset_prefix
130+
131+
update (file : Enso_File) (asset : Existing_Enso_Asset) =
132+
Utils.set_cached (Asset_Cache.asset_key file) asset cache_duration=Cloud_Caching_Settings.get_file_cache_ttl
133+
134+
## PRIVATE
135+
Returns the cached reference or fetches it from the cloud.
136+
fetch_asset_reference (file : Enso_File) -> Existing_Enso_Asset ! File_Error =
137+
# TODO remove workaround for bug https://github.com/enso-org/cloud-v2/issues/1173
138+
path = if file.enso_path.is_root then file.enso_path.to_text + "/" else file.enso_path.to_text
139+
Utils.get_cached (Asset_Cache.asset_key file) cache_duration=Cloud_Caching_Settings.get_file_cache_ttl <|
140+
Existing_Enso_Asset.resolve_path path if_not_found=(Error.throw (File_Error.Not_Found file))

0 commit comments

Comments
 (0)