/** * @since 1.0.0 */ import * as Headers from "@effect/platform/Headers" import * as Rpc from "@effect/rpc/Rpc" import * as RpcClient from "@effect/rpc/RpcClient" import * as RpcGroup from "@effect/rpc/RpcGroup" import * as RpcServer from "@effect/rpc/RpcServer" import * as Arr from "effect/Array" import type { Brand } from "effect/Brand" import type * as Cause from "effect/Cause" import * as Context from "effect/Context" import * as Data from "effect/Data" import type { DurationInput } from "effect/Duration" import * as Effect from "effect/Effect" import * as Equal from "effect/Equal" import * as Exit from "effect/Exit" import { identity } from "effect/Function" import * as Hash from "effect/Hash" import * as Layer from "effect/Layer" import * as Mailbox from "effect/Mailbox" import * as Option from "effect/Option" import * as Predicate from "effect/Predicate" import type * as Schedule from "effect/Schedule" import { Scope } from "effect/Scope" import type * as Stream from "effect/Stream" import type { AlreadyProcessingMessage, MailboxFull, PersistenceError } from "./ClusterError.js" import { ShardGroup } from "./ClusterSchema.js" import * as ClusterSchema from "./ClusterSchema.js" import { EntityAddress } from "./EntityAddress.js" import type { EntityId } from "./EntityId.js" import { EntityType } from "./EntityType.js" import * as Envelope from "./Envelope.js" import { hashString } from "./internal/hash.js" import { ResourceMap } from "./internal/resourceMap.js" import * as Message from "./Message.js" import type * as Reply from "./Reply.js" import { RunnerAddress } from "./RunnerAddress.js" import * as ShardId from "./ShardId.js" import type { Sharding } from "./Sharding.js" import { ShardingConfig } from "./ShardingConfig.js" import * as Snowflake from "./Snowflake.js" /** * @since 1.0.0 * @category type ids */ export const TypeId: unique symbol = Symbol.for("@effect/cluster/Entity") /** * @since 1.0.0 * @category type ids */ export type TypeId = typeof TypeId /** * @since 1.0.0 * @category models */ export interface Entity< in out Type extends string, in out Rpcs extends Rpc.Any > extends Equal.Equal { readonly [TypeId]: TypeId /** * The name of the entity type. */ readonly type: Type & Brand<"EntityType"> /** * A RpcGroup definition for messages which represents the messaging protocol * that the entity is capable of processing. */ readonly protocol: RpcGroup.RpcGroup /** * Get the shard group for the given EntityId. */ getShardGroup(entityId: EntityId): string /** * Get the ShardId for the given EntityId. */ getShardId(entityId: EntityId): Effect.Effect /** * Annotate the entity with a value. */ annotate(tag: Context.Tag, value: S): Entity /** * Annotate the Rpc's above this point with a value. */ annotateRpcs(tag: Context.Tag, value: S): Entity /** * Annotate the entity with a context object. */ annotateContext(context: Context.Context): Entity /** * Annotate the Rpc's above this point with a context object. */ annotateRpcsContext(context: Context.Context): Entity /** * Create a client for this entity. */ readonly client: Effect.Effect< ( entityId: string ) => RpcClient.RpcClient.From< Rpcs, MailboxFull | AlreadyProcessingMessage | PersistenceError >, never, Sharding > /** * Create a Layer from an Entity. * * It will register the entity with the Sharding service. */ toLayer< Handlers extends HandlersFrom, RX = never >( build: Handlers | Effect.Effect, options?: { readonly maxIdleTime?: DurationInput | undefined readonly concurrency?: number | "unbounded" | undefined readonly mailboxCapacity?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined readonly defectRetryPolicy?: Schedule.Schedule | undefined readonly spanAttributes?: Record | undefined } ): Layer.Layer< never, never, | Exclude | RpcGroup.HandlersContext | Rpc.Context | Rpc.Middleware | Sharding > of>(handlers: Handlers): Handlers /** * Create a Layer from an Entity. * * It will register the entity with the Sharding service. */ toLayerMailbox< R, RX = never >( build: | (( mailbox: Mailbox.ReadonlyMailbox>, replier: Replier ) => Effect.Effect) | Effect.Effect< ( mailbox: Mailbox.ReadonlyMailbox>, replier: Replier ) => Effect.Effect, never, RX >, options?: { readonly maxIdleTime?: DurationInput | undefined readonly mailboxCapacity?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined readonly defectRetryPolicy?: Schedule.Schedule | undefined readonly spanAttributes?: Record | undefined } ): Layer.Layer< never, never, | Exclude | R | Rpc.Context | Rpc.Middleware | Sharding > } /** * @since 1.0.0 * @category models */ export type Any = Entity /** * @since 1.0.0 * @category models */ export type HandlersFrom = { readonly [Current in Rpc as Current["_tag"]]: ( envelope: Request ) => Rpc.ResultFrom | Rpc.Wrapper> } /** * @since 1.0.0 * @category refinements */ export const isEntity = (u: unknown): u is Any => Predicate.hasProperty(u, TypeId) const Proto = { [TypeId]: TypeId, [Hash.symbol](this: Entity): number { return Hash.structure({ type: this.type }) }, [Equal.symbol](this: Entity, that: Equal.Equal): boolean { return isEntity(that) && this.type === that.type }, annotate(this: Entity, tag: Context.Tag, value: S) { return fromRpcGroup(this.type, this.protocol.annotate(tag, value)) }, annotateRpcs(this: Entity, tag: Context.Tag, value: S) { return fromRpcGroup(this.type, this.protocol.annotateRpcs(tag, value)) }, annotateContext(this: Entity, context: Context.Context) { return fromRpcGroup(this.type, this.protocol.annotateContext(context)) }, annotateRpcsContext(this: Entity, context: Context.Context) { return fromRpcGroup(this.type, this.protocol.annotateRpcsContext(context)) }, getShardId(this: Entity, entityId: EntityId) { return Effect.map(shardingTag, (sharding) => sharding.getShardId(entityId, this.getShardGroup(entityId))) }, get client() { return shardingTag.pipe( Effect.flatMap((sharding) => sharding.makeClient(this as any)) ) }, toLayer< Rpcs extends Rpc.Any, Handlers extends HandlersFrom, RX = never >( this: Entity, build: Handlers | Effect.Effect, options?: { readonly maxIdleTime?: DurationInput | undefined readonly concurrency?: number | "unbounded" | undefined readonly mailboxCapacity?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined readonly defectRetryPolicy?: Schedule.Schedule | undefined readonly spanAttributes?: Record | undefined } ): Layer.Layer< never, never, | Exclude | RpcGroup.HandlersContext | Rpc.Context | Rpc.Middleware | Sharding > { return shardingTag.pipe( Effect.flatMap((sharding) => sharding.registerEntity( this, Effect.isEffect(build) ? build : Effect.succeed(build), options ) ), Layer.scopedDiscard ) }, of: identity, toLayerMailbox< Rpcs extends Rpc.Any, R, RX = never >( this: Entity, build: | (( mailbox: Mailbox.ReadonlyMailbox>, replier: Replier ) => Effect.Effect) | Effect.Effect< ( mailbox: Mailbox.ReadonlyMailbox>, replier: Replier ) => Effect.Effect, never, RX >, options?: { readonly maxIdleTime?: DurationInput | undefined readonly mailboxCapacity?: number | "unbounded" | undefined readonly disableFatalDefects?: boolean | undefined readonly defectRetryPolicy?: Schedule.Schedule | undefined readonly spanAttributes?: Record | undefined } ) { const buildHandlers = Effect.gen(this, function*() { const behaviour = Effect.isEffect(build) ? yield* build : build const mailbox = yield* Mailbox.make>() // create the rpc handlers for the entity const handler = (envelope: any) => { return Effect.async((resume) => { mailbox.unsafeOffer(envelope) resumes.set(envelope, resume) }) } const handlers: Record = {} for (const rpc of this.protocol.requests.keys()) { handlers[rpc] = handler } // make the Replier for the behaviour const resumes = new Map, (exit: Exit.Exit) => void>() const complete = (request: Envelope.Request, exit: Exit.Exit) => Effect.sync(() => { const resume = resumes.get(request) if (resume) { resumes.delete(request) resume(exit) } }) const replier: Replier = { succeed: (request, value) => complete(request, Exit.succeed(value)), fail: (request, error) => complete(request, Exit.fail(error)), failCause: (request, cause) => complete(request, Exit.failCause(cause)), complete } // fork the behaviour into the layer scope yield* behaviour(mailbox, replier).pipe( Effect.catchAllCause((cause) => { const exit = Exit.failCause(cause) for (const resume of resumes.values()) { resume(exit) } return Effect.void }), Effect.interruptible, Effect.forkScoped ) return handlers as any }) return this.toLayer(buildHandlers, { ...options, concurrency: "unbounded" }) } } /** * Creates a new `Entity` of the specified `type` which will accept messages * that adhere to the provided `RpcGroup`. * * @since 1.0.0 * @category constructors */ export const fromRpcGroup = ( /** * The entity type name. */ type: Type, /** * The schema definition for messages that the entity is capable of * processing. */ protocol: RpcGroup.RpcGroup ): Entity => { const self = Object.create(Proto) self.type = EntityType.make(type) self.protocol = protocol self.getShardGroup = Context.get(protocol.annotations, ShardGroup) return self } /** * Creates a new `Entity` of the specified `type` which will accept messages * that adhere to the provided schemas. * * @since 1.0.0 * @category constructors */ export const make = >( /** * The entity type name. */ type: Type, /** * The schema definition for messages that the entity is capable of * processing. */ protocol: Rpcs ): Entity => fromRpcGroup(type, RpcGroup.make(...protocol)) /** * A Context.Tag to access the current entity address. * * @since 1.0.0 * @category context */ export class CurrentAddress extends Context.Tag("@effect/cluster/Entity/EntityAddress")< CurrentAddress, EntityAddress >() {} /** * A Context.Tag to access the current Runner address. * * @since 1.0.0 * @category context */ export class CurrentRunnerAddress extends Context.Tag("@effect/cluster/Entity/RunnerAddress")< CurrentRunnerAddress, RunnerAddress >() {} /** * @since 1.0.0 * @category Replier */ export interface Replier { readonly succeed: ( request: Envelope.Request, value: Replier.Success ) => Effect.Effect readonly fail: ( request: Envelope.Request, error: Rpc.Error ) => Effect.Effect readonly failCause: ( request: Envelope.Request, cause: Cause.Cause> ) => Effect.Effect readonly complete: ( request: Envelope.Request, exit: Exit.Exit, Rpc.Error> ) => Effect.Effect } /** * @since 1.0.0 * @category Replier */ export declare namespace Replier { /** * @since 1.0.0 * @category Replier */ export type Success = Rpc.Success extends Stream.Stream ? Stream.Stream<_A, _E | Rpc.Error, _R> | Mailbox.ReadonlyMailbox<_A, _E | Rpc.Error> : Rpc.Success } /** * @since 1.0.0 * @category Request */ export class Request extends Data.Class< Envelope.Request & { readonly lastSentChunk: Option.Option> } > { /** * @since 1.0.0 */ get lastSentChunkValue(): Option.Option> { return this.lastSentChunk.pipe(Option.map((chunk) => Arr.lastNonEmpty(chunk.values))) } /** * @since 1.0.0 */ get nextSequence(): number { if (Option.isNone(this.lastSentChunk)) { return 0 } return this.lastSentChunk.value.sequence + 1 } } const shardingTag = Context.GenericTag("@effect/cluster/Sharding") /** * @since 1.0.0 * @category Testing */ export const makeTestClient: ( entity: Entity, layer: Layer.Layer ) => Effect.Effect< (entityId: string) => Effect.Effect>, LE, Scope | ShardingConfig | Exclude | Rpc.MiddlewareClient > = Effect.fnUntraced(function*( entity: Entity, layer: Layer.Layer ) { const config = yield* ShardingConfig const makeShardId = (entityId: string) => ShardId.make( entity.getShardGroup(entityId as EntityId), (Math.abs(hashString(entityId) % config.shardsPerGroup)) + 1 ) const snowflakeGen = yield* Snowflake.makeGenerator const runnerAddress = new RunnerAddress({ host: "localhost", port: 3000 }) const entityMap = new Map | Rpc.Middleware | LR> readonly concurrency: number | "unbounded" readonly build: Effect.Effect>> }>() const sharding = shardingTag.of({ ...({} as Sharding["Type"]), registerEntity: (entity, handlers, options) => Effect.contextWith((context) => { entityMap.set(entity.type, { context: context as any, concurrency: options?.concurrency ?? 1, build: entity.protocol.toHandlersContext(handlers) as any }) }) }) yield* Layer.build(Layer.provide(layer, Layer.succeed(shardingTag, sharding))) const entityEntry = entityMap.get(entity.type) if (!entityEntry) { return yield* Effect.dieMessage(`Entity.makeTestClient: ${entity.type} was not registered by layer`) } const map = yield* ResourceMap.make(Effect.fnUntraced(function*(entityId: string) { const address = new EntityAddress({ entityType: entity.type, entityId: entityId as EntityId, shardId: makeShardId(entityId) }) const scope = yield* Effect.scope const handlerContext = entityEntry.context.pipe( Context.add(CurrentRunnerAddress, runnerAddress), Context.add(CurrentAddress, address), Context.add(Scope, scope) ) const handlers = yield* entityEntry.build.pipe( Effect.mapInputContext(() => handlerContext as Context.Context) ) // eslint-disable-next-line prefer-const let client!: Effect.Effect.Success>> const server = yield* RpcServer.makeNoSerialization(entity.protocol, { concurrency: entityEntry.concurrency, onFromServer(response) { return client.write(response) } }).pipe( Effect.mapInputContext(() => Context.merge(handlerContext, handlers) as Context.Context) ) client = yield* RpcClient.makeNoSerialization(entity.protocol, { supportsAck: true, generateRequestId: () => snowflakeGen.unsafeNext() as any, onFromClient({ message }) { if (message._tag === "Request") { return server.write(0, { ...message, payload: new Request({ ...message, [Envelope.TypeId]: Envelope.TypeId, address, requestId: Snowflake.Snowflake(message.id), lastSentChunk: Option.none() }) as any }) } return server.write(0, message) } }) return client.client })) return (entityId: string) => map.get(entityId) }) /** * @since 1.0.0 * @category Keep alive */ export const keepAlive: ( enabled: boolean ) => Effect.Effect< void, never, Sharding | CurrentAddress > = Effect.fnUntraced(function*(enabled: boolean) { const olatch = yield* Effect.serviceOption(KeepAliveLatch) if (olatch._tag === "None") return if (!enabled) { yield* olatch.value.open return } const sharding = yield* shardingTag const address = yield* CurrentAddress const requestId = yield* sharding.getSnowflake const span = yield* Effect.orDie(Effect.currentSpan) olatch.value.unsafeClose() yield* Effect.orDie(sharding.sendOutgoing( new Message.OutgoingRequest({ rpc: KeepAliveRpc, context: Context.empty() as any, envelope: Envelope.makeRequest({ requestId, address, tag: KeepAliveRpc._tag, payload: void 0, headers: Headers.empty, traceId: span.traceId, spanId: span.spanId, sampled: span.sampled }), lastReceivedReply: Option.none(), respond: () => Effect.void }), true )) }, (effect, enabled) => Effect.withSpan( effect, "Entity/keepAlive", { attributes: { enabled }, captureStackTrace: false } )) /** * @since 1.0.0 * @category Keep alive */ export const KeepAliveRpc = Rpc.make("Cluster/Entity/keepAlive") .annotate(ClusterSchema.Persisted, true) .annotate(ClusterSchema.Uninterruptible, true) /** * @since 1.0.0 * @category Keep alive */ export class KeepAliveLatch extends Context.Tag( "effect/cluster/Entity/KeepAliveLatch" )() {}