Skip to content

Commit a9f5556

Browse files
committed
Implement pausing for submissions
Introduce `submissions_paused` and `chunks_paused` tables (alongside the existing `submissions_{completed,failed,cancelled}` and `chunks_{completed,failed}` tables). Pausing a submission atomically moves it from `submissions` → `submissions_paused` and its remaining chunks from `chunks` → `chunks_paused`. Because paused chunks are no longer in the `chunks` table, the consumer dispatcher naturally skips them without any changes to the dispatch query. Unpausing reverses the move and notifies waiting consumers. Paused submissions can also be inserted directly in the paused state (via the new `paused: bool` field on `InsertSubmission`); in that case `notify_on_insert` is intentionally not fired. Paused submissions are cancellable; `cancel_submission` now handles the case where the submission is found in `submissions_paused`.
1 parent 5935222 commit a9f5556

16 files changed

Lines changed: 1008 additions & 28 deletions

File tree

‎libs/opsqueue_python/python/opsqueue/producer.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
SubmissionFailed,
2727
ChunkFailed,
2828
SubmissionNotCancellable,
29+
SubmissionPaused,
2930
)
3031

3132
__all__ = [
@@ -38,6 +39,7 @@
3839
"SubmissionNotCancellable",
3940
"SubmissionNotCancellableError",
4041
"SubmissionNotFoundError",
42+
"SubmissionPaused",
4143
"ChunkFailed",
4244
]
4345

@@ -324,7 +326,7 @@ def count_submissions(self) -> int:
324326

325327
def cancel_submission(self, submission_id: SubmissionId) -> None:
326328
"""
327-
Cancel a specific submission that is in progress.
329+
Cancel a specific submission that is in progress or paused.
328330
329331
Returns None if the submission was successfully cancelled.
330332
@@ -335,6 +337,36 @@ def cancel_submission(self, submission_id: SubmissionId) -> None:
335337
"""
336338
self.inner.cancel_submission(submission_id)
337339

340+
def pause_submission(self, submission_id: SubmissionId) -> None:
341+
"""
342+
Pause a specific submission that is currently in progress.
343+
344+
Once paused, the submission's chunks will not be dispatched to consumers
345+
until the submission is unpaused.
346+
347+
Returns None if the submission was successfully paused.
348+
349+
Raises:
350+
- `SubmissionNotFoundError` if the submission is not currently in
351+
the in-progress state (it may be already paused, completed, failed,
352+
cancelled, or simply not found).
353+
- `InternalProducerClientError` if there is a low-level internal error.
354+
"""
355+
self.inner.pause_submission(submission_id)
356+
357+
def unpause_submission(self, submission_id: SubmissionId) -> None:
358+
"""
359+
Unpause a specific submission that is currently paused,
360+
making it available to consumers again.
361+
362+
Returns None if the submission was successfully unpaused.
363+
364+
Raises:
365+
- `SubmissionNotFoundError` if the submission is not currently paused.
366+
- `InternalProducerClientError` if there is a low-level internal error.
367+
"""
368+
self.inner.unpause_submission(submission_id)
369+
338370
def get_submission_status(
339371
self, submission_id: SubmissionId
340372
) -> SubmissionStatus | None:

‎libs/opsqueue_python/src/common.rs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,9 @@ pub enum SubmissionStatus {
348348
Cancelled {
349349
submission: SubmissionCancelled,
350350
},
351+
Paused {
352+
submission: SubmissionPaused,
353+
},
351354
}
352355

353356
impl From<opsqueue::common::submission::SubmissionStatus> for SubmissionStatus {
@@ -368,6 +371,9 @@ impl From<opsqueue::common::submission::SubmissionStatus> for SubmissionStatus {
368371
Cancelled(s) => SubmissionStatus::Cancelled {
369372
submission: s.into(),
370373
},
374+
Paused(s) => SubmissionStatus::Paused {
375+
submission: s.into(),
376+
},
371377
}
372378
}
373379
}
@@ -478,6 +484,42 @@ pub struct SubmissionCancelled {
478484
pub cancelled_at: DateTime<Utc>,
479485
}
480486

487+
#[pyclass(frozen, get_all, module = "opsqueue")]
488+
#[derive(Debug, Clone, PartialEq, Eq)]
489+
pub struct SubmissionPaused {
490+
pub id: SubmissionId,
491+
pub chunks_total: u64,
492+
pub chunks_done: u64,
493+
pub metadata: Option<submission::Metadata>,
494+
pub strategic_metadata: StrategicMetadataMap,
495+
}
496+
497+
impl From<opsqueue::common::submission::SubmissionPaused> for SubmissionPaused {
498+
fn from(value: opsqueue::common::submission::SubmissionPaused) -> Self {
499+
Self {
500+
id: value.id.into(),
501+
chunks_total: value.chunks_total.into(),
502+
chunks_done: value.chunks_done.into(),
503+
metadata: value.metadata,
504+
strategic_metadata: value.strategic_metadata,
505+
}
506+
}
507+
}
508+
509+
#[pymethods]
510+
impl SubmissionPaused {
511+
fn __repr__(&self) -> String {
512+
format!(
513+
"SubmissionPaused(id={0}, chunks_total={1}, chunks_done={2}, metadata={3:?}, strategic_metadata={4:?})",
514+
self.id.__repr__(),
515+
self.chunks_total,
516+
self.chunks_done,
517+
self.metadata,
518+
self.strategic_metadata
519+
)
520+
}
521+
}
522+
481523
/// Submission could not be cancelled because it was already completed, failed
482524
/// or cancelled.
483525
#[pyclass(frozen, module = "opsqueue")]

