durabletask 1.4.0.dev31__py3-none-any.whl → 1.4.0.dev32__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.
durabletask/__init__.py CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  """Durable Task SDK for Python"""
5
5
 
6
+ from durabletask.grpc_options import GrpcChannelOptions, GrpcRetryPolicyOptions
6
7
  from durabletask.payload.store import LargePayloadStorageOptions, PayloadStore
7
8
  from durabletask.worker import (
8
9
  ActivityWorkItemFilter,
@@ -17,6 +18,8 @@ __all__ = [
17
18
  "ActivityWorkItemFilter",
18
19
  "ConcurrencyOptions",
19
20
  "EntityWorkItemFilter",
21
+ "GrpcChannelOptions",
22
+ "GrpcRetryPolicyOptions",
20
23
  "LargePayloadStorageOptions",
21
24
  "OrchestrationWorkItemFilter",
22
25
  "PayloadStore",
durabletask/client.py CHANGED
@@ -14,6 +14,7 @@ import grpc.aio
14
14
  import durabletask.history as history
15
15
  from durabletask.entities import EntityInstanceId
16
16
  from durabletask.entities.entity_metadata import EntityMetadata
17
+ from durabletask.grpc_options import GrpcChannelOptions
17
18
  import durabletask.internal.helpers as helpers
18
19
  import durabletask.internal.history_helpers as history_helpers
19
20
  import durabletask.internal.orchestrator_service_pb2 as pb
@@ -161,18 +162,22 @@ class TaskHubGrpcClient:
161
162
  metadata: Optional[list[tuple[str, str]]] = None,
162
163
  log_handler: Optional[logging.Handler] = None,
163
164
  log_formatter: Optional[logging.Formatter] = None,
165
+ channel: Optional[grpc.Channel] = None,
164
166
  secure_channel: bool = False,
165
167
  interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
168
+ channel_options: Optional[GrpcChannelOptions] = None,
166
169
  default_version: Optional[str] = None,
167
170
  payload_store: Optional[PayloadStore] = None):
168
171
 
169
- interceptors = prepare_sync_interceptors(metadata, interceptors)
170
-
171
- channel = shared.get_grpc_channel(
172
- host_address=host_address,
173
- secure_channel=secure_channel,
174
- interceptors=interceptors
175
- )
172
+ self._owns_channel = channel is None
173
+ if channel is None:
174
+ interceptors = prepare_sync_interceptors(metadata, interceptors)
175
+ channel = shared.get_grpc_channel(
176
+ host_address=host_address,
177
+ secure_channel=secure_channel,
178
+ interceptors=interceptors,
179
+ channel_options=channel_options,
180
+ )
176
181
  self._channel = channel
177
182
  self._stub = stubs.TaskHubSidecarServiceStub(channel)
178
183
  self._logger = shared.get_logger("client", log_handler, log_formatter)
@@ -180,8 +185,15 @@ class TaskHubGrpcClient:
180
185
  self._payload_store = payload_store
181
186
 
182
187
  def close(self) -> None:
183
- """Closes the underlying gRPC channel."""
184
- self._channel.close()
188
+ """Closes the underlying gRPC channel.
189
+
190
+ Only closes channels created internally. If a pre-configured channel
191
+ was passed via the ``channel`` constructor parameter, this method is
192
+ a no-op — the caller retains ownership and is responsible for closing
193
+ it.
194
+ """
195
+ if self._owns_channel:
196
+ self._channel.close()
185
197
 
186
198
  def schedule_new_orchestration(self, orchestrator: Union[task.Orchestrator[TInput, TOutput], str], *,
187
199
  input: Optional[TInput] = None,
@@ -480,18 +492,22 @@ class AsyncTaskHubGrpcClient:
480
492
  metadata: Optional[list[tuple[str, str]]] = None,
481
493
  log_handler: Optional[logging.Handler] = None,
482
494
  log_formatter: Optional[logging.Formatter] = None,
495
+ channel: Optional[grpc.aio.Channel] = None,
483
496
  secure_channel: bool = False,
484
497
  interceptors: Optional[Sequence[shared.AsyncClientInterceptor]] = None,
498
+ channel_options: Optional[GrpcChannelOptions] = None,
485
499
  default_version: Optional[str] = None,
486
500
  payload_store: Optional[PayloadStore] = None):
487
501
 
488
- interceptors = prepare_async_interceptors(metadata, interceptors)
489
-
490
- channel = shared.get_async_grpc_channel(
491
- host_address=host_address,
492
- secure_channel=secure_channel,
493
- interceptors=interceptors
494
- )
502
+ self._owns_channel = channel is None
503
+ if channel is None:
504
+ interceptors = prepare_async_interceptors(metadata, interceptors)
505
+ channel = shared.get_async_grpc_channel(
506
+ host_address=host_address,
507
+ secure_channel=secure_channel,
508
+ interceptors=interceptors,
509
+ channel_options=channel_options,
510
+ )
495
511
  self._channel = channel
496
512
  self._stub = stubs.TaskHubSidecarServiceStub(channel)
497
513
  self._logger = shared.get_logger("async_client", log_handler, log_formatter)
@@ -499,8 +515,15 @@ class AsyncTaskHubGrpcClient:
499
515
  self._payload_store = payload_store
500
516
 
501
517
  async def close(self) -> None:
502
- """Closes the underlying gRPC channel."""
503
- await self._channel.close()
518
+ """Closes the underlying gRPC channel.
519
+
520
+ Only closes channels created internally. If a pre-configured channel
521
+ was passed via the ``channel`` constructor parameter, this method is
522
+ a no-op — the caller retains ownership and is responsible for closing
523
+ it.
524
+ """
525
+ if self._owns_channel:
526
+ await self._channel.close()
504
527
 
505
528
  async def __aenter__(self):
506
529
  return self
@@ -0,0 +1,102 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ import json
8
+ from typing import Any, Optional
9
+
10
+
11
+ @dataclass
12
+ class GrpcRetryPolicyOptions:
13
+ """Configuration for transport-level gRPC retries."""
14
+
15
+ max_attempts: int = 4
16
+ initial_backoff_seconds: float = 0.05
17
+ max_backoff_seconds: float = 0.25
18
+ backoff_multiplier: float = 2.0
19
+ retryable_status_codes: list[str] = field(default_factory=lambda: ["UNAVAILABLE"])
20
+
21
+ def __post_init__(self) -> None:
22
+ if self.max_attempts < 2:
23
+ raise ValueError("max_attempts must be >= 2")
24
+ if self.initial_backoff_seconds <= 0:
25
+ raise ValueError("initial_backoff_seconds must be > 0")
26
+ if self.max_backoff_seconds <= 0:
27
+ raise ValueError("max_backoff_seconds must be > 0")
28
+ if self.backoff_multiplier <= 0:
29
+ raise ValueError("backoff_multiplier must be > 0")
30
+ if self.max_backoff_seconds < self.initial_backoff_seconds:
31
+ raise ValueError("max_backoff_seconds must be >= initial_backoff_seconds")
32
+ if len(self.retryable_status_codes) == 0:
33
+ raise ValueError("retryable_status_codes cannot be empty")
34
+ # Validate that backoff values are representable as non-zero gRPC duration strings.
35
+ self._format_duration(self.initial_backoff_seconds)
36
+ self._format_duration(self.max_backoff_seconds)
37
+
38
+ @staticmethod
39
+ def _format_duration(seconds: float) -> str:
40
+ formatted = f"{seconds:.9f}".rstrip('0')
41
+ if formatted.endswith('.'):
42
+ formatted += '0'
43
+ if float(formatted) == 0:
44
+ raise ValueError(
45
+ f"Duration {seconds!r} rounds to zero; use a value large enough to "
46
+ "produce a non-zero gRPC duration string."
47
+ )
48
+ return f"{formatted}s"
49
+
50
+ def to_service_config(self) -> dict[str, Any]:
51
+ return {
52
+ "methodConfig": [
53
+ {
54
+ "name": [{}],
55
+ "retryPolicy": {
56
+ "maxAttempts": self.max_attempts,
57
+ "initialBackoff": self._format_duration(self.initial_backoff_seconds),
58
+ "maxBackoff": self._format_duration(self.max_backoff_seconds),
59
+ "backoffMultiplier": self.backoff_multiplier,
60
+ "retryableStatusCodes": self.retryable_status_codes,
61
+ },
62
+ }
63
+ ]
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class GrpcChannelOptions:
69
+ """Configuration for transport-level gRPC channel behavior."""
70
+
71
+ max_receive_message_length: Optional[int] = None
72
+ max_send_message_length: Optional[int] = None
73
+ keepalive_time_ms: Optional[int] = None
74
+ keepalive_timeout_ms: Optional[int] = None
75
+ keepalive_permit_without_calls: Optional[bool] = None
76
+ retry_policy: Optional[GrpcRetryPolicyOptions] = None
77
+ raw_options: list[tuple[str, Any]] = field(default_factory=list)
78
+
79
+ def to_grpc_options(self) -> list[tuple[str, Any]]:
80
+ options = list(self.raw_options)
81
+
82
+ if self.max_receive_message_length is not None:
83
+ options.append(("grpc.max_receive_message_length", self.max_receive_message_length))
84
+ if self.max_send_message_length is not None:
85
+ options.append(("grpc.max_send_message_length", self.max_send_message_length))
86
+ if self.keepalive_time_ms is not None:
87
+ options.append(("grpc.keepalive_time_ms", self.keepalive_time_ms))
88
+ if self.keepalive_timeout_ms is not None:
89
+ options.append(("grpc.keepalive_timeout_ms", self.keepalive_timeout_ms))
90
+ if self.keepalive_permit_without_calls is not None:
91
+ options.append(
92
+ (
93
+ "grpc.keepalive_permit_without_calls",
94
+ 1 if self.keepalive_permit_without_calls else 0,
95
+ )
96
+ )
97
+
98
+ if self.retry_policy is not None:
99
+ options.append(("grpc.enable_retries", 1))
100
+ options.append(("grpc.service_config", json.dumps(self.retry_policy.to_service_config())))
101
+
102
+ return options
@@ -9,6 +9,7 @@ from typing import Any, Optional, Sequence, Union
9
9
 
10
10
  import grpc
11
11
  import grpc.aio
12
+ from durabletask.grpc_options import GrpcChannelOptions
12
13
 
13
14
  ClientInterceptor = Union[
14
15
  grpc.UnaryUnaryClientInterceptor,
@@ -39,7 +40,8 @@ def get_default_host_address() -> str:
39
40
  def get_grpc_channel(
40
41
  host_address: Optional[str],
41
42
  secure_channel: bool = False,
42
- interceptors: Optional[Sequence[ClientInterceptor]] = None) -> grpc.Channel:
43
+ interceptors: Optional[Sequence[ClientInterceptor]] = None,
44
+ channel_options: Optional[GrpcChannelOptions] = None) -> grpc.Channel:
43
45
 
44
46
  if host_address is None:
45
47
  host_address = get_default_host_address()
@@ -59,10 +61,21 @@ def get_grpc_channel(
59
61
  break
60
62
 
61
63
  # Create the base channel
64
+ options = channel_options.to_grpc_options() if channel_options is not None else None
62
65
  if secure_channel:
63
- channel = grpc.secure_channel(host_address, grpc.ssl_channel_credentials())
66
+ if options is None:
67
+ channel = grpc.secure_channel(host_address, grpc.ssl_channel_credentials())
68
+ else:
69
+ channel = grpc.secure_channel(
70
+ host_address,
71
+ grpc.ssl_channel_credentials(),
72
+ options=options,
73
+ )
64
74
  else:
65
- channel = grpc.insecure_channel(host_address)
75
+ if options is None:
76
+ channel = grpc.insecure_channel(host_address)
77
+ else:
78
+ channel = grpc.insecure_channel(host_address, options=options)
66
79
 
67
80
  # Apply interceptors ONLY if they exist
68
81
  if interceptors:
@@ -73,7 +86,8 @@ def get_grpc_channel(
73
86
  def get_async_grpc_channel(
74
87
  host_address: Optional[str],
75
88
  secure_channel: bool = False,
76
- interceptors: Optional[Sequence[AsyncClientInterceptor]] = None) -> grpc.aio.Channel:
89
+ interceptors: Optional[Sequence[AsyncClientInterceptor]] = None,
90
+ channel_options: Optional[GrpcChannelOptions] = None) -> grpc.aio.Channel:
77
91
 
78
92
  if host_address is None:
79
93
  host_address = get_default_host_address()
@@ -90,14 +104,34 @@ def get_async_grpc_channel(
90
104
  host_address = host_address[len(protocol):]
91
105
  break
92
106
 
107
+ options = channel_options.to_grpc_options() if channel_options is not None else None
108
+
93
109
  if secure_channel:
94
- channel = grpc.aio.secure_channel(
95
- host_address, grpc.ssl_channel_credentials(),
96
- interceptors=interceptors)
110
+ if options is None:
111
+ channel = grpc.aio.secure_channel(
112
+ host_address,
113
+ grpc.ssl_channel_credentials(),
114
+ interceptors=interceptors,
115
+ )
116
+ else:
117
+ channel = grpc.aio.secure_channel(
118
+ host_address,
119
+ grpc.ssl_channel_credentials(),
120
+ interceptors=interceptors,
121
+ options=options,
122
+ )
97
123
  else:
98
- channel = grpc.aio.insecure_channel(
99
- host_address,
100
- interceptors=interceptors)
124
+ if options is None:
125
+ channel = grpc.aio.insecure_channel(
126
+ host_address,
127
+ interceptors=interceptors,
128
+ )
129
+ else:
130
+ channel = grpc.aio.insecure_channel(
131
+ host_address,
132
+ interceptors=interceptors,
133
+ options=options,
134
+ )
101
135
 
102
136
  return channel
103
137
 
durabletask/worker.py CHANGED
@@ -21,6 +21,7 @@ from packaging.version import InvalidVersion, parse
21
21
  import grpc
22
22
  from google.protobuf import empty_pb2
23
23
 
24
+ from durabletask.grpc_options import GrpcChannelOptions
24
25
  from durabletask.entities.entity_operation_failed_exception import EntityOperationFailedException
25
26
  from durabletask.internal import helpers
26
27
  from durabletask.internal.entity_state_shim import StateShim
@@ -361,8 +362,13 @@ class TaskHubGrpcWorker:
361
362
  Defaults to None.
362
363
  secure_channel (bool, optional): Whether to use a secure gRPC channel (TLS).
363
364
  Defaults to False.
365
+ channel (Optional[grpc.Channel], optional): Pre-configured gRPC channel to use.
366
+ If set, host address, secure_channel, interceptors, and channel_options
367
+ are ignored when creating connections.
364
368
  interceptors (Optional[Sequence[shared.ClientInterceptor]], optional): Custom gRPC
365
369
  interceptors to apply to the channel. Defaults to None.
370
+ channel_options (Optional[GrpcChannelOptions], optional): Extra low-level gRPC
371
+ channel configuration including retry/service config options.
366
372
  concurrency_options (Optional[ConcurrencyOptions], optional): Configuration for
367
373
  controlling worker concurrency limits. If None, default settings are used.
368
374
 
@@ -426,8 +432,10 @@ class TaskHubGrpcWorker:
426
432
  metadata: Optional[list[tuple[str, str]]] = None,
427
433
  log_handler: Optional[logging.Handler] = None,
428
434
  log_formatter: Optional[logging.Formatter] = None,
435
+ channel: Optional[grpc.Channel] = None,
429
436
  secure_channel: bool = False,
430
437
  interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
438
+ channel_options: Optional[GrpcChannelOptions] = None,
431
439
  concurrency_options: Optional[ConcurrencyOptions] = None,
432
440
  maximum_timer_interval: Optional[timedelta] = DEFAULT_MAXIMUM_TIMER_INTERVAL,
433
441
  payload_store: Optional[PayloadStore] = None,
@@ -439,8 +447,10 @@ class TaskHubGrpcWorker:
439
447
  self._logger = shared.get_logger("worker", log_handler, log_formatter)
440
448
  self._shutdown = Event()
441
449
  self._is_running = False
450
+ self._channel = channel
442
451
  self._secure_channel = secure_channel
443
452
  self._payload_store = payload_store
453
+ self._channel_options = channel_options
444
454
 
445
455
  # Use provided concurrency options or create default ones
446
456
  self._concurrency_options = (
@@ -590,7 +600,7 @@ class TaskHubGrpcWorker:
590
600
 
591
601
  def create_fresh_connection():
592
602
  nonlocal current_channel, current_stub, conn_retry_count
593
- if current_channel:
603
+ if current_channel and self._channel is None:
594
604
  try:
595
605
  current_channel.close()
596
606
  except Exception:
@@ -598,16 +608,22 @@ class TaskHubGrpcWorker:
598
608
  current_channel = None
599
609
  current_stub = None
600
610
  try:
601
- current_channel = shared.get_grpc_channel(
602
- self._host_address, self._secure_channel, self._interceptors
603
- )
611
+ if self._channel is not None:
612
+ current_channel = self._channel
613
+ else:
614
+ current_channel = shared.get_grpc_channel(
615
+ self._host_address,
616
+ self._secure_channel,
617
+ self._interceptors,
618
+ channel_options=self._channel_options,
619
+ )
604
620
  current_stub = stubs.TaskHubSidecarServiceStub(current_channel)
605
621
  current_stub.Hello(empty_pb2.Empty())
606
622
  conn_retry_count = 0
607
623
  self._logger.info(f"Created fresh connection to {self._host_address}")
608
624
  except Exception as e:
609
625
  self._logger.warning(f"Failed to create connection: {e}")
610
- current_channel = None
626
+ current_channel = self._channel if self._channel is not None else None
611
627
  current_stub = None
612
628
  raise
613
629
 
@@ -632,12 +648,12 @@ class TaskHubGrpcWorker:
632
648
  current_reader_thread = None
633
649
 
634
650
  # Close the channel
635
- if current_channel:
651
+ if current_channel and self._channel is None:
636
652
  try:
637
653
  current_channel.close()
638
654
  except Exception:
639
655
  pass
640
- current_channel = None
656
+ current_channel = self._channel if self._channel is not None else None
641
657
  current_stub = None
642
658
 
643
659
  def should_invalidate_connection(rpc_error):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask
3
- Version: 1.4.0.dev31
3
+ Version: 1.4.0.dev32
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -1,9 +1,10 @@
1
- durabletask/__init__.py,sha256=OdfKCNlS_NJawRfLWsFNj7YIHeGSQkh2VH3OzG0Oric,644
2
- durabletask/client.py,sha256=OfVkCn6x4PE-8_8Yn1agjKzRuLzJD_fPidVy4tVUblY,38896
1
+ durabletask/__init__.py,sha256=GhLZFOSOVlP7Jomi6f-nNYiUxDf5a2wNylkBAcshEBA,780
2
+ durabletask/client.py,sha256=PUOTYi0Kw7JoREbz-W8Gw5tjN_VCZxBLMqLq0T97unQ,40086
3
+ durabletask/grpc_options.py,sha256=k1xXnKzK5b_86wibuh7Ype396A-Gs6P4nbHBaUSpE9U,4174
3
4
  durabletask/history.py,sha256=VhrWb5eFr9TZSaazpotdMzKt2quwLuJVRk9j39WbTPs,18680
4
5
  durabletask/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
6
  durabletask/task.py,sha256=QS7RPUaWhb4Xq5hi7nnueooJqQSDO6ipEoiAiSVkAIs,24009
6
- durabletask/worker.py,sha256=B8aYoOguNuYccmmtGF9GFr3YEEdfTGJqcOoox0kByQM,133661
7
+ durabletask/worker.py,sha256=ZNx_85fGjgUkjJK_dEI2AZRB4qM3XMV6hgPlc3PPdFk,134691
7
8
  durabletask/entities/__init__.py,sha256=DbNd5riqWZaj3tG6gN82O8Q6wTmFpe6QaH0pQgDSPHs,721
8
9
  durabletask/entities/durable_entity.py,sha256=LQPWnUlRsHiFVRoTdpeSK--eXtjf2UGbVQwEEKf7QwI,3318
9
10
  durabletask/entities/entity_context.py,sha256=U-B3i9QP34N-6Fikx_tMp8zo0YLdmwhwpdxwjHd7z-M,5346
@@ -27,15 +28,15 @@ durabletask/internal/orchestrator_service_pb2.py,sha256=K5iG8oguyGhTGt0YmkDsK9ZQ
27
28
  durabletask/internal/orchestrator_service_pb2.pyi,sha256=TFa752MEoXPdK1fP2fDIIaqpGQJ0sbyXRoNg51hlegE,78590
28
29
  durabletask/internal/orchestrator_service_pb2_grpc.py,sha256=Rk0QpGOc8fTeIGULB6xww9CtX6GiLGlZ3JMoZyW9Wuo,60845
29
30
  durabletask/internal/proto_task_hub_sidecar_service_stub.py,sha256=pFlOCj1LhuhU5OKU4CZIuQCCsf9jilC9GXDqlEQYQ0U,1503
30
- durabletask/internal/shared.py,sha256=3GMYHWCFX9esgzlRwNr3J2AOeufAIypTZJQ9n2xI6g4,5589
31
+ durabletask/internal/shared.py,sha256=00NrfM7dmdBQznLt0S9rfWoYWBTOXHWnmg3nemhyWco,6819
31
32
  durabletask/internal/tracing.py,sha256=3cSanl0UcmGekItHcQ6tSl7wqSkS7_wOKzsDvJyS_ag,28821
32
33
  durabletask/payload/__init__.py,sha256=1h68pQvgk8JUp5LBJuBq9W4GUPYkdlhqmCCQEg6YBYI,784
33
34
  durabletask/payload/helpers.py,sha256=RYG5MEVAqHjm4zfFHs3Td91FVQHUoCcb5hbEJ4sYj5s,12350
34
35
  durabletask/payload/store.py,sha256=3qJMvKxRUkr6ScWUzxpKAVgzuhFLywRW8a2_5OOmNk4,3000
35
36
  durabletask/testing/__init__.py,sha256=rXbcSFtzuaRAbDNX-HmdgbxLTegvKJ1FRjZfSOIAMgA,323
36
37
  durabletask/testing/in_memory_backend.py,sha256=mF0zCA3GMLUd0vqa7CvkgBhYXH4VCsqHReIWuw72wvA,82530
37
- durabletask-1.4.0.dev31.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
38
- durabletask-1.4.0.dev31.dist-info/METADATA,sha256=aeUengR81uLV9mY6zeEvw5d4S8KCoM2uYLPP2rF0q04,4404
39
- durabletask-1.4.0.dev31.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
40
- durabletask-1.4.0.dev31.dist-info/top_level.txt,sha256=EBVyuKWnjOwq8bJI1Uvb9U3c4fzQxACWj9p83he6fik,12
41
- durabletask-1.4.0.dev31.dist-info/RECORD,,
38
+ durabletask-1.4.0.dev32.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
39
+ durabletask-1.4.0.dev32.dist-info/METADATA,sha256=6jrrQkmgSTCTRRdoeoFUUKpP0OUVNjB4inn5V4Ua9-M,4404
40
+ durabletask-1.4.0.dev32.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
41
+ durabletask-1.4.0.dev32.dist-info/top_level.txt,sha256=EBVyuKWnjOwq8bJI1Uvb9U3c4fzQxACWj9p83he6fik,12
42
+ durabletask-1.4.0.dev32.dist-info/RECORD,,