blob: 397c3da512cf7a8c23abf294b64f174918080589 [file]
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Task SDK wire frame codec.
//
// Mirrors the Airflow supervisor's length-prefixed msgpack IPC format.
//
// Frame format on the wire:
//
// +---------+----------------------------+
// | len (4) | msgpack payload (variable) |
// +---------+----------------------------+
//
// `len` is big-endian uint32 — the byte length of the msgpack payload.
// Payload is a msgpack array:
// - Request: [id: int, body: map] (arity 2)
// - Response: [id: int, body: map, error: map?] (arity 3)
//
// The body is a map with a `type` key naming the message. Both
// supervisor and runtime maintain independent id counters starting
// at 0. Routing uses the pending-request map (not arity) — if the
// id matches a request we sent, it's the response; otherwise it's
// supervisor-initiated. See `comm-channel.ts`.
import { encode, decode } from "@msgpack/msgpack";
/**
* Maximum frame payload size in bytes (2^32 − 1). The length prefix is a
* big-endian uint32, so anything larger would silently truncate.
*/
export const MAX_FRAME_SIZE = 0xffff_ffff;
export interface Frame {
id: number;
body: unknown;
error?: unknown;
/** Whether the frame's msgpack array was arity 3 (response) or 2 (request). */
isResponse: boolean;
}
export function encodeRequest(id: number, body: unknown): Buffer {
return encodeFrame(id, body, undefined, /* isResponse */ false);
}
export function encodeResponse(id: number, body?: unknown, error?: unknown): Buffer {
return encodeFrame(id, body, error, /* isResponse */ true);
}
function encodeFrame(id: number, body: unknown, error: unknown, isResponse: boolean): Buffer {
const array = isResponse ? [id, body ?? null, error ?? null] : [id, body ?? null];
const payload = Buffer.from(encode(array));
if (payload.length > MAX_FRAME_SIZE) {
throw new RangeError(
`Frame payload ${payload.length} bytes exceeds MAX_FRAME_SIZE (${MAX_FRAME_SIZE})`,
);
}
const framed = Buffer.alloc(4 + payload.length);
framed.writeUInt32BE(payload.length, 0);
payload.copy(framed, 4);
return framed;
}
/** Decode a single framed payload (length prefix already stripped). */
export function decodePayload(payload: Buffer): Frame {
const value = decode(payload);
if (!Array.isArray(value)) {
throw new Error(`Expected msgpack array frame, got ${typeof value}`);
}
const arity = value.length;
if (arity < 2) {
throw new Error(`Unexpected Task SDK frame arity ${arity}`);
}
const [id, body, error] = value as [number, unknown, unknown?];
if (typeof id !== "number") {
throw new Error(`Frame id must be number, got ${typeof id}`);
}
// Specific per-failure messages above are deliberate — this is a
// cross-language wire boundary and a vague decode error there costs
// hours. Construction itself is a single expression: error is
// omitted (not set to null) when absent so `"error" in frame`
// stays a faithful arity signal.
return {
id,
body,
isResponse: arity >= 3,
...(error != null ? { error } : {}),
};
}
/**
* Try to consume one full frame (length prefix + payload) from `buf`.
* Returns the frame plus any trailing bytes belonging to the next frame,
* or `null` if `buf` doesn't yet contain a complete frame.
*/
export function tryTakeFrame(buf: Buffer): { frame: Frame; rest: Buffer } | null {
if (buf.length < 4) return null;
const len = buf.readUInt32BE(0);
if (len > MAX_FRAME_SIZE) {
throw new RangeError(
`Incoming frame length ${len} bytes exceeds MAX_FRAME_SIZE (${MAX_FRAME_SIZE})`,
);
}
if (buf.length < 4 + len) return null;
const payload = buf.subarray(4, 4 + len);
const rest = buf.subarray(4 + len);
return {
frame: decodePayload(Buffer.from(payload)),
rest: Buffer.from(rest),
};
}
/** Accumulate bytes across socket reads and emit complete frames. */
export class FrameReader {
private buf: Buffer = Buffer.alloc(0);
push(chunk: Buffer): Frame[] {
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk]);
const frames: Frame[] = [];
while (true) {
const taken = tryTakeFrame(this.buf);
if (!taken) break;
frames.push(taken.frame);
this.buf = taken.rest;
}
return frames;
}
get pending(): number {
return this.buf.length;
}
}