krishiv_plan/
task_fragment.rs1use crate::{ExecutionKind, NodeOp, PlanError, PlanNode};
4
5const FRAGMENT_PREFIX: &str = "krishiv-fragment:";
6
7pub const TASK_FRAGMENT_VERSION: u32 = 1;
9
10const fn default_task_fragment_version() -> u32 {
11 TASK_FRAGMENT_VERSION
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16pub struct TypedTaskFragment {
17 #[serde(default = "default_task_fragment_version")]
19 pub version: u32,
20 pub execution_kind: ExecutionKind,
21 pub body: String,
22}
23
24impl TypedTaskFragment {
25 pub fn new(execution_kind: ExecutionKind, body: impl Into<String>) -> Self {
26 Self {
27 version: TASK_FRAGMENT_VERSION,
28 execution_kind,
29 body: body.into(),
30 }
31 }
32
33 pub fn encode(&self) -> Result<String, PlanError> {
34 let json = serde_json::to_string(self)
35 .map_err(|e| PlanError::Encode(format!("fragment json: {e}")))?;
36 Ok(format!("{FRAGMENT_PREFIX}{json}"))
37 }
38
39 pub fn decode(fragment: &str) -> Option<Self> {
40 Self::decode_versioned(fragment).ok()
41 }
42
43 pub fn decode_versioned(fragment: &str) -> Result<Self, PlanError> {
45 let payload = fragment.strip_prefix(FRAGMENT_PREFIX).ok_or_else(|| {
46 PlanError::Parse("task fragment does not use the typed envelope".into())
47 })?;
48 let decoded: Self = serde_json::from_str(payload)
49 .map_err(|e| PlanError::Parse(format!("fragment json: {e}")))?;
50 if decoded.version != TASK_FRAGMENT_VERSION {
51 return Err(PlanError::Validation(format!(
52 "unsupported task fragment version {}; supported version is {}",
53 decoded.version, TASK_FRAGMENT_VERSION
54 )));
55 }
56 Ok(decoded)
57 }
58
59 pub fn execution_kind_from_legacy(fragment: &str) -> ExecutionKind {
60 if fragment.starts_with("stream:") {
61 return ExecutionKind::Streaming;
62 }
63 if fragment.starts_with("delta:") {
69 return ExecutionKind::DeltaBatch;
70 }
71 if let Some(op) = crate::lowering::decode_task_fragment(fragment) {
72 return match op {
73 NodeOp::Window { .. }
74 | NodeOp::StreamSource { .. }
75 | NodeOp::Watermark { .. }
76 | NodeOp::KeyBy { .. }
77 | NodeOp::StateTtl { .. } => ExecutionKind::Streaming,
78 _ => ExecutionKind::Batch,
79 };
80 }
81 ExecutionKind::Batch
82 }
83
84 pub fn decode_or_legacy(fragment: &str) -> Self {
85 Self::decode(fragment).unwrap_or_else(|| {
86 Self::new(
87 Self::execution_kind_from_legacy(fragment),
88 fragment.to_string(),
89 )
90 })
91 }
92
93 pub fn decode_for_profile(
95 fragment: &str,
96 profile: krishiv_common::DurabilityProfile,
97 ) -> Result<Self, PlanError> {
98 if fragment.starts_with(FRAGMENT_PREFIX) {
99 return Self::decode_versioned(fragment);
100 }
101 if krishiv_common::allow_legacy_task_fragments(profile) {
102 return Ok(Self::decode_or_legacy(fragment));
103 }
104 Err(PlanError::Validation(format!(
105 "legacy untyped task fragment rejected for profile '{}': {}",
106 profile,
107 fragment.chars().take(120).collect::<String>()
108 )))
109 }
110}
111
112pub fn task_body_for_profile(
114 fragment: &str,
115 profile: krishiv_common::DurabilityProfile,
116) -> Result<String, PlanError> {
117 let body = TypedTaskFragment::decode_for_profile(fragment, profile)?.body;
118 if body.len() == body.trim().len() {
119 return Ok(body);
120 }
121 Ok(body.trim().to_owned())
122}
123
124pub fn validate_job_fragments(
126 spec: &krishiv_proto::JobSpec,
127 profile: krishiv_common::DurabilityProfile,
128) -> Result<(), PlanError> {
129 for stage in spec.stages() {
130 for task in stage.tasks() {
131 let typed = TypedTaskFragment::decode_for_profile(task.description(), profile)?;
132 if typed
134 .body
135 .starts_with(crate::window::WINDOW_EXECUTION_SPEC_PREFIX)
136 || typed.body.starts_with("stream:tw:")
137 || typed.body.starts_with("stream:sw:")
138 || typed.body.starts_with("stream:ses:")
139 {
140 crate::window::decode_window_execution_spec(&typed.body)?;
141 } else if let Some(NodeOp::Window { spec }) =
142 crate::lowering::decode_task_fragment(&typed.body)
143 {
144 crate::window::validate_window_execution_spec(&spec)?;
145 }
146 }
147 }
148 Ok(())
149}
150
151pub fn encode_typed_task_fragment(node: &PlanNode) -> Result<String, PlanError> {
153 let body = crate::lowering::encode_task_fragment(node);
154 TypedTaskFragment::new(node.kind(), body).encode()
155}
156
157pub fn execution_kind_from_fragment(fragment: &str) -> ExecutionKind {
159 TypedTaskFragment::decode_or_legacy(fragment).execution_kind
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use crate::{NodeOp, PlanNode};
166
167 #[test]
168 fn round_trip_typed_fragment() {
169 use crate::window::WindowExecutionSpec;
170 let spec = WindowExecutionSpec::tumbling("user_id", "ts", 1_000);
171 let node = PlanNode::new("w", "win", ExecutionKind::Streaming).with_op(NodeOp::Window {
172 spec: Box::new(spec),
173 });
174 let encoded = encode_typed_task_fragment(&node).expect("encode");
175 let decoded = TypedTaskFragment::decode_or_legacy(&encoded);
176 assert_eq!(decoded.execution_kind, ExecutionKind::Streaming);
177 assert!(decoded.body.starts_with("stream:"));
178 }
179
180 #[test]
181 fn legacy_stream_prefix_is_streaming() {
182 assert_eq!(
183 execution_kind_from_fragment("stream:tw:key=u"),
184 ExecutionKind::Streaming
185 );
186 }
187
188 #[test]
189 fn legacy_delta_step_prefix_is_delta_batch() {
190 assert_eq!(
193 execution_kind_from_fragment("delta:step:orders|d|s|st"),
194 ExecutionKind::DeltaBatch
195 );
196 }
197
198 #[test]
199 fn rejects_unknown_fragment_version() {
200 let encoded = format!(
201 "{FRAGMENT_PREFIX}{}",
202 serde_json::json!({"version": 99, "execution_kind": "Batch", "body": "scan"})
203 );
204 let err = TypedTaskFragment::decode_versioned(&encoded).unwrap_err();
205 assert!(
206 err.to_string()
207 .contains("unsupported task fragment version")
208 );
209 }
210
211 #[test]
212 fn durable_profile_rejects_legacy_fragment() {
213 use krishiv_common::DurabilityProfile;
214 let err = TypedTaskFragment::decode_for_profile(
215 "stream:tw:key=u",
216 DurabilityProfile::SingleNodeDurable,
217 )
218 .unwrap_err();
219 assert!(err.to_string().contains("legacy untyped"));
220 }
221}