1313
1414from __future__ import annotations
1515
16+ import contextvars
1617import queue
1718import threading
1819from contextlib import contextmanager
19- from typing import Dict , Iterable , Iterator , List , Optional , Set
20+ from typing import Dict , Iterable , Iterator , List , Optional , Set , Tuple
2021
2122from cloudquery .sdk .message import SyncCheckpointMessage
2223
@@ -49,7 +50,6 @@ def __init__(self, msg: SyncCheckpointMessage, keys: List[str]):
4950 self .keys = keys
5051 self .blockers = 0
5152 self .dependents : List [_Pending ] = []
52- self .sent = False
5353
5454
5555class Checkpoints :
@@ -62,7 +62,9 @@ class Checkpoints:
6262
6363 def __init__ (self ):
6464 self ._lock = threading .Lock ()
65- self ._local = threading .local ()
65+ self ._scope : contextvars .ContextVar [Optional [Scope ]] = contextvars .ContextVar (
66+ "cloudquery_checkpoint_scope" , default = None
67+ )
6668 self ._res : Optional [queue .Queue ] = None
6769 # newest unsent checkpoint of each key
6870 self ._unsent : Dict [str , _Pending ] = {}
@@ -89,17 +91,20 @@ def new_scope(self, parent: Optional[Scope]) -> Scope:
8991
9092 @contextmanager
9193 def entered (self , scope : Scope ) -> Iterator [None ]:
92- """Makes scope the current one on this thread, for the state changes its resolver makes."""
93- previous = getattr (self ._local , "scope" , None )
94- self ._local .scope = scope
94+ """
95+ Makes scope the current one in this context, for the state changes its resolver makes.
96+ To change state from other threads, run them in a copy of the context, e.g. with
97+ ``contextvars.copy_context().run``; asyncio tasks copy it already.
98+ """
99+ token = self ._scope .set (scope )
95100 try :
96101 yield
97102 finally :
98- self ._local . scope = previous
103+ self ._scope . reset ( token )
99104
100105 def current_scope (self ) -> Optional [Scope ]:
101- """Returns the scope of the table resolver running on this thread , or None outside table resolvers."""
102- return getattr ( self ._local , "scope" , None )
106+ """Returns the scope of the table resolver running in this context , or None outside table resolvers."""
107+ return self ._scope . get ( )
103108
104109 def resolver_finished (self , scope : Scope ) -> None :
105110 """
@@ -121,47 +126,56 @@ def resolver_finished(self, scope: Scope) -> None:
121126
122127 def schedule (
123128 self ,
124- scopes : Iterable [ Optional [ Scope ]],
125- keys : List [str ],
126- msg : SyncCheckpointMessage ,
129+ checkpoints : List [
130+ Tuple [ Iterable [ Optional [ Scope ]], List [str ], SyncCheckpointMessage ]
131+ ] ,
127132 ) -> None :
128133 """
129- Sends msg once every resource the scopes produced so far is on the queue, including
130- the relations spawned from them, and after every earlier unsent checkpoint of keys.
131- A None scope stands for changes made outside table resolvers, which wait for nothing.
134+ Sends each (scopes, keys, msg) once every resource the scopes produced so far is on the
135+ queue, including the relations spawned from them, and after every earlier unsent
136+ checkpoint of keys. A None scope stands for changes made outside table resolvers, which
137+ wait for nothing. Schedules all of them or, if it raises, none of them.
132138
133139 Raises:
134- RuntimeError: if no sync is running, as the checkpoint could not be sent.
140+ RuntimeError: if no sync is running, as the checkpoints could not be sent.
135141 """
136142 with self ._lock :
137143 if self ._res is None :
138144 raise RuntimeError (
139145 "state checkpoints can only be flushed while the scheduler syncs, "
140146 "e.g. from a table resolver or the post-sync hook"
141147 )
142- pending = _Pending (msg , keys )
143- for scope in {s for s in scopes if s is not None }:
144- # the scope's own resources are on the queue already: resolvers put each
145- # resource on it before they resume, so only its relations can be in flight
146- for child in scope .open_children :
147- child .waiters .append (pending )
148- pending .blockers += 1
149- earlier = set ()
150- for key in keys :
151- previous = self ._unsent .get (key )
152- if previous is not None and previous not in earlier :
153- earlier .add (previous )
154- previous .dependents .append (pending )
155- pending .blockers += 1
156- self ._unsent [key ] = pending
157- if pending .blockers == 0 :
158- self ._send ([pending ])
148+ for scopes , keys , msg in checkpoints :
149+ self ._schedule (scopes , keys , msg )
150+
151+ def _schedule (
152+ self ,
153+ scopes : Iterable [Optional [Scope ]],
154+ keys : List [str ],
155+ msg : SyncCheckpointMessage ,
156+ ) -> None :
157+ pending = _Pending (msg , keys )
158+ for scope in {s for s in scopes if s is not None }:
159+ # the scope's own resources are on the queue already: resolvers put each
160+ # resource on it before they resume, so only its relations can be in flight
161+ for child in scope .open_children :
162+ child .waiters .append (pending )
163+ pending .blockers += 1
164+ earlier = set ()
165+ for key in keys :
166+ previous = self ._unsent .get (key )
167+ if previous is not None and previous not in earlier :
168+ earlier .add (previous )
169+ previous .dependents .append (pending )
170+ pending .blockers += 1
171+ self ._unsent [key ] = pending
172+ if pending .blockers == 0 :
173+ self ._send ([pending ])
159174
160175 def _send (self , ready : List [_Pending ]) -> None :
161176 """Puts ready checkpoints on the queue, then the ones that only waited for them. Called with the lock held."""
162177 while ready :
163178 pending = ready .pop ()
164- pending .sent = True
165179 if self ._res is not None :
166180 self ._res .put (pending .msg )
167181 for key in pending .keys :
0 commit comments