Skip to main content

fusillade_core/batch/
mod.rs

1//! File and batch types for grouping requests.
2//!
3//! This module defines types for:
4//! - Files: Collections of request templates
5//! - Request templates: Mutable request definitions
6//! - Batches: Execution triggers for files
7//!
8//! See request/ for the types for requests, since they have their logic more tightly coupled to
9//! their models.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::fmt;
14use std::str::FromStr;
15use uuid::Uuid;
16
17/// Unique identifier for a file.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct FileId(pub Uuid);
21
22impl From<Uuid> for FileId {
23    fn from(uuid: Uuid) -> Self {
24        FileId(uuid)
25    }
26}
27
28impl std::ops::Deref for FileId {
29    type Target = Uuid;
30    fn deref(&self) -> &Self::Target {
31        &self.0
32    }
33}
34
35impl std::fmt::Display for FileId {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "{}", &self.0.to_string()[..8])
38    }
39}
40
41/// Unique identifier for a batch.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
43#[serde(transparent)]
44pub struct BatchId(pub Uuid);
45
46impl From<Uuid> for BatchId {
47    fn from(uuid: Uuid) -> Self {
48        BatchId(uuid)
49    }
50}
51
52impl std::ops::Deref for BatchId {
53    type Target = Uuid;
54    fn deref(&self) -> &Self::Target {
55        &self.0
56    }
57}
58
59impl std::fmt::Display for BatchId {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "{}", self.0)
62    }
63}
64
65/// Unique identifier for a request template.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
67#[serde(transparent)]
68pub struct TemplateId(pub Uuid);
69
70impl From<Uuid> for TemplateId {
71    fn from(uuid: Uuid) -> Self {
72        TemplateId(uuid)
73    }
74}
75
76impl std::ops::Deref for TemplateId {
77    type Target = Uuid;
78    fn deref(&self) -> &Self::Target {
79        &self.0
80    }
81}
82
83impl std::fmt::Display for TemplateId {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        write!(f, "{}", &self.0.to_string()[..8])
86    }
87}
88
89/// Purpose for which a file was created.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Purpose {
93    /// File contains batch API request templates
94    Batch,
95    /// Virtual file that streams batch output (completed requests)
96    BatchOutput,
97    /// Virtual file that streams batch errors (failed requests)
98    BatchError,
99}
100
101/// Type of batch output file for lookup purposes.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum OutputFileType {
104    /// Output file containing completed requests
105    Output,
106    /// Error file containing failed requests
107    Error,
108}
109
110impl fmt::Display for Purpose {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Purpose::Batch => write!(f, "batch"),
114            Purpose::BatchOutput => write!(f, "batch_output"),
115            Purpose::BatchError => write!(f, "batch_error"),
116        }
117    }
118}
119
120impl FromStr for Purpose {
121    type Err = String;
122
123    fn from_str(s: &str) -> Result<Self, Self::Err> {
124        match s.to_lowercase().as_str() {
125            "batch" => Ok(Purpose::Batch),
126            "batch_output" => Ok(Purpose::BatchOutput),
127            "batch_error" => Ok(Purpose::BatchError),
128            _ => Err(format!("Invalid purpose: {}", s)),
129        }
130    }
131}
132
133/// File status tracking lifecycle and health.
134///
135/// Status tracks the file's lifecycle:
136/// - `Processed`: Successfully uploaded and parsed into templates (users can access)
137/// - `Error`: Failed to process during upload (only visible to admins with SystemAccess)
138/// - `Deleted`: Soft-deleted by user (metadata retained for audit, only visible to admins)
139/// - `Expired`: Past its expiration date (metadata retained for audit, only visible to admins)
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "lowercase")]
142pub enum FileStatus {
143    /// File was successfully processed and templates created
144    Processed,
145    /// File processing failed (see error_message for details)
146    Error,
147    /// File was soft-deleted by user (metadata retained for audit)
148    Deleted,
149    /// File has passed its expiration date
150    Expired,
151}
152
153impl fmt::Display for FileStatus {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            FileStatus::Processed => write!(f, "processed"),
157            FileStatus::Error => write!(f, "error"),
158            FileStatus::Deleted => write!(f, "deleted"),
159            FileStatus::Expired => write!(f, "expired"),
160        }
161    }
162}
163
164impl FromStr for FileStatus {
165    type Err = String;
166
167    fn from_str(s: &str) -> Result<Self, Self::Err> {
168        match s.to_lowercase().as_str() {
169            "processed" => Ok(FileStatus::Processed),
170            "error" => Ok(FileStatus::Error),
171            "deleted" => Ok(FileStatus::Deleted),
172            "expired" => Ok(FileStatus::Expired),
173            _ => Err(format!("Invalid file status: {}", s)),
174        }
175    }
176}
177
178/// A file containing a collection of request templates.
179#[derive(Debug, Clone, Serialize)]
180pub struct File {
181    pub id: FileId,
182    pub name: String,
183    pub description: Option<String>,
184    pub size_bytes: i64,
185    pub status: FileStatus,
186    pub error_message: Option<String>,
187    pub purpose: Option<Purpose>,
188    pub expires_at: Option<DateTime<Utc>>,
189    pub deleted_at: Option<DateTime<Utc>>,
190    pub uploaded_by: Option<String>,
191    pub created_at: DateTime<Utc>,
192    pub updated_at: DateTime<Utc>,
193    pub size_finalized: bool,
194    /// API key UUID that created this file, for per-member attribution within orgs
195    pub api_key_id: Option<Uuid>,
196    /// Connection UUID when file was ingested from an external source.
197    pub source_connection_id: Option<Uuid>,
198    /// Original external file key/path within the source connection scope.
199    pub source_external_key: Option<String>,
200}
201
202/// A request template defining how to make a request.
203///
204/// Templates are mutable, but requests snapshot the template state
205/// at execution time.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207pub struct RequestTemplate {
208    pub id: TemplateId,
209    pub file_id: FileId,
210    pub custom_id: Option<String>, // OpenAI Batch API custom identifier
211    pub endpoint: String,
212    pub method: String,
213    pub path: String,
214    pub body: String,
215    pub model: String,
216    pub api_key: String,
217    pub created_at: DateTime<Utc>,
218    pub updated_at: DateTime<Utc>,
219}
220
221/// Input for creating a new request template.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
223pub struct RequestTemplateInput {
224    pub custom_id: Option<String>, // OpenAI Batch API custom identifier
225    pub endpoint: String,
226    pub method: String,
227    pub path: String,
228    pub body: String,
229    pub model: String,
230    pub api_key: String,
231}
232
233/// Batch output item - represents a completed request in OpenAI format.
234#[derive(Debug, Clone, Serialize, serde::Deserialize)]
235pub struct BatchOutputItem {
236    /// Request ID
237    pub id: String,
238    /// Custom ID from the original request
239    pub custom_id: Option<String>,
240    /// Response details
241    pub response: BatchResponseDetails,
242    /// Error (should be null for successful responses)
243    pub error: Option<serde_json::Value>,
244}
245
246/// Batch error item - represents a failed request in OpenAI format.
247#[derive(Debug, Clone, Serialize, serde::Deserialize)]
248pub struct BatchErrorItem {
249    /// Request ID
250    pub id: String,
251    /// Custom ID from the original request
252    pub custom_id: Option<String>,
253    /// Response (should be null for errors)
254    pub response: Option<serde_json::Value>,
255    /// Error details
256    pub error: BatchErrorDetails,
257}
258
259/// Response details for a batch output item.
260#[derive(Debug, Clone, Serialize, serde::Deserialize)]
261pub struct BatchResponseDetails {
262    /// HTTP status code
263    pub status_code: i16,
264    /// Request ID from the upstream API
265    pub request_id: Option<String>,
266    /// Response body (e.g., ChatCompletion, Embedding, etc.)
267    pub body: serde_json::Value,
268}
269
270/// Error details for a batch error item.
271#[derive(Debug, Clone, Serialize, serde::Deserialize)]
272pub struct BatchErrorDetails {
273    /// Error code
274    pub code: Option<String>,
275    /// Error message
276    pub message: String,
277}
278
279/// Enum for different types of file content that can be streamed.
280#[derive(Debug, Clone, Serialize)]
281#[serde(untagged)]
282pub enum FileContentItem {
283    /// Request template (for input files with purpose='batch')
284    Template(RequestTemplateInput),
285    /// Batch output (for output files with purpose='batch_output')
286    Output(BatchOutputItem),
287    /// Batch error (for error files with purpose='batch_error')
288    Error(BatchErrorItem),
289}
290
291/// Status of a batch result item.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum BatchResultStatus {
295    /// Request is pending execution
296    Pending,
297    /// Request is currently being processed (claimed or processing)
298    InProgress,
299    /// Request completed successfully
300    Completed,
301    /// Request failed with an error
302    Failed,
303}
304
305/// Merged batch result item combining input, output, and status.
306/// Used for the Results view to show input/output pairs in a single row.
307#[derive(Debug, Clone, Serialize)]
308pub struct BatchResultItem {
309    /// Fusillade request ID (unique identifier)
310    pub id: String,
311    /// User-provided identifier (NOT unique - may be duplicated)
312    pub custom_id: Option<String>,
313    /// Model used for this request
314    pub model: String,
315    /// Original request body from the input template
316    pub input_body: serde_json::Value,
317    /// Full response object (choices, usage, etc.) for completed requests
318    pub response_body: Option<serde_json::Value>,
319    /// Error message for failed requests
320    pub error: Option<String>,
321    /// Current status of the request
322    pub status: BatchResultStatus,
323}
324
325/// Metadata for creating a file from a stream
326#[derive(Debug, Clone, Default, Serialize)]
327pub struct FileMetadata {
328    pub filename: Option<String>,
329    pub description: Option<String>,
330    pub purpose: Option<String>,
331    pub expires_after_anchor: Option<String>,
332    pub expires_after_seconds: Option<i64>,
333    pub size_bytes: Option<i64>,
334    pub uploaded_by: Option<String>,
335    /// API key UUID that created this file, for per-member attribution within orgs
336    pub api_key_id: Option<Uuid>,
337    /// Connection UUID when file was ingested from an external source (e.g. S3).
338    pub source_connection_id: Option<Uuid>,
339    /// Original external file key/path within the source connection scope.
340    pub source_external_key: Option<String>,
341}
342
343/// Filter parameters for listing files
344#[derive(Debug, Clone, Default)]
345pub struct FileFilter {
346    /// Filter by user who uploaded the file
347    /// TODO: We use a string here, because this crate is decoupled from the dwctl one which uses a
348    /// UUID. Is this fine? This just needs to be a unique identifier per user.
349    pub uploaded_by: Option<String>,
350    /// Filter by file status (processed, error, deleted, expired)
351    pub status: Option<String>,
352    /// Filter by purpose
353    pub purpose: Option<String>,
354    /// Search query for filename (case-insensitive substring match)
355    pub search: Option<String>,
356    /// Cursor for pagination (file ID to start after)
357    pub after: Option<FileId>,
358    /// Maximum number of results to return
359    pub limit: Option<usize>,
360    /// Filter by API key UUID(s) (for per-member attribution). Matches files
361    /// created with any of the given keys, supporting users with keys across multiple contexts.
362    /// An empty vec is treated as "no matches" (returns no results), not as "no filter".
363    pub api_key_ids: Option<Vec<Uuid>>,
364    /// Sort order (true = ascending, false = descending)
365    pub ascending: bool,
366}
367
368/// Filter parameters for listing batches
369#[derive(Debug, Clone, Default)]
370pub struct ListBatchesFilter {
371    /// Filter by batch creator
372    pub created_by: Option<String>,
373    /// Search query (matches metadata JSON text, filename, or batch ID)
374    pub search: Option<String>,
375    /// Cursor for pagination (batch ID to start after)
376    pub after: Option<BatchId>,
377    /// Maximum number of batches to return (defaults to 100 if not set)
378    pub limit: Option<i64>,
379    /// Filter by API key UUID(s) (for per-member attribution). Matches batches
380    /// created with any of the given keys, supporting users with keys across multiple contexts.
381    /// An empty vec is treated as "no matches" (returns no results), not as "no filter".
382    pub api_key_ids: Option<Vec<Uuid>>,
383    /// Filter by batch status (e.g. "completed", "in_progress", "failed")
384    pub status: Option<String>,
385    /// Only return batches created after this timestamp
386    pub created_after: Option<DateTime<Utc>>,
387    /// Only return batches created before this timestamp
388    pub created_before: Option<DateTime<Utc>>,
389    /// When true, sort active batches before terminal ones, with each group
390    /// sorted by created_at DESC. A batch is "active" when none of completed_at,
391    /// failed_at, cancelled_at, or cancelling_at are set. Cancelling batches are
392    /// treated as terminal because cancel_batch sets both cancelling_at and
393    /// cancelled_at atomically. Default false (pure chronological).
394    pub active_first: bool,
395    /// Only return batches whose completion window matches any value in this
396    /// list (e.g., `["24h"]` for batch tier, `["1h"]` for flex, `["0s"]` for
397    /// realtime tracking rows). `None` disables the filter; `Some(vec![])`
398    /// matches no rows.
399    pub completion_windows: Option<Vec<String>>,
400    /// Filter by explicit batch service tier. Currently the only named batch
401    /// tier is `background`; ordinary SLA batches store `NULL` here.
402    /// `None` disables the filter and an empty vector matches no rows.
403    pub service_tiers: Option<Vec<String>>,
404}
405
406/// Items that can be yielded from a file upload stream
407#[derive(Debug, Clone, Serialize)]
408pub enum FileStreamItem {
409    /// File metadata (should be first item in stream)
410    Metadata(FileMetadata),
411    /// A request template parsed from JSONL
412    Template(RequestTemplateInput),
413    /// Producer is aborting the stream. Fusillade should rollback and stop processing.
414    Abort,
415    /// Deprecated compatibility path for callers that still surface producer parse errors here.
416    #[deprecated(note = "Use FileStreamItem::Abort and retain the producer error locally instead")]
417    Error(String),
418}
419
420/// Result of creating a file from a stream.
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
422pub enum FileStreamResult {
423    /// Successfully created a file
424    Success(FileId),
425    /// Stream was aborted by the producer and the transaction was rolled back
426    Aborted,
427}
428
429/// Input parameters for creating a new batch.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct BatchInput {
432    /// The file containing request templates
433    pub file_id: FileId,
434    /// The API endpoint to use for all requests (e.g., "/v1/chat/completions")
435    pub endpoint: String,
436    /// Completion window (e.g., "24h")
437    pub completion_window: String,
438    /// Optional metadata key-value pairs (OpenAI allows up to 16 pairs)
439    pub metadata: Option<serde_json::Value>,
440    /// User who created this batch (for ownership tracking)
441    pub created_by: Option<String>,
442    /// API key UUID that created this batch, for per-member attribution within orgs
443    pub api_key_id: Option<Uuid>,
444    /// API key secret for batch request execution. Used instead of the key in
445    /// request_templates so that billing is attributed to the batch creator,
446    /// not the file uploader.
447    pub api_key: Option<String>,
448    /// Total number of request templates in the file. Set at creation time so
449    /// the batch immediately reflects the expected request count before
450    /// population completes.
451    pub total_requests: Option<i64>,
452}
453
454/// Input parameters for creating a file-backed background batch.
455///
456/// Background batches deliberately have no completion window. They use the
457/// ordinary batch lifecycle but are dispatched only by the background daemon.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct BackgroundBatchInput {
460    /// The file containing request templates.
461    pub file_id: FileId,
462    /// The API endpoint to use for all requests.
463    pub endpoint: String,
464    /// Optional metadata key-value pairs.
465    pub metadata: Option<serde_json::Value>,
466    /// User who created this batch.
467    pub created_by: Option<String>,
468    /// API key UUID that created this batch.
469    pub api_key_id: Option<Uuid>,
470    /// API key secret for batch request execution.
471    pub api_key: Option<String>,
472    /// Expected number of request templates in the source file.
473    pub total_requests: Option<i64>,
474}
475
476/// A batch represents one execution of all of a file's templates.
477#[derive(Debug, Clone, Serialize)]
478pub struct Batch {
479    pub id: BatchId,
480    pub file_id: Option<FileId>,
481    pub created_at: DateTime<Utc>,
482    /// Metadata key-value pairs (OpenAI allows up to 16 pairs)
483    pub metadata: Option<serde_json::Value>,
484    /// Explicit scheduling tier. `Some("background")` identifies a background
485    /// batch; `None` retains completion-window-driven SLA semantics.
486    pub service_tier: Option<String>,
487    /// Completion window (e.g., "24h"). `None` for background batches.
488    pub completion_window: Option<String>,
489    /// The API endpoint to use for all requests (e.g., "/v1/chat/completions")
490    pub endpoint: String,
491    /// File ID containing the successful results
492    pub output_file_id: Option<FileId>,
493    /// File ID containing the error results
494    pub error_file_id: Option<FileId>,
495    /// User who created this batch
496    pub created_by: String,
497    /// When the batch will expire (created_at + completion_window).
498    /// `None` for background batches, which do not have a completion SLA.
499    pub expires_at: Option<DateTime<Utc>>,
500    /// When batch cancellation was initiated
501    pub cancelling_at: Option<DateTime<Utc>>,
502    /// Batch-level errors (validation errors, system errors, etc.)
503    pub errors: Option<serde_json::Value>,
504
505    /// Status fields
506    pub total_requests: i64,
507    pub pending_requests: i64,
508    pub in_progress_requests: i64,
509    pub completed_requests: i64,
510    pub failed_requests: i64,
511    pub canceled_requests: i64,
512    pub requests_started_at: Option<DateTime<Utc>>,
513
514    /// Terminal state timestamps (set once when batch enters that state)
515    pub finalizing_at: Option<DateTime<Utc>>,
516    pub completed_at: Option<DateTime<Utc>>,
517    pub failed_at: Option<DateTime<Utc>>,
518    pub cancelled_at: Option<DateTime<Utc>>,
519
520    /// When batch was soft-deleted. NULL means active.
521    pub deleted_at: Option<DateTime<Utc>>,
522
523    /// When batch completion notification was sent. NULL means not yet notified.
524    pub notification_sent_at: Option<DateTime<Utc>>,
525
526    /// API key UUID that created this batch, for per-member attribution within orgs
527    pub api_key_id: Option<Uuid>,
528}
529
530/// A batch with extra context for notification emails (file metadata, model names).
531/// Returned by `poll_completed_batches` which joins the files and requests tables.
532#[derive(Debug, Clone)]
533pub struct BatchNotification {
534    pub batch: Batch,
535    pub model: String,
536    pub input_file_name: Option<String>,
537    pub input_file_description: Option<String>,
538}
539
540/// Status information for a batch, computed from its executions.
541#[derive(Debug, Clone, Serialize)]
542pub struct BatchStatus {
543    pub batch_id: BatchId,
544    pub file_id: Option<FileId>,
545    pub file_name: Option<String>,
546    pub total_requests: i64,
547    pub pending_requests: i64,
548    pub in_progress_requests: i64,
549    pub completed_requests: i64,
550    pub failed_requests: i64,
551    pub canceled_requests: i64,
552    pub started_at: Option<DateTime<Utc>>,
553    pub failed_at: Option<DateTime<Utc>>,
554    pub created_at: DateTime<Utc>,
555}
556
557impl BatchStatus {
558    /// Check if the batch has finished (all requests in terminal state).
559    pub fn is_finished(&self) -> bool {
560        self.completed_requests + self.failed_requests + self.canceled_requests
561            == self.total_requests
562    }
563
564    /// Check if the batch is still running.
565    pub fn is_running(&self) -> bool {
566        !self.is_finished()
567    }
568
569    /// Get OpenAI-compatible status string.
570    /// Maps internal state to OpenAI's status values:
571    /// - "validating" - batch just created, no requests started yet
572    /// - "in_progress" - batch is being processed
573    /// - "finalizing" - nearly all requests done (95%+ complete)
574    /// - "completed" - all requests in terminal state and at least one succeeded
575    /// - "failed" - all requests in terminal state and all failed
576    /// - "cancelled" - all requests cancelled
577    pub fn openai_status(&self) -> &'static str {
578        if self.failed_at.is_some() {
579            return "failed";
580        }
581
582        if self.started_at.is_none() {
583            // Batch hasn't been populated yet — total_requests may already be
584            // set from the template count, but no request rows exist.
585            return "validating";
586        }
587
588        let terminal_count =
589            self.completed_requests + self.failed_requests + self.canceled_requests;
590
591        if terminal_count == 0 {
592            // Populated but nothing terminal yet
593            "in_progress"
594        } else if terminal_count == self.total_requests {
595            // All done - determine outcome
596            if self.canceled_requests == self.total_requests {
597                "cancelled"
598            } else if self.completed_requests == 0 {
599                "failed"
600            } else {
601                "completed"
602            }
603        } else if terminal_count as f64 / self.total_requests as f64 >= 0.95 {
604            // Nearly done (95%+)
605            "finalizing"
606        } else {
607            // In progress
608            "in_progress"
609        }
610    }
611}
612
613/// Aggregated statistics for a model's templates in a file.
614/// Used for efficient cost estimation without streaming all template data.
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct ModelTemplateStats {
617    /// The model name
618    pub model: String,
619    /// Number of request templates using this model
620    pub request_count: i64,
621    /// Total size of all request bodies in bytes
622    pub total_body_bytes: i64,
623}
624
625#[cfg(test)]
626mod background_tests {
627    use super::*;
628
629    #[test]
630    fn background_batch_input_does_not_require_a_completion_window() {
631        let file_id = FileId(Uuid::new_v4());
632        let input = BackgroundBatchInput {
633            file_id,
634            endpoint: "/v1/responses".to_string(),
635            metadata: None,
636            created_by: Some("user-a".to_string()),
637            api_key_id: None,
638            api_key: Some("test-key".to_string()),
639            total_requests: Some(2),
640        };
641
642        assert_eq!(input.file_id, file_id);
643        assert_eq!(input.total_requests, Some(2));
644    }
645
646    #[test]
647    fn background_batch_serializes_without_a_deadline() {
648        let batch = Batch {
649            id: BatchId(Uuid::new_v4()),
650            file_id: Some(FileId(Uuid::new_v4())),
651            created_at: Utc::now(),
652            metadata: None,
653            service_tier: Some("background".to_string()),
654            completion_window: None,
655            endpoint: "/v1/responses".to_string(),
656            output_file_id: None,
657            error_file_id: None,
658            created_by: "user-a".to_string(),
659            expires_at: None,
660            cancelling_at: None,
661            errors: None,
662            total_requests: 0,
663            pending_requests: 0,
664            in_progress_requests: 0,
665            completed_requests: 0,
666            failed_requests: 0,
667            canceled_requests: 0,
668            requests_started_at: None,
669            finalizing_at: None,
670            completed_at: None,
671            failed_at: None,
672            cancelled_at: None,
673            deleted_at: None,
674            notification_sent_at: None,
675            api_key_id: None,
676        };
677
678        let json = serde_json::to_value(batch).expect("batch should serialize");
679        assert_eq!(json["service_tier"], "background");
680        assert!(json["completion_window"].is_null());
681        assert!(json["expires_at"].is_null());
682    }
683}