EdgeAIG's picture
download
raw
17.8 kB
/**
* Handles communication between Effect Cluster runners.
*
* `Runners` sits between sharding decisions and runner execution. It can ping a
* runner, send requests or control envelopes, notify a runner that persisted
* work is available, and record that a runner address is unavailable. This
* module defines the runner communication service, its RPC protocol, no-op and
* RPC-backed implementations, local persistence support, reply recovery, and
* the protocol service used by transport-specific runner layers.
*
* @since 4.0.0
*/
import * as Context from "../../Context.js";
import * as Effect from "../../Effect.js";
import * as Exit from "../../Exit.js";
import * as Latch from "../../Latch.js";
import * as Layer from "../../Layer.js";
import * as Option from "../../Option.js";
import * as Queue from "../../Queue.js";
import * as RcMap from "../../RcMap.js";
import * as Schema from "../../Schema.js";
import * as Rpc from "../rpc/Rpc.js";
import * as RpcClient_ from "../rpc/RpcClient.js";
import * as RpcGroup from "../rpc/RpcGroup.js";
import * as RpcSchema from "../rpc/RpcSchema.js";
import { AlreadyProcessingMessage, EntityNotAssignedToRunner, MailboxFull, RunnerUnavailable } from "./ClusterError.js";
import { Persisted } from "./ClusterSchema.js";
import * as Envelope from "./Envelope.js";
import * as Message from "./Message.js";
import * as MessageStorage from "./MessageStorage.js";
import * as Reply from "./Reply.js";
import { ShardingConfig } from "./ShardingConfig.js";
import * as Snowflake from "./Snowflake.js";
/**
* Service for communicating with cluster runners, including pinging runners,
* sending and notifying messages, coordinating persisted replies, and marking
* runners unavailable.
*
* @category context
* @since 4.0.0
*/
export class Runners extends /*#__PURE__*/Context.Service()("effect/cluster/Runners") {}
/**
* Builds the `Runners` service from remote runner callbacks and adds local
* message persistence, duplicate request handling, optional local serialization
* simulation, and polling for persisted replies.
*
* **When to use**
*
* Use when you need a custom `Runners` service around remote `ping`, `send`,
* `notify`, and `onRunnerUnavailable` callbacks, with standard local
* persistence and reply recovery behavior.
*
* **Details**
*
* `make` uses the supplied remote callbacks for runner communication and
* derives `sendLocal` and `notifyLocal`. Local sends can optionally simulate
* remote serialization, persisted notifications are saved through
* `MessageStorage`, duplicate requests are resumed from stored replies when
* possible, and pending replies are polled according to
* `ShardingConfig.entityReplyPollInterval`.
*
* **Gotchas**
*
* `notify` and `notifyLocal` only support RPCs annotated as persisted; calling
* either path with a non-persisted message dies instead of returning a typed
* error.
*
* @see {@link makeRpc} for the RPC-backed implementation built on top of this constructor
* @see {@link makeNoop} for a no-op implementation when remote runner communication is not needed
*
* @category constructors
* @since 4.0.0
*/
export const make = /*#__PURE__*/Effect.fnUntraced(function* (options) {
const storage = yield* MessageStorage.MessageStorage;
const runnersScope = yield* Effect.scope;
const snowflakeGen = yield* Snowflake.Generator;
const config = yield* ShardingConfig;
const requestIdRewrites = new Map();
function notifyWith(message, afterPersist) {
const rpc = message.rpc;
const persisted = Context.get(rpc.annotations, Persisted);
if (!persisted) {
return Effect.die("Runners.notify only supports persisted messages");
}
if (message._tag === "OutgoingEnvelope") {
const rewriteId = requestIdRewrites.get(message.envelope.requestId);
const requestId = rewriteId ?? message.envelope.requestId;
const entry = storageRequests.get(requestId);
if (rewriteId) {
message = new Message.OutgoingEnvelope({
...message,
envelope: message.envelope.withRequestId(rewriteId)
});
}
return storage.saveEnvelope(message).pipe(Effect.catchTag("MalformedMessage", Effect.die), Effect.andThen(entry ? Effect.andThen(entry.latch.open, afterPersist(message, false)) : afterPersist(message, false)));
}
// For requests, after persisting the request, we need to check if the
// request is a duplicate. If it is, we need to resume from the last
// received reply.
//
// Otherwise, we notify the remote entity and then reply from storage.
return Effect.flatMap(Effect.catchTag(storage.saveRequest(message), "MalformedMessage", Effect.die), MessageStorage.SaveResult.$match({
Success: () => afterPersist(message, false),
Duplicate: ({
lastReceivedReply,
originalId
}) => {
// If the last received reply is an exit, we can just return it
// as the response.
if (Option.isSome(lastReceivedReply) && lastReceivedReply.value._tag === "WithExit") {
return message.respond(lastReceivedReply.value.withRequestId(message.envelope.requestId));
}
requestIdRewrites.set(message.envelope.requestId, originalId);
return afterPersist(new Message.OutgoingRequest({
...message,
lastReceivedReply,
envelope: Envelope.makeRequest({
...message.envelope,
requestId: originalId
}),
respond(reply) {
if (reply._tag === "WithExit") {
requestIdRewrites.delete(message.envelope.requestId);
}
return message.respond(reply.withRequestId(message.envelope.requestId));
}
}), true);
}
}));
}
const storageRequests = new Map();
const waitingStorageRequests = new Map();
const replyFromStorage = Effect.fnUntraced(function* (message) {
let entry = storageRequests.get(message.envelope.requestId);
if (entry) {
entry.messages.add(message);
entry.doneLatch ??= Latch.makeUnsafe(false);
return yield* entry.doneLatch.await;
} else {
entry = {
latch: Latch.makeUnsafe(false),
doneLatch: undefined,
replies: [],
messages: new Set([message])
};
storageRequests.set(message.envelope.requestId, entry);
}
while (true) {
// wait for the storage loop to notify us
entry.latch.closeUnsafe();
waitingStorageRequests.set(message.envelope.requestId, message);
storageLatch.openUnsafe();
yield* entry.latch.await;
// send the replies back
for (let i = 0; i < entry.replies.length; i++) {
const reply = entry.replies[i];
// we have reached the end
if (reply._tag === "WithExit") {
for (const message of entry.messages) {
yield* message.respond(reply);
}
entry.doneLatch?.openUnsafe();
return;
}
entry.latch.closeUnsafe();
for (const message of entry.messages) {
yield* message.respond(reply);
}
// wait for ack
yield* entry.latch.await;
}
entry.replies = [];
}
}, (effect, message) => Effect.ensuring(effect, Effect.sync(() => {
const entry = storageRequests.get(message.envelope.requestId);
if (!entry || entry.messages.size > 1) {
entry?.messages.delete(message);
return;
}
storageRequests.delete(message.envelope.requestId);
waitingStorageRequests.delete(message.envelope.requestId);
})));
const storageLatch = Latch.makeUnsafe(false);
if (storage !== MessageStorage.noop) {
yield* Effect.gen(function* () {
const foundRequests = new Set();
while (true) {
yield* storageLatch.await;
storageLatch.closeUnsafe();
const replies = yield* storage.repliesFor(waitingStorageRequests.values()).pipe(Effect.catchCause(cause => Effect.as(Effect.annotateLogs(Effect.logDebug(cause), {
package: "@effect/cluster",
module: "Runners",
fiber: "Read replies loop"
}), [])));
// put the replies into the storage requests and then open the latches
for (let i = 0; i < replies.length; i++) {
const reply = replies[i];
const entry = storageRequests.get(reply.requestId);
if (!entry) continue;
entry.replies.push(reply);
waitingStorageRequests.delete(reply.requestId);
foundRequests.add(entry);
}
foundRequests.forEach(entry => entry.latch.openUnsafe());
foundRequests.clear();
}
}).pipe(Effect.forkIn(runnersScope));
yield* Effect.suspend(() => {
if (waitingStorageRequests.size === 0) {
return storageLatch.await;
}
return storageLatch.open;
}).pipe(Effect.delay(config.entityReplyPollInterval), Effect.forever, Effect.forkIn(runnersScope));
}
return Runners.of({
...options,
sendLocal(options) {
const message = options.message;
if (!options.simulateRemoteSerialization) {
return options.send(Message.incomingLocalFromOutgoing(message));
}
return Message.serialize(message).pipe(Effect.flatMap(encoded => Message.deserializeLocal(message, encoded)), Effect.flatMap(options.send), Effect.catchTag("MalformedMessage", error => {
if (message._tag === "OutgoingEnvelope") {
return Effect.die(error);
}
return message.respond(new Reply.WithExit({
id: snowflakeGen.nextUnsafe(),
requestId: message.envelope.requestId,
exit: Exit.die(error)
}));
}));
},
notify(options_) {
const {
discard,
message
} = options_;
return notifyWith(message, (message, duplicate) => {
if (discard || message._tag === "OutgoingEnvelope") {
return options.notify(options_);
} else if (!duplicate && Option.isSome(options_.address)) {
return Effect.catch(options.send({
address: options_.address.value,
message
}), _ => replyFromStorage(message));
}
return options.notify(options_).pipe(Effect.andThen(replyFromStorage(message)));
});
},
notifyLocal(options) {
return notifyWith(options.message, (message, duplicate) => {
if (options.discard || message._tag === "OutgoingEnvelope") {
return Effect.catchTag(options.notify(Message.incomingLocalFromOutgoing(message)), "EntityNotAssignedToRunner", () => Effect.void);
} else if (!duplicate && options.storageOnly !== true) {
return options.notify(Message.incomingLocalFromOutgoing(message)).pipe(Effect.andThen(storage.registerReplyHandler(message)), Effect.catchTag("EntityNotAssignedToRunner", () => replyFromStorage(message)));
}
return options.notify(Message.incomingLocalFromOutgoing(message)).pipe(Effect.catchTag("EntityNotAssignedToRunner", () => Effect.void), Effect.andThen(replyFromStorage(message)));
});
}
});
});
/**
* Creates a no-op `Runners` service that rejects sends with
* `EntityNotAssignedToRunner` and ignores notifications, pings, and unavailable
* runner reports.
*
* @category No-op
* @since 4.0.0
*/
export const makeNoop = /*#__PURE__*/make({
send: ({
message
}) => Effect.fail(new EntityNotAssignedToRunner({
address: message.envelope.address
})),
notify: () => Effect.void,
ping: () => Effect.void,
onRunnerUnavailable: () => Effect.void
});
/**
* Layer that provides the no-op `Runners` service, using the default snowflake
* generator.
*
* @category layers
* @since 4.0.0
*/
export const layerNoop = /*#__PURE__*/Layer.effect(Runners, makeNoop).pipe(/*#__PURE__*/Layer.provide([Snowflake.layerGenerator]));
const rpcErrors = /*#__PURE__*/Schema.Union([EntityNotAssignedToRunner, MailboxFull, AlreadyProcessingMessage]);
/**
* RPC group used for runner-to-runner communication, including ping, notify,
* effect, stream, and envelope messages.
*
* @category Rpcs
* @since 4.0.0
*/
export class Rpcs extends /*#__PURE__*/RpcGroup.make(/*#__PURE__*/Rpc.make("Ping"), /*#__PURE__*/Rpc.make("Notify", {
payload: {
envelope: Envelope.Partial
},
success: Schema.Void,
error: /*#__PURE__*/Schema.Union([EntityNotAssignedToRunner, AlreadyProcessingMessage])
}), /*#__PURE__*/Rpc.make("Effect", {
payload: {
request: Envelope.PartialRequest,
persisted: Schema.Boolean
},
success: Reply.Encoded,
error: rpcErrors
}), /*#__PURE__*/Rpc.make("Stream", {
payload: {
request: Envelope.PartialRequest,
persisted: Schema.Boolean
},
error: rpcErrors,
success: Reply.Encoded,
stream: true
}), /*#__PURE__*/Rpc.make("Envelope", {
payload: {
envelope: /*#__PURE__*/Schema.Union([Envelope.AckChunk, Envelope.Interrupt]),
persisted: Schema.Boolean
},
error: rpcErrors
})) {}
/**
* Builds a runner RPC client from the current `RpcClient.Protocol`, using the
* `Runners` span prefix with tracing disabled.
*
* @category Rpcs
* @since 4.0.0
*/
export const makeRpcClient = /*#__PURE__*/RpcClient_.make(Rpcs, {
spanPrefix: "Runners",
disableTracing: true
});
/**
* Builds a `Runners` service backed by RPC clients, caching a client per runner
* address and dispatching ping, notify, effect, stream, and envelope messages over
* the runner protocol.
*
* @category constructors
* @since 4.0.0
*/
export const makeRpc = /*#__PURE__*/Effect.gen(function* () {
const makeClientProtocol = yield* RpcClientProtocol;
const snowflakeGen = yield* Snowflake.Generator;
const clients = yield* RcMap.make({
lookup: address => Effect.flatMap(makeClientProtocol(address), protocol => Effect.provideService(makeRpcClient, RpcClient_.Protocol, protocol)),
idleTimeToLive: "3 minutes"
});
return yield* make({
ping(address) {
return RcMap.get(clients, address).pipe(Effect.flatMap(client => client.Ping()), Effect.catchCause(() => Effect.andThen(RcMap.invalidate(clients, address), Effect.fail(new RunnerUnavailable({
address
})))), Effect.scoped);
},
send({
address,
message
}) {
const rpc = message.rpc;
const isPersisted = Context.get(rpc.annotations, Persisted);
if (message._tag === "OutgoingEnvelope") {
return RcMap.get(clients, address).pipe(Effect.flatMap(client => client.Envelope({
envelope: message.envelope,
persisted: isPersisted
})), Effect.catchTag("RpcClientError", Effect.die), Effect.scoped, Effect.catchDefect(() => Effect.fail(new RunnerUnavailable({
address
}))));
}
const isStream = RpcSchema.isStreamSchema(rpc.successSchema);
if (!isStream) {
return Effect.matchEffect(Message.serializeRequest(message), {
onSuccess: request => RcMap.get(clients, address).pipe(Effect.flatMap(client => client.Effect({
request,
persisted: isPersisted
})), Effect.catchTag("RpcClientError", Effect.die), Effect.flatMap(reply => Schema.decodeEffect(Reply.Reply(message.rpc))(reply).pipe(Effect.provideContext(message.context), Effect.orDie)), Effect.flatMap(message.respond), Effect.scoped, Effect.catchDefect(() => Effect.fail(new RunnerUnavailable({
address
})))),
onFailure: error => message.respond(new Reply.WithExit({
id: snowflakeGen.nextUnsafe(),
requestId: message.envelope.requestId,
exit: Exit.die(error)
}))
});
}
return Effect.matchEffect(Message.serializeRequest(message), {
onSuccess: request => RcMap.get(clients, address).pipe(Effect.flatMap(client => client.Stream({
request,
persisted: isPersisted
}, {
asQueue: true
})), Effect.flatMap(queue => {
const decode = Schema.decodeEffect(Reply.Reply(message.rpc));
return Queue.take(queue).pipe(Effect.flatMap(reply => Effect.orDie(decode(reply))), Effect.flatMap(message.respond), Effect.forever, Effect.catchTag("RpcClientError", Effect.die), Effect.provideContext(message.context), Effect.catchTag("Done", _ => Effect.void), Effect.catchDefect(() => Effect.fail(new RunnerUnavailable({
address
}))));
}), Effect.scoped),
onFailure: error => message.respond(new Reply.WithExit({
id: snowflakeGen.nextUnsafe(),
requestId: message.envelope.requestId,
exit: Exit.die(error)
}))
});
},
notify({
address,
message
}) {
if (Option.isNone(address)) {
return Effect.void;
}
const envelope = message.envelope;
const encode = message._tag === "OutgoingRequest" ? Effect.orDie(Message.serializeRequest(message)) : Effect.succeed(envelope);
return Effect.flatMap(encode, envelope => RcMap.get(clients, address.value).pipe(Effect.flatMap(client => client.Notify({
envelope
})), Effect.scoped, Effect.ignore));
},
onRunnerUnavailable: address => RcMap.invalidate(clients, address)
});
});
/**
* Layer that provides an RPC-backed `Runners` service using `RpcClientProtocol`,
* message storage, sharding configuration, and the default snowflake generator.
*
* @category layers
* @since 4.0.0
*/
export const layerRpc = /*#__PURE__*/Layer.effect(Runners, makeRpc).pipe(/*#__PURE__*/Layer.provide(Snowflake.layerGenerator));
/**
* Service that creates an RPC client protocol for communicating with a runner at a
* given address.
*
* @category client
* @since 4.0.0
*/
export class RpcClientProtocol extends /*#__PURE__*/Context.Service()("effect/cluster/Runners/RpcClientProtocol") {}
//# sourceMappingURL=Runners.js.map

Xet Storage Details

Size:
17.8 kB
·
Xet hash:
34d8dacf87f466b89154c441771901ada6d607390e291b15df51bb1a62f22e66

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.