parsl 2024.6.3__py3-none-any.whl → 2024.6.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. parsl/app/app.py +0 -2
  2. parsl/config.py +27 -4
  3. parsl/dataflow/dflow.py +36 -10
  4. parsl/executors/high_throughput/executor.py +36 -30
  5. parsl/executors/high_throughput/interchange.py +26 -28
  6. parsl/providers/kubernetes/kube.py +22 -9
  7. parsl/providers/slurm/slurm.py +31 -22
  8. parsl/tests/configs/flux_local.py +11 -0
  9. parsl/tests/conftest.py +4 -0
  10. parsl/tests/test_bash_apps/test_stdout.py +20 -2
  11. parsl/tests/test_htex/test_htex.py +24 -7
  12. parsl/tests/test_htex/test_zmq_binding.py +22 -6
  13. parsl/tests/test_python_apps/test_context_manager.py +96 -1
  14. parsl/tests/test_python_apps/test_dependencies_deep.py +59 -0
  15. parsl/tests/test_radical/test_mpi_funcs.py +0 -1
  16. parsl/tests/unit/test_usage_tracking.py +45 -0
  17. parsl/usage_tracking/levels.py +6 -0
  18. parsl/usage_tracking/usage.py +54 -23
  19. parsl/version.py +1 -1
  20. parsl-2024.6.17.data/scripts/interchange.py +681 -0
  21. {parsl-2024.6.3.dist-info → parsl-2024.6.17.dist-info}/METADATA +2 -2
  22. {parsl-2024.6.3.dist-info → parsl-2024.6.17.dist-info}/RECORD +29 -24
  23. {parsl-2024.6.3.data → parsl-2024.6.17.data}/scripts/exec_parsl_function.py +0 -0
  24. {parsl-2024.6.3.data → parsl-2024.6.17.data}/scripts/parsl_coprocess.py +0 -0
  25. {parsl-2024.6.3.data → parsl-2024.6.17.data}/scripts/process_worker_pool.py +0 -0
  26. {parsl-2024.6.3.dist-info → parsl-2024.6.17.dist-info}/LICENSE +0 -0
  27. {parsl-2024.6.3.dist-info → parsl-2024.6.17.dist-info}/WHEEL +0 -0
  28. {parsl-2024.6.3.dist-info → parsl-2024.6.17.dist-info}/entry_points.txt +0 -0
  29. {parsl-2024.6.3.dist-info → parsl-2024.6.17.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,11 @@
1
1
  import pathlib
2
2
  import warnings
3
+ from subprocess import Popen, TimeoutExpired
3
4
  from unittest import mock
4
5
 
5
6
  import pytest
6
7
 
7
8
  from parsl import HighThroughputExecutor, curvezmq
8
- from parsl.multiprocessing import ForkProcess
9
9
 
10
10
  _MOCK_BASE = "parsl.executors.high_throughput.executor"
11
11
 
@@ -78,16 +78,33 @@ def test_htex_shutdown(
78
78
  timeout_expires: bool,
79
79
  htex: HighThroughputExecutor,
80
80
  ):
81
- mock_ix_proc = mock.Mock(spec=ForkProcess)
81
+ mock_ix_proc = mock.Mock(spec=Popen)
82
82
 
83
83
  if started:
84
84
  htex.interchange_proc = mock_ix_proc
85
- mock_ix_proc.is_alive.return_value = True
85
+
86
+ # This will, in the absence of any exit trigger, block forever if
87
+ # no timeout is given and if the interchange does not terminate.
88
+ # Raise an exception to report that, rather than actually block,
89
+ # and hope that nothing is catching that exception.
90
+
91
+ # this function implements the behaviour if the interchange has
92
+ # not received a termination call
93
+ def proc_wait_alive(timeout):
94
+ if timeout:
95
+ raise TimeoutExpired(cmd="mock-interchange", timeout=timeout)
96
+ else:
97
+ raise RuntimeError("This wait call would hang forever")
98
+
99
+ def proc_wait_terminated(timeout):
100
+ return 0
101
+
102
+ mock_ix_proc.wait.side_effect = proc_wait_alive
86
103
 
87
104
  if not timeout_expires:
88
105
  # Simulate termination of the Interchange process
89
106
  def kill_interchange(*args, **kwargs):
90
- mock_ix_proc.is_alive.return_value = False
107
+ mock_ix_proc.wait.side_effect = proc_wait_terminated
91
108
 
92
109
  mock_ix_proc.terminate.side_effect = kill_interchange
93
110
 
@@ -96,8 +113,8 @@ def test_htex_shutdown(
96
113
  mock_logs = mock_logger.info.call_args_list
97
114
  if started:
98
115
  assert mock_ix_proc.terminate.called
99
- assert mock_ix_proc.join.called
100
- assert {"timeout": 10} == mock_ix_proc.join.call_args[1]
116
+ assert mock_ix_proc.wait.called
117
+ assert {"timeout": 10} == mock_ix_proc.wait.call_args[1]
101
118
  if timeout_expires:
102
119
  assert "Unable to terminate Interchange" in mock_logs[1][0][0]
103
120
  assert mock_ix_proc.kill.called
@@ -105,7 +122,7 @@ def test_htex_shutdown(
105
122
  assert "Finished" in mock_logs[-1][0][0]
106
123
  else:
107
124
  assert not mock_ix_proc.terminate.called
108
- assert not mock_ix_proc.join.called
125
+ assert not mock_ix_proc.wait.called
109
126
  assert "has not started" in mock_logs[0][0][0]
110
127
 
111
128
 
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import pathlib
2
3
  from typing import Optional
3
4
  from unittest import mock
@@ -10,6 +11,21 @@ from parsl import curvezmq
10
11
  from parsl.executors.high_throughput.interchange import Interchange
11
12
 
12
13
 
14
+ def make_interchange(*, interchange_address: Optional[str], cert_dir: Optional[str]) -> Interchange:
15
+ return Interchange(interchange_address=interchange_address,
16
+ cert_dir=cert_dir,
17
+ client_address="127.0.0.1",
18
+ client_ports=(50055, 50056, 50057),
19
+ worker_ports=None,
20
+ worker_port_range=(54000, 55000),
21
+ hub_address=None,
22
+ hub_zmq_port=None,
23
+ heartbeat_threshold=60,
24
+ logdir=".",
25
+ logging_level=logging.INFO,
26
+ poll_period=10)
27
+
28
+
13
29
  @pytest.fixture
14
30
  def encrypted(request: pytest.FixtureRequest):
15
31
  if hasattr(request, "param"):
@@ -31,7 +47,7 @@ def test_interchange_curvezmq_sockets(
31
47
  mock_socket: mock.MagicMock, cert_dir: Optional[str], encrypted: bool
32
48
  ):
33
49
  address = "127.0.0.1"
34
- ix = Interchange(interchange_address=address, cert_dir=cert_dir)
50
+ ix = make_interchange(interchange_address=address, cert_dir=cert_dir)
35
51
  assert isinstance(ix.zmq_context, curvezmq.ServerContext)
36
52
  assert ix.zmq_context.encrypted is encrypted
37
53
  assert mock_socket.call_count == 5
@@ -40,7 +56,7 @@ def test_interchange_curvezmq_sockets(
40
56
  @pytest.mark.local
41
57
  @pytest.mark.parametrize("encrypted", (True, False), indirect=True)
42
58
  def test_interchange_binding_no_address(cert_dir: Optional[str]):
43
- ix = Interchange(cert_dir=cert_dir)
59
+ ix = make_interchange(interchange_address=None, cert_dir=cert_dir)
44
60
  assert ix.interchange_address == "*"
45
61
 
46
62
 
@@ -49,7 +65,7 @@ def test_interchange_binding_no_address(cert_dir: Optional[str]):
49
65
  def test_interchange_binding_with_address(cert_dir: Optional[str]):
50
66
  # Using loopback address
51
67
  address = "127.0.0.1"
52
- ix = Interchange(interchange_address=address, cert_dir=cert_dir)
68
+ ix = make_interchange(interchange_address=address, cert_dir=cert_dir)
53
69
  assert ix.interchange_address == address
54
70
 
55
71
 
@@ -60,7 +76,7 @@ def test_interchange_binding_with_non_ipv4_address(cert_dir: Optional[str]):
60
76
  # Confirm that a ipv4 address is required
61
77
  address = "localhost"
62
78
  with pytest.raises(zmq.error.ZMQError):
63
- Interchange(interchange_address=address, cert_dir=cert_dir)
79
+ make_interchange(interchange_address=address, cert_dir=cert_dir)
64
80
 
65
81
 
66
82
  @pytest.mark.local
@@ -69,7 +85,7 @@ def test_interchange_binding_bad_address(cert_dir: Optional[str]):
69
85
  """Confirm that we raise a ZMQError when a bad address is supplied"""
70
86
  address = "550.0.0.0"
71
87
  with pytest.raises(zmq.error.ZMQError):
72
- Interchange(interchange_address=address, cert_dir=cert_dir)
88
+ make_interchange(interchange_address=address, cert_dir=cert_dir)
73
89
 
74
90
 
75
91
  @pytest.mark.local
@@ -77,7 +93,7 @@ def test_interchange_binding_bad_address(cert_dir: Optional[str]):
77
93
  def test_limited_interface_binding(cert_dir: Optional[str]):
78
94
  """When address is specified the worker_port would be bound to it rather than to 0.0.0.0"""
79
95
  address = "127.0.0.1"
80
- ix = Interchange(interchange_address=address, cert_dir=cert_dir)
96
+ ix = make_interchange(interchange_address=address, cert_dir=cert_dir)
81
97
  ix.worker_result_port
82
98
  proc = psutil.Process()
83
99
  conns = proc.connections(kind="tcp")
@@ -1,7 +1,11 @@
1
+ from concurrent.futures import Future
2
+ from threading import Event
3
+
1
4
  import pytest
2
5
 
3
6
  import parsl
4
- from parsl.dataflow.dflow import DataFlowKernel
7
+ from parsl.config import Config
8
+ from parsl.dataflow.dflow import DataFlowKernel, DataFlowKernelLoader
5
9
  from parsl.errors import NoDataFlowKernelError
6
10
  from parsl.tests.configs.local_threads import fresh_config
7
11
 
@@ -16,6 +20,16 @@ def foo(x, stdout='foo.stdout'):
16
20
  return f"echo {x + 1}"
17
21
 
18
22
 
23
+ @parsl.python_app
24
+ def wait_for_event(ev: Event):
25
+ ev.wait()
26
+
27
+
28
+ @parsl.python_app
29
+ def raise_app():
30
+ raise RuntimeError("raise_app deliberate failure")
31
+
32
+
19
33
  @pytest.mark.local
20
34
  def test_within_context_manger(tmpd_cwd):
21
35
  config = fresh_config()
@@ -31,3 +45,84 @@ def test_within_context_manger(tmpd_cwd):
31
45
  with pytest.raises(NoDataFlowKernelError) as excinfo:
32
46
  square(2).result()
33
47
  assert str(excinfo.value) == "Must first load config"
48
+
49
+
50
+ @pytest.mark.local
51
+ def test_exit_skip():
52
+ config = fresh_config()
53
+ config.exit_mode = "skip"
54
+
55
+ with parsl.load(config) as dfk:
56
+ ev = Event()
57
+ fut = wait_for_event(ev)
58
+ # deliberately don't wait for this to finish, so that the context
59
+ # manager can exit
60
+
61
+ assert parsl.dfk() is dfk, "global dfk should be left in place by skip mode"
62
+
63
+ assert not fut.done(), "wait_for_event should not be done yet"
64
+ ev.set()
65
+
66
+ # now we can wait for that result...
67
+ fut.result()
68
+ assert fut.done(), "wait_for_event should complete outside of context manager in 'skip' mode"
69
+
70
+ # now cleanup the DFK that the above `with` block
71
+ # deliberately avoided doing...
72
+ dfk.cleanup()
73
+
74
+
75
+ # 'wait' mode has two cases to test:
76
+ # 1. that we wait when there is no exception
77
+ # 2. that we do not wait when there is an exception
78
+ @pytest.mark.local
79
+ def test_exit_wait_no_exception():
80
+ config = fresh_config()
81
+ config.exit_mode = "wait"
82
+
83
+ with parsl.load(config) as dfk:
84
+ fut = square(1)
85
+ # deliberately don't wait for this to finish, so that the context
86
+ # manager can exit
87
+
88
+ assert fut.done(), "This future should be marked as done before the context manager exits"
89
+
90
+ assert dfk.cleanup_called, "The DFK should have been cleaned up by the context manager"
91
+ assert DataFlowKernelLoader._dfk is None, "The global DFK should have been removed"
92
+
93
+
94
+ @pytest.mark.local
95
+ def test_exit_wait_exception():
96
+ config = fresh_config()
97
+ config.exit_mode = "wait"
98
+
99
+ with pytest.raises(RuntimeError):
100
+ with parsl.load(config) as dfk:
101
+ # we'll never fire this future
102
+ fut_never = Future()
103
+
104
+ fut_raise = raise_app()
105
+
106
+ fut_depend = square(fut_never)
107
+
108
+ # this should cause an exception, which should cause the context
109
+ # manager to exit, without waiting for fut_depend to finish.
110
+ fut_raise.result()
111
+
112
+ assert dfk.cleanup_called, "The DFK should have been cleaned up by the context manager"
113
+ assert DataFlowKernelLoader._dfk is None, "The global DFK should have been removed"
114
+ assert fut_raise.exception() is not None, "fut_raise should contain an exception"
115
+ assert not fut_depend.done(), "fut_depend should have been left un-done (due to dependency failure)"
116
+
117
+
118
+ @pytest.mark.local
119
+ def test_exit_wrong_mode():
120
+
121
+ with pytest.raises(Exception) as ex:
122
+ Config(exit_mode="wrongmode")
123
+
124
+ # with typeguard 4.x this is TypeCheckError,
125
+ # with typeguard 2.x this is TypeError
126
+ # we can't instantiate TypeCheckError if we're in typeguard 2.x environment
127
+ # because it does not exist... so check name using strings.
128
+ assert ex.type.__name__ == "TypeCheckError" or ex.type.__name__ == "TypeError"
@@ -0,0 +1,59 @@
1
+ import inspect
2
+ from concurrent.futures import Future
3
+ from typing import Any, Callable, Dict
4
+
5
+ import pytest
6
+
7
+ import parsl
8
+ from parsl.executors.base import ParslExecutor
9
+
10
+ # N is the number of tasks to chain
11
+ # With mid-2024 Parsl, N>140 causes Parsl to hang
12
+ N = 100
13
+
14
+ # MAX_STACK is the maximum Python stack depth allowed for either
15
+ # task submission to an executor or execution of a task.
16
+ # With mid-2024 Parsl, 2-3 stack entries will be used per
17
+ # recursively launched parsl task. So this should be smaller than
18
+ # 2*N, but big enough to allow regular pytest+parsl stuff to
19
+ # happen.
20
+ MAX_STACK = 50
21
+
22
+
23
+ def local_config():
24
+ return parsl.Config(executors=[ImmediateExecutor()])
25
+
26
+
27
+ class ImmediateExecutor(ParslExecutor):
28
+ def start(self):
29
+ pass
30
+
31
+ def shutdown(self):
32
+ pass
33
+
34
+ def submit(self, func: Callable, resource_specification: Dict[str, Any], *args: Any, **kwargs: Any) -> Future:
35
+ stack_depth = len(inspect.stack())
36
+ assert stack_depth < MAX_STACK, "tasks should not be launched deep in the Python stack"
37
+ fut: Future[None] = Future()
38
+ res = func(*args, **kwargs)
39
+ fut.set_result(res)
40
+ return fut
41
+
42
+
43
+ @parsl.python_app
44
+ def chain(upstream):
45
+ stack_depth = len(inspect.stack())
46
+ assert stack_depth < MAX_STACK, "chained dependencies should not be launched deep in the Python stack"
47
+
48
+
49
+ @pytest.mark.local
50
+ def test_deep_dependency_stack_depth():
51
+
52
+ fut = Future()
53
+ here = fut
54
+
55
+ for _ in range(N):
56
+ here = chain(here)
57
+
58
+ fut.set_result(None)
59
+ here.result()
@@ -16,7 +16,6 @@ def some_mpi_func(msg, sleep, comm=None, parsl_resource_specification={}):
16
16
  apps = []
17
17
 
18
18
 
19
- @pytest.mark.skip("hangs in CI - waiting for resolution of issue #3029")
20
19
  @pytest.mark.local
21
20
  @pytest.mark.radical
22
21
  def test_radical_mpi(n=7):
@@ -0,0 +1,45 @@
1
+ """Test usage_tracking values."""
2
+
3
+ import pytest
4
+
5
+ import parsl
6
+ from parsl.config import Config
7
+ from parsl.errors import ConfigurationError
8
+
9
+
10
+ @pytest.mark.local
11
+ def test_config_load():
12
+ """Test loading a config with usage tracking."""
13
+ with parsl.load(Config(usage_tracking=3)):
14
+ pass
15
+ parsl.clear()
16
+
17
+
18
+ @pytest.mark.local
19
+ @pytest.mark.parametrize("level", (0, 1, 2, 3, False, True))
20
+ def test_valid(level):
21
+ """Test valid usage_tracking values."""
22
+ Config(usage_tracking=level)
23
+ assert Config(usage_tracking=level).usage_tracking == level
24
+
25
+
26
+ @pytest.mark.local
27
+ @pytest.mark.parametrize("level", (12, 1000, -1))
28
+ def test_invalid_values(level):
29
+ """Test invalid usage_tracking values."""
30
+ with pytest.raises(ConfigurationError):
31
+ Config(usage_tracking=level)
32
+
33
+
34
+ @pytest.mark.local
35
+ @pytest.mark.parametrize("level", ("abcd", None, bytes(1), 1.0, 1j, object()))
36
+ def test_invalid_types(level):
37
+ """Test invalid usage_tracking types."""
38
+ with pytest.raises(Exception) as ex:
39
+ Config(usage_tracking=level)
40
+
41
+ # with typeguard 4.x this is TypeCheckError,
42
+ # with typeguard 2.x this is TypeError
43
+ # we can't instantiate TypeCheckError if we're in typeguard 2.x environment
44
+ # because it does not exist... so check name using strings.
45
+ assert ex.type.__name__ in ["TypeCheckError", "TypeError"]
@@ -0,0 +1,6 @@
1
+ """Module for defining the usage tracking levels."""
2
+
3
+ DISABLED = 0 # Tracking is disabled
4
+ LEVEL_1 = 1 # Share info about Parsl version, Python version, platform
5
+ LEVEL_2 = 2 # Share info about config + level 1
6
+ LEVEL_3 = 3 # Share info about app count, app fails, execution time + level 2
@@ -7,8 +7,11 @@ import time
7
7
  import uuid
8
8
 
9
9
  from parsl.dataflow.states import States
10
+ from parsl.errors import ConfigurationError
10
11
  from parsl.multiprocessing import ForkProcess
11
12
  from parsl.usage_tracking.api import get_parsl_usage
13
+ from parsl.usage_tracking.levels import DISABLED as USAGE_TRACKING_DISABLED
14
+ from parsl.usage_tracking.levels import LEVEL_3 as USAGE_TRACKING_LEVEL_3
12
15
  from parsl.utils import setproctitle
13
16
  from parsl.version import VERSION as PARSL_VERSION
14
17
 
@@ -110,17 +113,32 @@ class UsageTracker:
110
113
  self.python_version = "{}.{}.{}".format(sys.version_info.major,
111
114
  sys.version_info.minor,
112
115
  sys.version_info.micro)
113
- self.tracking_enabled = self.check_tracking_enabled()
114
- logger.debug("Tracking status: {}".format(self.tracking_enabled))
115
-
116
- def check_tracking_enabled(self):
117
- """Check if tracking is enabled.
118
-
119
- Tracking will be enabled unless the following is true:
120
-
121
- 1. dfk.config.usage_tracking is set to False
122
-
116
+ self.tracking_level = self.check_tracking_level()
117
+ self.start_time = None
118
+ logger.debug("Tracking level: {}".format(self.tracking_level))
119
+
120
+ def check_tracking_level(self) -> int:
121
+ """Check if tracking is enabled and return level.
122
+
123
+ Checks usage_tracking in Config
124
+ - Possible values: [True, False, 0, 1, 2, 3]
125
+
126
+ True/False values are treated as Level 1/Level 0 respectively.
127
+
128
+ Returns: int
129
+ - 0 : Tracking is disabled
130
+ - 1 : Tracking is enabled with level 1
131
+ Share info about Parsl version, Python version, platform
132
+ - 2 : Tracking is enabled with level 2
133
+ Share info about config + level 1
134
+ - 3 : Tracking is enabled with level 3
135
+ Share info about app count, app fails, execution time + level 2
123
136
  """
137
+ if not USAGE_TRACKING_DISABLED <= self.config.usage_tracking <= USAGE_TRACKING_LEVEL_3:
138
+ raise ConfigurationError(
139
+ f"Usage Tracking values must be 0, 1, 2, or 3 and not {self.config.usage_tracking}"
140
+ )
141
+
124
142
  return self.config.usage_tracking
125
143
 
126
144
  def construct_start_message(self) -> bytes:
@@ -133,18 +151,28 @@ class UsageTracker:
133
151
  'parsl_v': self.parsl_version,
134
152
  'python_v': self.python_version,
135
153
  'platform.system': platform.system(),
136
- 'start': int(time.time()),
137
- 'components': get_parsl_usage(self.dfk._config)}
154
+ 'tracking_level': int(self.tracking_level)}
155
+
156
+ if self.tracking_level >= 2:
157
+ message['components'] = get_parsl_usage(self.dfk._config)
158
+
159
+ if self.tracking_level == 3:
160
+ self.start_time = int(time.time())
161
+ message['start'] = self.start_time
162
+
138
163
  logger.debug(f"Usage tracking start message: {message}")
139
164
 
140
165
  return self.encode_message(message)
141
166
 
142
167
  def construct_end_message(self) -> bytes:
143
168
  """Collect the final run information at the time of DFK cleanup.
169
+ This is only called if tracking level is 3.
144
170
 
145
171
  Returns:
146
172
  - Message dict dumped as json string, ready for UDP
147
173
  """
174
+ end_time = int(time.time())
175
+
148
176
  app_count = self.dfk.task_count
149
177
 
150
178
  app_fails = self.dfk.task_state_counts[States.failed] + self.dfk.task_state_counts[States.dep_fail]
@@ -157,7 +185,8 @@ class UsageTracker:
157
185
  'app_fails': app_fails}
158
186
 
159
187
  message = {'correlator': self.correlator_uuid,
160
- 'end': int(time.time()),
188
+ 'end': end_time,
189
+ 'execution_time': end_time - self.start_time,
161
190
  'components': [dfk_component] + get_parsl_usage(self.dfk._config)}
162
191
  logger.debug(f"Usage tracking end message (unencoded): {message}")
163
192
 
@@ -168,20 +197,22 @@ class UsageTracker:
168
197
 
169
198
  def send_UDP_message(self, message: bytes) -> None:
170
199
  """Send UDP message."""
171
- if self.tracking_enabled:
172
- try:
173
- proc = udp_messenger(self.domain_name, self.UDP_PORT, self.sock_timeout, message)
174
- self.procs.append(proc)
175
- except Exception as e:
176
- logger.debug("Usage tracking failed: {}".format(e))
200
+ try:
201
+ proc = udp_messenger(self.domain_name, self.UDP_PORT, self.sock_timeout, message)
202
+ self.procs.append(proc)
203
+ except Exception as e:
204
+ logger.debug("Usage tracking failed: {}".format(e))
177
205
 
178
206
  def send_start_message(self) -> None:
179
- message = self.construct_start_message()
180
- self.send_UDP_message(message)
207
+ if self.tracking_level:
208
+ self.start_time = time.time()
209
+ message = self.construct_start_message()
210
+ self.send_UDP_message(message)
181
211
 
182
212
  def send_end_message(self) -> None:
183
- message = self.construct_end_message()
184
- self.send_UDP_message(message)
213
+ if self.tracking_level == 3:
214
+ message = self.construct_end_message()
215
+ self.send_UDP_message(message)
185
216
 
186
217
  def close(self, timeout: float = 10.0) -> None:
187
218
  """First give each process one timeout period to finish what it is
parsl/version.py CHANGED
@@ -3,4 +3,4 @@
3
3
  Year.Month.Day[alpha/beta/..]
4
4
  Alphas will be numbered like this -> 2024.12.10a0
5
5
  """
6
- VERSION = '2024.06.03'
6
+ VERSION = '2024.06.17'