Repository navigation
Expand file tree
/
Copy pathruntime_state.py
More file actions
192 lines (169 loc) · 6.65 KB
/
Copy pathruntime_state.py
File metadata and controls
192 lines (169 loc) · 6.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
from __future__ import annotations
from dataclasses import asdict, dataclass
from threading import RLock
from time import time
from typing import Optional
MAX_RUNTIME_TEXT_BYTES = 512 * 1024
VALID_STATUSES = frozenset({"valid", "incomplete", "hard_error"})
class StaleRuntimeCandidateUpdate(ValueError):
"""Raised when one browser session sends an obsolete update."""
@dataclass(frozen=True)
class RuntimeCandidateState:
state_key: str
node_kind: str
input_text: str
input_status: str
error: str
accepted_text: Optional[str]
revision: int
client_id: str
client_sequence: int
updated_at: float
def to_dict(self) -> dict:
data = asdict(self)
data.pop("client_id", None)
data.pop("client_sequence", None)
return data
class RuntimeCandidateStateStore:
def __init__(self) -> None:
self._lock = RLock()
self._states: dict[str, RuntimeCandidateState] = {}
self._client_updates: dict[tuple[str, str], tuple[int, str, str]] = {}
@staticmethod
def _validate(
*,
state_key: str,
node_kind: str,
text: str,
input_status: str,
error: str,
client_id: str,
client_sequence: int,
) -> tuple[str, str, str, str, str, str, int]:
if not isinstance(state_key, str):
raise TypeError("state_key must be a string")
key = state_key.strip()
if not key:
raise ValueError("state_key must not be empty")
if len(key) > 256:
raise ValueError("state_key must be at most 256 characters")
if node_kind not in {"flat", "ex"}:
raise ValueError("node_kind must be 'flat' or 'ex'")
if not isinstance(text, str):
raise TypeError("text must be a string")
if len(text.encode("utf-8")) > MAX_RUNTIME_TEXT_BYTES:
raise ValueError(
f"text must be at most {MAX_RUNTIME_TEXT_BYTES} UTF-8 bytes"
)
if input_status not in VALID_STATUSES:
raise ValueError("input_status is invalid")
if not isinstance(error, str):
raise TypeError("error must be a string")
message = error.strip()
if input_status == "valid" and message:
raise ValueError("valid input must not include an error")
if input_status != "valid" and not message:
raise ValueError("invalid input must include an error")
if not isinstance(client_id, str):
raise TypeError("client_id must be a string")
session = client_id.strip()
if not session:
raise ValueError("client_id must not be empty")
if len(session) > 128:
raise ValueError("client_id must be at most 128 characters")
if isinstance(client_sequence, bool) or not isinstance(client_sequence, int):
raise TypeError("client_sequence must be an integer")
if client_sequence < 0:
raise ValueError("client_sequence must not be negative")
return key, node_kind, text, input_status, message, session, client_sequence
def update(
self,
*,
state_key: str,
node_kind: str,
text: str,
input_status: str,
error: str,
client_id: str,
client_sequence: int,
) -> RuntimeCandidateState:
key, kind, value, status, message, session, sequence = self._validate(
state_key=state_key,
node_kind=node_kind,
text=text,
input_status=input_status,
error=error,
client_id=client_id,
client_sequence=client_sequence,
)
signature = (value, kind)
with self._lock:
previous = self._states.get(key)
if previous is not None and previous.node_kind != kind:
raise ValueError("state_key is already used by another Runtime node kind")
previous_client_update = self._client_updates.get((key, session))
if previous_client_update is not None:
previous_sequence, previous_text, previous_kind = previous_client_update
if sequence < previous_sequence:
raise StaleRuntimeCandidateUpdate(
"client_sequence is older than the accepted update"
)
if sequence == previous_sequence:
if signature != (previous_text, previous_kind):
raise StaleRuntimeCandidateUpdate(
"client_sequence was already used for different text"
)
if (
previous is not None
and previous.client_id == session
and previous.input_text == value
):
return previous
raise StaleRuntimeCandidateUpdate(
"update was already superseded by another client"
)
accepted_text = previous.accepted_text if previous is not None else None
revision = previous.revision if previous is not None else 0
if status == "valid":
if accepted_text != value:
accepted_text = value
revision += 1
state = RuntimeCandidateState(
state_key=key,
node_kind=kind,
input_text=value,
input_status=status,
error=message,
accepted_text=accepted_text,
revision=revision,
client_id=session,
client_sequence=sequence,
updated_at=time(),
)
self._states[key] = state
self._client_updates[(key, session)] = (sequence, value, kind)
return state
def get(self, state_key: str) -> Optional[RuntimeCandidateState]:
if not isinstance(state_key, str):
return None
key = state_key.strip()
if not key:
return None
with self._lock:
return self._states.get(key)
def clear(self, state_key: str) -> bool:
if not isinstance(state_key, str):
return False
key = state_key.strip()
if not key:
return False
with self._lock:
removed = self._states.pop(key, None) is not None
for client_key in [
client_key
for client_key in self._client_updates
if client_key[0] == key
]:
self._client_updates.pop(client_key, None)
return removed
RUNTIME_CANDIDATE_STATE_STORE = RuntimeCandidateStateStore()