‎libs/opsqueue_python/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ fn opsqueue_internal(m: &Bound<'_, PyModule>) -> PyResult<()> {
2424
m.add_class::<common::SubmissionCancelled>()?;
2525
m.add_class::<common::SubmissionCompleted>()?;
2626
m.add_class::<common::SubmissionFailed>()?;
27+
m.add_class::<common::SubmissionPaused>()?;
2728
m.add_class::<common::SubmissionNotCancellable>()?;
2829
m.add_class::<producer::PyChunksIter>()?;
2930
m.add_class::<consumer::ConsumerClient>()?;

‎libs/opsqueue_python/src/producer.rs‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,59 @@ impl ProducerClient {
147147
})
148148
}
149149

150+
/// Pause an in-progress submission.
151+
///
152+
/// Will return an error if the submission is not currently in progress
153+
/// (e.g. already paused, completed, failed, or not found).
154+
#[allow(clippy::result_large_err, clippy::type_complexity)]
155+
pub fn pause_submission(
156+
&self,
157+
py: Python<'_>,
158+
id: SubmissionId,
159+
) -> CPyResult<
160+
(),
161+
E![
162+
FatalPythonException,
163+
SubmissionNotFound,
164+
InternalProducerClientError
165+
],
166+
> {
167+
py.allow_threads(|| {
168+
self.block_unless_interrupted(async {
169+
self.producer_client
170+
.pause_submission(id.into())
171+
.await
172+
.map_err(|e| CError(R(e)))
173+
})
174+
})
175+
}
176+
177+
/// Unpause a paused submission, making it available to consumers again.
178+
///
179+
/// Will return an error if the submission is not currently paused.
180+
#[allow(clippy::result_large_err, clippy::type_complexity)]
181+
pub fn unpause_submission(
182+
&self,
183+
py: Python<'_>,
184+
id: SubmissionId,
185+
) -> CPyResult<
186+
(),
187+
E![
188+
FatalPythonException,
189+
SubmissionNotFound,
190+
InternalProducerClientError
191+
],
192+
> {
193+
py.allow_threads(|| {
194+
self.block_unless_interrupted(async {
195+
self.producer_client
196+
.unpause_submission(id.into())
197+
.await
198+
.map_err(|e| CError(R(e)))
199+
})
200+
})
201+
}
202+
150203
/// Retrieve the status (in progress, completed or failed) of a specific submission.
151204
///
152205
/// The returned SubmissionStatus object also includes the number of chunks finished so far,
@@ -212,6 +265,7 @@ impl ProducerClient {
212265
},
213266
metadata,
214267
strategic_metadata,
268+
paused: false,
215269
};
216270
self.block_unless_interrupted(async move {
217271
self.producer_client
@@ -272,6 +326,7 @@ impl ProducerClient {
272326
},
273327
metadata,
274328
strategic_metadata: strategic_metadata.unwrap_or_default(),
329+
paused: false,
275330
};
276331
self.producer_client
277332
.insert_submission(&submission, &otel_trace_carrier)

‎libs/opsqueue_python/tests/test_roundtrip.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
SubmissionNotFoundError,
1515
SubmissionNotCancellable,
1616
SubmissionNotCancellableError,
17+
SubmissionPaused,
1718
)
1819
from opsqueue.consumer import ConsumerClient, Chunk
1920
from opsqueue.common import SerializationFormat
@@ -508,3 +509,94 @@ def consume(x: int) -> int | None:
508509
with pytest.raises(SubmissionFailedError) as exc_info:
509510
producer_client.blocking_stream_completed_submission(submission_id)
510511
assert exc_info.value.submission.chunks_done == len(chunks) - 1
512+
513+
514+
def test_pause_in_progress(opsqueue: OpsqueueProcess) -> None:
515+
"""Pausing an in-progress submission succeeds and its status becomes Paused."""
516+
url = "file:///tmp/opsqueue/test_pause_in_progress"
517+
producer_client = ProducerClient(f"localhost:{opsqueue.port}", url)
518+
submission_id = producer_client.insert_submission((1, 2, 3), chunk_size=1)
519+
# Sanity-check: submission is in progress before pausing.
520+
assert isinstance(
521+
producer_client.get_submission_status(submission_id),
522+
SubmissionStatus.InProgress,
523+
)
524+
525+
producer_client.pause_submission(submission_id)
526+
527+
status = producer_client.get_submission_status(submission_id)
528+
assert isinstance(status, SubmissionStatus.Paused)
529+
assert isinstance(status.submission, SubmissionPaused)
530+
531+
532+
def test_pause_not_found(opsqueue: OpsqueueProcess) -> None:
533+
"""Pausing a non-existent submission raises SubmissionNotFoundError."""
534+
url = "file:///tmp/opsqueue/test_pause_not_found"
535+
producer_client = ProducerClient(f"localhost:{opsqueue.port}", url)
536+
with pytest.raises(SubmissionNotFoundError) as exc_info:
537+
producer_client.pause_submission(SubmissionId(0))
538+
assert exc_info.value.submission_id == 0
539+
540+
541+
def test_unpause_and_complete(
542+
opsqueue: OpsqueueProcess, any_consumer_strategy: StrategyDescription
543+
) -> None:
544+
"""Unpausing a paused submission makes it available to consumers again,
545+
and it can be completed normally afterwards."""
546+
url = "file:///tmp/opsqueue/test_unpause_and_complete"
547+
producer_client = ProducerClient(f"localhost:{opsqueue.port}", url)
548+
submission_id = producer_client.insert_submission((1, 2, 3), chunk_size=1)
549+
550+
producer_client.pause_submission(submission_id)
551+
assert isinstance(
552+
producer_client.get_submission_status(submission_id), SubmissionStatus.Paused
553+
)
554+
555+
producer_client.unpause_submission(submission_id)
556+
assert isinstance(
557+
producer_client.get_submission_status(submission_id),
558+
SubmissionStatus.InProgress,
559+
)
560+
561+
def run_consumer() -> None:
562+
consumer_client = ConsumerClient(f"localhost:{opsqueue.port}", url)
563+
strategy = strategy_from_description(any_consumer_strategy)
564+
consumer_client.run_each_op(lambda x: x, strategy=strategy)
565+
566+
with background_process(run_consumer):
567+
producer_client.blocking_stream_completed_submission(submission_id)
568+
assert isinstance(
569+
producer_client.get_submission_status(submission_id),
570+
SubmissionStatus.Completed,
571+
)
572+
573+
574+
def test_unpause_not_found(opsqueue: OpsqueueProcess) -> None:
575+
"""Unpausing a submission that is not paused (e.g. in-progress) raises
576+
SubmissionNotFoundError."""
577+
url = "file:///tmp/opsqueue/test_unpause_not_found"
578+
producer_client = ProducerClient(f"localhost:{opsqueue.port}", url)
579+
submission_id = producer_client.insert_submission((1, 2, 3), chunk_size=1)
580+
assert isinstance(
581+
producer_client.get_submission_status(submission_id),
582+
SubmissionStatus.InProgress,
583+
)
584+
with pytest.raises(SubmissionNotFoundError):
585+
producer_client.unpause_submission(submission_id)
586+
587+
588+
def test_cancel_paused(opsqueue: OpsqueueProcess) -> None:
589+
"""A paused submission can be cancelled; its status becomes Cancelled."""
590+
url = "file:///tmp/opsqueue/test_cancel_paused"
591+
producer_client = ProducerClient(f"localhost:{opsqueue.port}", url)
592+
submission_id = producer_client.insert_submission((1, 2, 3), chunk_size=1)
593+
594+
producer_client.pause_submission(submission_id)
595+
assert isinstance(
596+
producer_client.get_submission_status(submission_id), SubmissionStatus.Paused
597+
)
598+
599+
producer_client.cancel_submission(submission_id)
600+
assert isinstance(
601+
producer_client.get_submission_status(submission_id), SubmissionStatus.Cancelled
602+
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DROP TABLE chunks_paused;
2+
DROP TABLE submissions_paused;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
CREATE TABLE submissions_paused
2+
(
3+
id BIGINT PRIMARY KEY NOT NULL,
4+
prefix TEXT,
5+
chunks_total INTEGER NOT NULL DEFAULT 0,
6+
chunks_done INTEGER NOT NULL DEFAULT 0,
7+
metadata BLOB,
8+
otel_trace_carrier TEXT NOT NULL DEFAULT '{}',
9+
chunk_size INTEGER
10+
);
11+
12+
CREATE INDEX submissions_paused_prefix ON submissions_paused (prefix, id);
13+
14+
CREATE TABLE chunks_paused
15+
(
16+
submission_id INTEGER NOT NULL,
17+
chunk_index INTEGER NOT NULL,
18+
input_content BLOB NULL,
19+
retries INTEGER NOT NULL DEFAULT 0,
20+
21+
PRIMARY KEY (submission_id, chunk_index)
22+
) WITHOUT ROWID, STRICT;
16 KB
Binary file not shown.

0 commit comments

Comments
 (0)