Skip to content

Latest commit

 

History

History
133 lines (101 loc) · 6.81 KB

File metadata and controls

133 lines (101 loc) · 6.81 KB

invisibles - architecture

concepts

service - the object being exposed remotely. regular Python object, no base class needed.

protocol - shared RPC state for one endpoint. holds object registry, netref caches, boxer, handlers. one Protocol instance per endpoint.

connection - InvisiblesConnection. one sync connection per client. reentrant serving pattern. handles ALL protocol operations and method calls (both sync and async).

proxy (netref) - client-side transparent handle. matches the remote object's API exactly. sync methods are def, async methods are async def (using run_in_executor). created automatically during unboxing.

dispatcher - how the server runs method calls:

  • InlineDispatcher (default) - serialized, runs inline in serve thread
  • ThreadedDispatcher - thread pool for thread-safe objects
  • SharedDispatcher - serialized across all connections (shared lock)
  • AsyncDispatcher - event loop thread for async methods, sync methods run inline

boxing - how objects cross the wire. the core dichotomy is lifecycle:

  • objects WITHOUT lifecycle (primitives: int, str, float, bool, bytes, None, complex) go by-value via codec
  • objects WITH lifecycle (everything else: lists, dicts, sets, custom objects) go by-reference - sender registers them, receiver gets a proxy
  • tuples are protocol plumbing: recursively box each element (can mix values and references)

codec - serialization for primitive types. currently pickle. pluggable.

endpoint structure

Endpoint (one per service)
|
+-- Protocol (shared state, ONE instance)
|   +-- local_objects    - registry of shared objects
|   +-- netref_cache     - weak cache of proxy instances
|   +-- class_cache      - netref class cache
|   +-- boxer            - boxing/unboxing (always sync)
|   +-- handlers         - request dispatch
|
+-- InvisiblesConnection  - one per client
    +-- all protocol ops: INSPECT, GETATTR, SETATTR, DIR, etc.
    +-- all method calls: sync and async (same wire protocol)
    +-- unboxing netrefs: HANDLE_INSPECT through same connection
    +-- reentrant serving (nested requests while waiting)
    +-- RLock for thread-safe concurrent serving (run_in_executor)

one connection handles everything. async is an execution model (server-side), not a communication protocol. the client sends requests through the sync channel regardless of whether the method is sync or async.

flow

                CLIENT                                    SERVER

  user code                                               service object
      |                                                       ^
      v                                                       |
  proxy (netref)                                          dispatcher
      |                                                       ^
      |  sync method --> InvisiblesConnection ----------> InvisiblesConnection
      |                   box args -> send -> receive        receive -> unbox -> dispatch
      |                   reentrant wait <-- response <-- box result -> send
      |
      |  async method -> run_in_executor(sync_request) -> same connection, same path
      |                   event loop stays free              server: AsyncDispatcher
      |                   executor thread does reentrant     awaits coroutine on its loop
      |                   wait like sync                     returns result normally
      |
      v
  result

proxy creation

  1. client calls sync_request(HANDLE_GET_ROOT) on sync connection
  2. server boxes the service as REMOTE_REF, registers it in local_objects
  3. client unboxes: sees REMOTE_REF, calls HANDLE_INSPECT via sync connection
  4. client builds proxy class: def for sync methods, async def for async methods
  5. client creates proxy instance, caches it

HANDLE_INSPECT always goes through the sync connection. it's a protocol operation, not a user method call. the result (method signatures) is a tuple of primitives - boxes cleanly.

method call (sync)

  1. proxy.method(args) hits __getattribute__, finds method in class dict
  2. method calls sync_request(HANDLE_CALL_ATTR, proxy, name, args, kwargs) on sync connection
  3. connection boxes args, sends request, enters reentrant serve loop
  4. server receives, unboxes, dispatches to method via dispatcher, boxes result, sends response
  5. client's serve loop receives response, unboxes, returns to caller

method call (async)

  1. await proxy.method(args) hits __getattribute__, finds async method in class dict
  2. async method uses run_in_executor(sync_request) to avoid blocking event loop
  3. executor thread calls sync_request(HANDLE_CALL_ATTR, proxy, name, args, kwargs) - same as sync
  4. server receives, unboxes, dispatches via AsyncDispatcher which awaits the coroutine on its event loop
  5. server boxes result, sends response - same as sync
  6. executor thread receives response, returns. future resolves, caller gets result

boxing

the core dichotomy is lifecycle. does the object have lifecycle or not?

  • primitives (int, str, float, bool, bytes, None, complex): no lifecycle, immutable. serialize and send by value. this is safe because there's nothing to get out of sync.

  • compound types (list, dict, set, custom objects): have lifecycle, can be mutated, can contain objects with lifecycle. send by reference. sender registers in local_objects, receiver creates a proxy. all operations go through RPC.

  • tuples: protocol plumbing. recursively box each element. a tuple can contain a mix of primitives (by value) and objects (by reference). tuples themselves are not serialized as a unit - each element is boxed independently.

  • exceptions: special case. must be real objects (not proxies) for gen.throw(), exit, isinstance to work. serialized by value.

  • netrefs returning to origin: when a proxy is passed back as an argument to its own server, the boxer recognizes it (same protocol) and sends LABEL_LOCAL_REF with the object_id. the server looks up the original object. zero serialization.

object to send
    |
    +-- tuple -> LABEL_TUPLE -> recursively box each element
    +-- primitive (int, str, float, ...) -> LABEL_VALUE -> serialize via codec
    +-- exception -> LABEL_VALUE -> serialize via codec
    +-- netref from this connection -> LABEL_LOCAL_REF -> send object_id back
    +-- anything else -> LABEL_REMOTE_REF -> register in local_objects, send object_id
                                              receiver creates proxy

scenarios

scenario connections dispatcher
sync, single client 1 InlineDispatcher
sync, multiple clients, serialized N SharedDispatcher
sync, multiple clients, parallel N ThreadedDispatcher
async/mixed, single client 1 AsyncDispatcher
async/mixed, multiple clients N AsyncDispatcher

one connection per client. always sync. dispatcher determines execution model.