Skip to content

hololinked.core.eventloop.eventloop.EventLoop

Runs operations on the Thing instances.

Instantiate, use the Operation model and the submit() method in a protocol runtime to serve requests. These create Jobs that go through schedulers, complete them and return Replys.

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
class EventLoop:
    """
    Runs operations on the `Thing` instances.

    Instantiate, use the `Operation` model and the submit() method in a protocol runtime to serve requests.
    These create `Job`s that go through schedulers, complete them and return `Reply`s.
    """

    things: dict[str, Thing]
    """Every served `Thing`, sub-things included."""

    per_job_scheduler_types: dict[str, type[Scheduler]]
    """
    Scheduler class constructed fresh for every operation.

    `AsyncScheduler` and `ThreadedScheduler` usually live here. They are instantiated per operation.
    Please only assign types or classes, not instances. There is no validation.
    """

    per_thing_schedulers: dict[str, Scheduler]
    """
    Long-lived scheduler shared by every operation on that `Thing`.

    `QueuedScheduler` usually lives here. Please only assign scheduler instances here, not types or classes.
    """

    pending_operations: PendingOperations
    """Replies still to be collected, kept apart per caller. Handles noblock operations."""

    def __init__(
        self,
        *,
        things: list[Thing] | None = None,
        logger: structlog.stdlib.BoundLogger | None = None,
        **kwargs: Any,
    ) -> None:
        """
        Initialize the event loop.

        Parameters
        ----------
        things: list[Thing]
            list of `Thing` instances to be served
        logger: structlog.stdlib.BoundLogger, optional
            logger instance to use. A default is created when none is given.
        """
        if not logger:
            logger = structlog.get_logger()
        self.logger = logger.bind(component="eventloop", impl=self.__class__.__name__)
        self.things = dict()
        self.per_job_scheduler_types = dict()
        self.per_thing_schedulers = dict()
        self.pending_operations = PendingOperations()
        self.event_bus = EventBus()
        self._stop_hooks = []  # type: list[Callable[[], None]]
        self._loop = None  # type: asyncio.AbstractEventLoop | None
        self._run = False  # flag to stop the event loop and everything hooked onto it
        self.add_things(*(things or []))

    def add_thing(self, thing: Thing) -> None:
        """
        Adds a thing to the list of things to serve with the event loop.

        Parameters
        ----------
        thing: Thing
            the `Thing` to serve

        Raises
        ------
        RuntimeError
            if the event loop is already running - the registries it writes to are read without a lock
            by every submission, on the understanding that they stop changing once things are served
        """
        if self._run:
            raise RuntimeError(
                f"cannot add thing {thing.id} while the event loop is running - add every thing before run()"
            )
        # setup scheduling requirements
        thing.eventloop = self
        self.things[thing.id] = thing
        for event in thing.events.descriptors.values():
            self.event_bus.register(event, thing)
        for action in thing.actions.descriptors.values():
            if action.synchronous:
                continue  # QueuedScheduler, which is the default and is shared per Thing
            key = qualified_operation_key(thing.id, action.name, Operations.invokeaction)
            self.per_job_scheduler_types[key] = AsyncScheduler if action.iscoroutine else ThreadedScheduler
        # properties need not dealt yet, but may be in future)

    def add_things(self, *things: Thing) -> None:
        """Adds multiple things to serve."""
        for thing in things:
            self.add_thing(thing)

    @property
    def is_running(self) -> bool:
        """Check if the server is running or not."""
        return self._run

    def run_coro_threadsafe(self, coro: Coroutine[Any, Any, Any]) -> Future:
        """
        Run a coroutine on this `EventLoop`'s own asyncio loop, from whichever thread is asking.

        Parameters
        ----------
        coro: Coroutine
            the coroutine to schedule

        Returns
        -------
        concurrent.futures.Future
            resolved with the coroutine's result

        Raises
        ------
        RuntimeError
            if the asyncio loop is not running
        """
        if self._loop is None:
            coro.close()
            raise RuntimeError("the event loop is not running, call run() before submitting operations")
        return asyncio.run_coroutine_threadsafe(coro, self._loop)

    def submit(self, operation: Operation) -> Future:
        """
        Schedule an operation on its `Thing` and return the promise of its reply.

        Parameters
        ----------
        operation: Operation
            what to do, on which `Thing`

        Returns
        -------
        concurrent.futures.Future
            resolved with a `Reply`, the timeout kinds included

        Raises
        ------
        KeyError
            if no `Thing` with that id is being served
        RuntimeError
            if the event loop is not running
        """
        if not self._run:
            raise RuntimeError("the event loop is not running, call run() before submitting operations")
        thing = self.things[operation.thing_id]
        job = Job(operation=operation, future=Future(), started=CrossLoopEvent(), completed=CrossLoopEvent())
        invokation_timeout = operation.scheduler_execution_context.invokation_timeout
        if invokation_timeout is not None:
            # races against the job leaving the queue - whichever gets there first answers the caller
            job.invokation_timeout_task = self.run_coro_threadsafe(job.answer_if_never_started(invokation_timeout))

        scheduler_type = self.per_job_scheduler_types.get(operation.qualified_name)
        if scheduler_type is not None:
            # async/threaded: a fresh scheduler per job, since they run concurrently
            scheduler = scheduler_type(thing, self)
        else:
            # queued (the default): the one scheduler shared by this Thing
            scheduler = self.per_thing_schedulers[operation.thing_id]
        scheduler.dispatch_job(job)
        return job.future

    async def execute(self, operation: Operation) -> Reply:
        """
        Schedule an operation and wait for its reply, on the calling coroutine's own loop.

        Parameters
        ----------
        operation: Operation
            what to do, on which `Thing`

        Returns
        -------
        Reply
            the reply of the operation. Use `kind` to find out if it errored or timed out or completed successfully.

        Raises
        ------
        KeyError
            if no `Thing` with that id is being served
        RuntimeError
            if the event loop is not running
        """
        return await asyncio.wrap_future(self.submit(operation))

    async def tunnel_message_to_things(self, scheduler: Scheduler) -> None:
        """
        Drain one scheduler's queue, handing each operation to its `Thing`.

        Does not have to be invoked by the caller. It's always running once the event loop is started.

        Parameters
        ----------
        scheduler: Scheduler
            the scheduler whose jobs are to be drained
        """
        self.logger.info("started schedulers")
        loop = get_current_async_loop()
        while self._run and scheduler.run:
            # wait for a job first
            if not scheduler.has_job:
                await scheduler.wait_for_job()
                # this means in next loop it wont be in this block as a job arrived
                continue

            invokation_timed_out = False
            job = scheduler.next_job
            job.started.set()  # releases the invokation timeout
            if job.invokation_timeout_task is not None:
                # conditional because sometimes some operations dont ask for an invocation timeout
                # and simply wait arbitrarily long
                invokation_timed_out = await asyncio.wrap_future(job.invokation_timeout_task)
            if invokation_timed_out:
                # the timeout already answered the caller, drop operation
                continue

            # hand the operation to the thing
            scheduler.last_operation_request = job.operation

            # schedule an execution timeout, which answers the caller early but would
            # still unable to interrupt the thing's execution
            execution_timed_out = False
            execution_timeout = job.operation.scheduler_execution_context.execution_timeout
            if execution_timeout is not None:
                job.execution_timeout_task = loop.create_task(job.answer_if_overdue(execution_timeout))

            # always drain the reply, even once a timeout has answered. Abandoning the wait leaves
            # the thing's answer sitting in the scheduler for the next job to pick up as its own.
            await scheduler.wait_for_reply()
            job.completed.set()  # releases the execution timeout
            if job.execution_timeout_task is not None:
                # conditional for the same reason as the invokation timeout above
                execution_timed_out = await job.execution_timeout_task

            # check the reply is never undefined, Undefined is a sensible placeholder for the
            # NotImplemented singleton
            if scheduler.last_operation_reply is Undefined:
                # this is a logic error, as the reply should never be undefined
                payload, preserialized_payload = format_return_value(
                    dict(exception=format_exception_as_json(RuntimeError("No reply from thing - logic error"))),
                    Serializers.default,
                )
                job.answer(Reply(payload, preserialized_payload, ReplyKind.ERROR))
                continue

            reply = scheduler.last_operation_reply
            scheduler.reset_operation_reply()
            if execution_timed_out:
                # drop the thing's late reply
                continue
            job.answer(reply)

        scheduler.cleanup()
        self.logger.info("stopped schedulers")

    async def run_thing_instance(self, instance: Thing, scheduler: Scheduler | None = None) -> None:
        """
        Run a single `Thing` instance in an infinite loop by allowing the scheduler to schedule operations on it.

        Parameters
        ----------
        instance: Thing
            instance of the `Thing`
        scheduler: Optional[Scheduler]
            scheduler that schedules operations on the `Thing` instance, a default is always available.
        """
        logger = self.logger.bind(cls=instance.__class__.__name__, thing_id=instance.id)
        logger.info("starting to run operations on thing")
        instance.logger.info("waiting to receive operations now")
        # if logger.level >= logging.ERROR:
        # sleep added to resolve some issue with logging related IO bound tasks in asyncio - not really clear what it is.
        # This loop crashes for log levels above ERROR without the following statement
        await asyncio.sleep(0.001)
        # TODO - investigate and fix it
        scheduler = scheduler or self.per_thing_schedulers[instance.id]

        while self._run and scheduler.run:
            # print("starting to serve thing {}".format(instance.id))
            await scheduler.wait_for_operation()
            if scheduler.last_operation_request is Undefined:
                logger.warning("No operation request found although an interruption to wait was made, continuing...")
                continue

            try:
                request: Operation = scheduler.last_operation_request
                thing_id, objekt, operation = request.thing_id, request.objekt, request.operation

                # deserializing the payload required to execute the operation
                payload = request.payload.deserialize()
                preserialized_payload = request.preserialized_payload.value
                instance.logger.debug(f"starting execution of operation {operation} on {objekt}")

                # start activities related to thing execution context
                fetch_execution_logs = request.thing_execution_context.fetch_execution_logs
                if fetch_execution_logs:
                    list_handler = LogHistoryHandler([])
                    list_handler.setLevel(logging.DEBUG)
                    if isinstance(instance.logger, structlog.stdlib.BoundLoggerBase):
                        stdlib_logger = instance.logger._logger
                    else:
                        stdlib_logger = instance.logger
                    # a Thing's own logger carries no handler of its own, they are configured
                    # on the root, which is where its records are emitted
                    handlers = stdlib_logger.handlers or logging.getLogger().handlers
                    if handlers:
                        list_handler.setFormatter(handlers[0].formatter)
                    stdlib_logger.addHandler(list_handler)

                # execute the operation
                return_value = await self.execute_operation(instance, objekt, operation, payload, preserialized_payload)

                # handle return value
                serializer = Serializers.for_object(thing_id, instance.__class__.__name__, objekt)
                content_type_if_no_serializer = Serializers.get_content_type_for_object(
                    thing_id,
                    instance.__class__.__name__,
                    objekt,
                )
                rpayload, rpreserialized_payload = format_return_value(
                    return_value,
                    serializer=serializer,
                    content_type_if_no_serializer=content_type_if_no_serializer,
                )

                # complete thing execution context
                if fetch_execution_logs:
                    rpayload.value = dict(
                        return_value=rpayload.value,
                        execution_logs=list_handler.log_list,
                    )

                # raise any payload errors now
                rpayload.require_serialized()

                # set reply
                scheduler.last_operation_reply = Reply(rpayload, rpreserialized_payload, ReplyKind.OK)

            except BreakInnerLoop:
                # exit the loop and stop the thing
                instance.logger.info("exiting event loop")

                # send a reply with None return value
                rpayload, rpreserialized_payload = format_return_value(None, Serializers.default)

                # complete thing execution context
                if fetch_execution_logs:
                    rpayload.value = dict(
                        return_value=rpayload.value,
                        execution_logs=list_handler.log_list,
                    )

                # set reply, let the protocol at the border decide how to signal an exit
                scheduler.last_operation_reply = Reply(rpayload, rpreserialized_payload, ReplyKind.EXIT)

                # quit the loop
                break

            except Exception as ex:
                # error occurred while executing the operation
                instance.logger.error(f"error while executing operation - {ex!s}")
                instance.logger.exception(ex)

                # send a reply with error
                rpayload, rpreserialized_payload = format_return_value(
                    dict(exception=format_exception_as_json(ex)), Serializers.default
                )

                # complete thing execution context
                if fetch_execution_logs:
                    rpayload.value["execution_logs"] = list_handler.log_list

                # set error reply
                scheduler.last_operation_reply = Reply(rpayload, rpreserialized_payload, ReplyKind.ERROR)

            finally:
                # cleanup
                if fetch_execution_logs:
                    if isinstance(instance.logger, structlog.stdlib.BoundLoggerBase):
                        stdlib_logger = instance.logger._logger
                    else:
                        stdlib_logger = instance.logger
                    stdlib_logger.removeHandler(list_handler)
                instance.logger.debug(f"completed execution of operation {operation} on {objekt}")
        logger.info("stopped running thing")

    async def execute_operation(
        self,
        instance: Thing,
        objekt: str,
        operation: str,
        payload: Any,
        preserialized_payload: bytes,
    ) -> Any:
        """
        Execute a given operation on a thing instance.

        Parameters
        ----------
        instance: Thing
            instance of the thing
        objekt: str
            name of the property, action or event
        operation: str
            operation to be executed on the property, action or event
        payload: Any
            payload to be used for the operation
        preserialized_payload: bytes
            preserialized payload to be used for the operation

        Returns
        -------
        Any
            the result of the operation on the thing instance
        """
        if operation == Operations.readproperty:
            prop = instance.properties[objekt]  # type: Property
            return getattr(instance, prop.name)
        elif operation == Operations.writeproperty:
            prop: Property = instance.properties[objekt]  # ty: ignore[invalid-assignment]
            if preserialized_payload != b"":
                prop_value = preserialized_payload
            else:
                prop_value = payload
            return prop.external_set(instance, prop_value)
        elif operation == Operations.deleteproperty:
            prop = instance.properties[objekt]  # type: Property
            del prop  # raises NotImplementedError when deletion is not implemented which is mostly the case
        elif operation == Operations.invokeaction:
            if payload is None:
                payload = dict()
            args = payload.pop("__args__", tuple())
            # payload then become kwargs
            if preserialized_payload != b"":
                args = (preserialized_payload,) + args
            action: BoundAction = instance.actions[objekt]  # ty: ignore[invalid-assignment]
            if action.descriptor.iscoroutine:
                # the actual scheduling as a purely async task is done by the scheduler, not here,
                # this will be a blocking call
                return await action.external_call(*args, **payload)
            return action.external_call(*args, **payload)
        elif operation == Operations.readmultipleproperties or operation == Operations.readallproperties:
            if objekt is None:
                return instance.properties.get()
            return instance.properties.get(names=objekt)
        elif operation == Operations.writemultipleproperties or operation == Operations.writeallproperties:
            return instance.properties.set(**payload)
        raise NotImplementedError(f"Unimplemented execution path for Thing {instance.id} for operation {operation}")

    def add_stop_hook(self, callback: Callable[[], None]) -> None:
        """
        Ask to be called when the event loop stops.

        A protocol server registers its socket teardown here, so that stopping the event loop - which
        is what `Thing.exit()` reaches - also stops whatever is sitting in front of it.

        Parameters
        ----------
        callback: Callable[[], None]
            called from `stop()`, on whichever thread stopped the event loop
        """
        if callback not in self._stop_hooks:
            self._stop_hooks.append(callback)

    def run(self, extra_coroutines: Sequence[Coroutine[Any, Any, Any]] = ()) -> None:
        """
        Start & run the event loop. This method is blocking.

        Creates the shared scheduler for each `Thing`, gives each `Thing` a thread of its own.
        Call `stop()` (threadsafe) to stop. Pass any extra coroutines to run alongside the event loop's own tasks
        via the `extra_coroutines` parameter. This would be usually the protocol server's request listeners.

        Parameters
        ----------
        extra_coroutines: Sequence[Coroutine]
            coroutines to run beside the event loop's own tasks.
        """
        self._run = True
        self._loop = get_current_async_loop()
        self.logger.info("starting event loop")
        for thing in self.things.values():
            self.per_thing_schedulers[thing.id] = QueuedScheduler(thing, self)
        threads = dict()  # type: dict[int, threading.Thread]
        for thing in self.things.values():
            thread = threading.Thread(target=self.run_things, args=([thing],), daemon=True)
            thread.start()
            threads[thread.ident] = thread
        try:
            loop = self._loop
            existing_tasks = asyncio.all_tasks(loop)
            loop.run_until_complete(
                asyncio.gather(
                    *[self.tunnel_message_to_things(scheduler) for scheduler in self.per_thing_schedulers.values()],
                    *extra_coroutines,
                    *existing_tasks,
                )
            )
            loop.close()
        finally:
            self._loop = None  # nothing may be scheduled onto a closed loop
            self.stop()
        for thread in threads.values():
            thread.join()
        self.logger.info("event loop stopped")

    def run_things(self, things: list[Thing]):
        """
        Run loop that executes operations on `Thing` instances. This method is blocking and is called by `run()` method.

        Parameters
        ----------
        things: List[Thing]
            list of `Thing` instances to be executed in this particular loop iteration.
        """
        thing_executor_loop = get_current_async_loop()
        self.logger.info(f"starting thing in thread {threading.get_ident()} for {[obj.id for obj in things]}")
        thing_executor_loop.run_until_complete(
            asyncio.gather(*[self.run_thing_instance(instance) for instance in things])
        )
        self.logger.info(f"exiting event loop in thread {threading.get_ident()}")
        thing_executor_loop.close()

    def stop(self) -> None:
        """Stop the event loop, and everything hooked onto it. This method is threadsafe."""
        self._run = False
        for hook in self._stop_hooks:
            try:
                hook()
            except Exception as ex:
                self.logger.warning(f"stop hook raised while stopping the event loop - {ex!s}")
        for scheduler in self.per_thing_schedulers.values():
            scheduler.cleanup()
        self.pending_operations.clear()

    def __str__(self):
        return f"EventLoop(things: {list(self.things)})"

Attributes

things instance-attribute

things: dict[str, Thing] = dict()

Every served Thing, sub-things included.

per_job_scheduler_types instance-attribute

per_job_scheduler_types: dict[str, type[Scheduler]] = dict()

Scheduler class constructed fresh for every operation.

AsyncScheduler and ThreadedScheduler usually live here. They are instantiated per operation. Please only assign types or classes, not instances. There is no validation.

per_thing_schedulers instance-attribute

per_thing_schedulers: dict[str, Scheduler] = dict()

Long-lived scheduler shared by every operation on that Thing.

QueuedScheduler usually lives here. Please only assign scheduler instances here, not types or classes.

pending_operations instance-attribute

pending_operations: PendingOperations = PendingOperations()

Replies still to be collected, kept apart per caller. Handles noblock operations.

Functions

__init__

__init__(*, things: list[Thing] | None = None, logger: BoundLogger | None = None, **kwargs: Any) -> None

Initialize the event loop.

Parameters:

Name Type Description Default

things

list[Thing] | None

list of Thing instances to be served

None

logger

BoundLogger | None

logger instance to use. A default is created when none is given.

None
Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def __init__(
    self,
    *,
    things: list[Thing] | None = None,
    logger: structlog.stdlib.BoundLogger | None = None,
    **kwargs: Any,
) -> None:
    """
    Initialize the event loop.

    Parameters
    ----------
    things: list[Thing]
        list of `Thing` instances to be served
    logger: structlog.stdlib.BoundLogger, optional
        logger instance to use. A default is created when none is given.
    """
    if not logger:
        logger = structlog.get_logger()
    self.logger = logger.bind(component="eventloop", impl=self.__class__.__name__)
    self.things = dict()
    self.per_job_scheduler_types = dict()
    self.per_thing_schedulers = dict()
    self.pending_operations = PendingOperations()
    self.event_bus = EventBus()
    self._stop_hooks = []  # type: list[Callable[[], None]]
    self._loop = None  # type: asyncio.AbstractEventLoop | None
    self._run = False  # flag to stop the event loop and everything hooked onto it
    self.add_things(*(things or []))

add_thing

add_thing(thing: Thing) -> None

Adds a thing to the list of things to serve with the event loop.

Parameters:

Name Type Description Default

thing

Thing

the Thing to serve

required

Raises:

Type Description
RuntimeError

if the event loop is already running - the registries it writes to are read without a lock by every submission, on the understanding that they stop changing once things are served

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def add_thing(self, thing: Thing) -> None:
    """
    Adds a thing to the list of things to serve with the event loop.

    Parameters
    ----------
    thing: Thing
        the `Thing` to serve

    Raises
    ------
    RuntimeError
        if the event loop is already running - the registries it writes to are read without a lock
        by every submission, on the understanding that they stop changing once things are served
    """
    if self._run:
        raise RuntimeError(
            f"cannot add thing {thing.id} while the event loop is running - add every thing before run()"
        )
    # setup scheduling requirements
    thing.eventloop = self
    self.things[thing.id] = thing
    for event in thing.events.descriptors.values():
        self.event_bus.register(event, thing)
    for action in thing.actions.descriptors.values():
        if action.synchronous:
            continue  # QueuedScheduler, which is the default and is shared per Thing
        key = qualified_operation_key(thing.id, action.name, Operations.invokeaction)
        self.per_job_scheduler_types[key] = AsyncScheduler if action.iscoroutine else ThreadedScheduler

add_things

add_things(*things: Thing) -> None

Adds multiple things to serve.

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def add_things(self, *things: Thing) -> None:
    """Adds multiple things to serve."""
    for thing in things:
        self.add_thing(thing)

is_running

is_running() -> bool

Check if the server is running or not.

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
@property
def is_running(self) -> bool:
    """Check if the server is running or not."""
    return self._run

submit

submit(operation: Operation) -> Future

Schedule an operation on its Thing and return the promise of its reply.

Parameters:

Name Type Description Default

operation

Operation

what to do, on which Thing

required

Returns:

Type Description
Future

resolved with a Reply, the timeout kinds included

Raises:

Type Description
KeyError

if no Thing with that id is being served

RuntimeError

if the event loop is not running

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def submit(self, operation: Operation) -> Future:
    """
    Schedule an operation on its `Thing` and return the promise of its reply.

    Parameters
    ----------
    operation: Operation
        what to do, on which `Thing`

    Returns
    -------
    concurrent.futures.Future
        resolved with a `Reply`, the timeout kinds included

    Raises
    ------
    KeyError
        if no `Thing` with that id is being served
    RuntimeError
        if the event loop is not running
    """
    if not self._run:
        raise RuntimeError("the event loop is not running, call run() before submitting operations")
    thing = self.things[operation.thing_id]
    job = Job(operation=operation, future=Future(), started=CrossLoopEvent(), completed=CrossLoopEvent())
    invokation_timeout = operation.scheduler_execution_context.invokation_timeout
    if invokation_timeout is not None:
        # races against the job leaving the queue - whichever gets there first answers the caller
        job.invokation_timeout_task = self.run_coro_threadsafe(job.answer_if_never_started(invokation_timeout))

    scheduler_type = self.per_job_scheduler_types.get(operation.qualified_name)
    if scheduler_type is not None:
        # async/threaded: a fresh scheduler per job, since they run concurrently
        scheduler = scheduler_type(thing, self)
    else:
        # queued (the default): the one scheduler shared by this Thing
        scheduler = self.per_thing_schedulers[operation.thing_id]
    scheduler.dispatch_job(job)
    return job.future

execute async

execute(operation: Operation) -> Reply

Schedule an operation and wait for its reply, on the calling coroutine's own loop.

Parameters:

Name Type Description Default

operation

Operation

what to do, on which Thing

required

Returns:

Type Description
Reply

the reply of the operation. Use kind to find out if it errored or timed out or completed successfully.

Raises:

Type Description
KeyError

if no Thing with that id is being served

RuntimeError

if the event loop is not running

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
async def execute(self, operation: Operation) -> Reply:
    """
    Schedule an operation and wait for its reply, on the calling coroutine's own loop.

    Parameters
    ----------
    operation: Operation
        what to do, on which `Thing`

    Returns
    -------
    Reply
        the reply of the operation. Use `kind` to find out if it errored or timed out or completed successfully.

    Raises
    ------
    KeyError
        if no `Thing` with that id is being served
    RuntimeError
        if the event loop is not running
    """
    return await asyncio.wrap_future(self.submit(operation))

execute_operation async

execute_operation(instance: Thing, objekt: str, operation: str, payload: Any, preserialized_payload: bytes) -> Any

Execute a given operation on a thing instance.

Parameters:

Name Type Description Default

instance

Thing

instance of the thing

required

objekt

str

name of the property, action or event

required

operation

str

operation to be executed on the property, action or event

required

payload

Any

payload to be used for the operation

required

preserialized_payload

bytes

preserialized payload to be used for the operation

required

Returns:

Type Description
Any

the result of the operation on the thing instance

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
async def execute_operation(
    self,
    instance: Thing,
    objekt: str,
    operation: str,
    payload: Any,
    preserialized_payload: bytes,
) -> Any:
    """
    Execute a given operation on a thing instance.

    Parameters
    ----------
    instance: Thing
        instance of the thing
    objekt: str
        name of the property, action or event
    operation: str
        operation to be executed on the property, action or event
    payload: Any
        payload to be used for the operation
    preserialized_payload: bytes
        preserialized payload to be used for the operation

    Returns
    -------
    Any
        the result of the operation on the thing instance
    """
    if operation == Operations.readproperty:
        prop = instance.properties[objekt]  # type: Property
        return getattr(instance, prop.name)
    elif operation == Operations.writeproperty:
        prop: Property = instance.properties[objekt]  # ty: ignore[invalid-assignment]
        if preserialized_payload != b"":
            prop_value = preserialized_payload
        else:
            prop_value = payload
        return prop.external_set(instance, prop_value)
    elif operation == Operations.deleteproperty:
        prop = instance.properties[objekt]  # type: Property
        del prop  # raises NotImplementedError when deletion is not implemented which is mostly the case
    elif operation == Operations.invokeaction:
        if payload is None:
            payload = dict()
        args = payload.pop("__args__", tuple())
        # payload then become kwargs
        if preserialized_payload != b"":
            args = (preserialized_payload,) + args
        action: BoundAction = instance.actions[objekt]  # ty: ignore[invalid-assignment]
        if action.descriptor.iscoroutine:
            # the actual scheduling as a purely async task is done by the scheduler, not here,
            # this will be a blocking call
            return await action.external_call(*args, **payload)
        return action.external_call(*args, **payload)
    elif operation == Operations.readmultipleproperties or operation == Operations.readallproperties:
        if objekt is None:
            return instance.properties.get()
        return instance.properties.get(names=objekt)
    elif operation == Operations.writemultipleproperties or operation == Operations.writeallproperties:
        return instance.properties.set(**payload)
    raise NotImplementedError(f"Unimplemented execution path for Thing {instance.id} for operation {operation}")

run

run(extra_coroutines: Sequence[Coroutine[Any, Any, Any]] = ()) -> None

Start & run the event loop. This method is blocking.

Creates the shared scheduler for each Thing, gives each Thing a thread of its own. Call stop() (threadsafe) to stop. Pass any extra coroutines to run alongside the event loop's own tasks via the extra_coroutines parameter. This would be usually the protocol server's request listeners.

Parameters:

Name Type Description Default

extra_coroutines

Sequence[Coroutine[Any, Any, Any]]

coroutines to run beside the event loop's own tasks.

()
Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def run(self, extra_coroutines: Sequence[Coroutine[Any, Any, Any]] = ()) -> None:
    """
    Start & run the event loop. This method is blocking.

    Creates the shared scheduler for each `Thing`, gives each `Thing` a thread of its own.
    Call `stop()` (threadsafe) to stop. Pass any extra coroutines to run alongside the event loop's own tasks
    via the `extra_coroutines` parameter. This would be usually the protocol server's request listeners.

    Parameters
    ----------
    extra_coroutines: Sequence[Coroutine]
        coroutines to run beside the event loop's own tasks.
    """
    self._run = True
    self._loop = get_current_async_loop()
    self.logger.info("starting event loop")
    for thing in self.things.values():
        self.per_thing_schedulers[thing.id] = QueuedScheduler(thing, self)
    threads = dict()  # type: dict[int, threading.Thread]
    for thing in self.things.values():
        thread = threading.Thread(target=self.run_things, args=([thing],), daemon=True)
        thread.start()
        threads[thread.ident] = thread
    try:
        loop = self._loop
        existing_tasks = asyncio.all_tasks(loop)
        loop.run_until_complete(
            asyncio.gather(
                *[self.tunnel_message_to_things(scheduler) for scheduler in self.per_thing_schedulers.values()],
                *extra_coroutines,
                *existing_tasks,
            )
        )
        loop.close()
    finally:
        self._loop = None  # nothing may be scheduled onto a closed loop
        self.stop()
    for thread in threads.values():
        thread.join()
    self.logger.info("event loop stopped")

run_things

run_things(things: list[Thing])

Run loop that executes operations on Thing instances. This method is blocking and is called by run() method.

Parameters:

Name Type Description Default

things

list[Thing]

list of Thing instances to be executed in this particular loop iteration.

required
Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def run_things(self, things: list[Thing]):
    """
    Run loop that executes operations on `Thing` instances. This method is blocking and is called by `run()` method.

    Parameters
    ----------
    things: List[Thing]
        list of `Thing` instances to be executed in this particular loop iteration.
    """
    thing_executor_loop = get_current_async_loop()
    self.logger.info(f"starting thing in thread {threading.get_ident()} for {[obj.id for obj in things]}")
    thing_executor_loop.run_until_complete(
        asyncio.gather(*[self.run_thing_instance(instance) for instance in things])
    )
    self.logger.info(f"exiting event loop in thread {threading.get_ident()}")
    thing_executor_loop.close()

run_thing_instance async

run_thing_instance(instance: Thing, scheduler: Scheduler | None = None) -> None

Run a single Thing instance in an infinite loop by allowing the scheduler to schedule operations on it.

Parameters:

Name Type Description Default

instance

Thing

instance of the Thing

required

scheduler

Scheduler | None

scheduler that schedules operations on the Thing instance, a default is always available.

None
Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
async def run_thing_instance(self, instance: Thing, scheduler: Scheduler | None = None) -> None:
    """
    Run a single `Thing` instance in an infinite loop by allowing the scheduler to schedule operations on it.

    Parameters
    ----------
    instance: Thing
        instance of the `Thing`
    scheduler: Optional[Scheduler]
        scheduler that schedules operations on the `Thing` instance, a default is always available.
    """
    logger = self.logger.bind(cls=instance.__class__.__name__, thing_id=instance.id)
    logger.info("starting to run operations on thing")
    instance.logger.info("waiting to receive operations now")
    # if logger.level >= logging.ERROR:
    # sleep added to resolve some issue with logging related IO bound tasks in asyncio - not really clear what it is.
    # This loop crashes for log levels above ERROR without the following statement
    await asyncio.sleep(0.001)
    # TODO - investigate and fix it
    scheduler = scheduler or self.per_thing_schedulers[instance.id]

    while self._run and scheduler.run:
        # print("starting to serve thing {}".format(instance.id))
        await scheduler.wait_for_operation()
        if scheduler.last_operation_request is Undefined:
            logger.warning("No operation request found although an interruption to wait was made, continuing...")
            continue

        try:
            request: Operation = scheduler.last_operation_request
            thing_id, objekt, operation = request.thing_id, request.objekt, request.operation

            # deserializing the payload required to execute the operation
            payload = request.payload.deserialize()
            preserialized_payload = request.preserialized_payload.value
            instance.logger.debug(f"starting execution of operation {operation} on {objekt}")

            # start activities related to thing execution context
            fetch_execution_logs = request.thing_execution_context.fetch_execution_logs
            if fetch_execution_logs:
                list_handler = LogHistoryHandler([])
                list_handler.setLevel(logging.DEBUG)
                if isinstance(instance.logger, structlog.stdlib.BoundLoggerBase):
                    stdlib_logger = instance.logger._logger
                else:
                    stdlib_logger = instance.logger
                # a Thing's own logger carries no handler of its own, they are configured
                # on the root, which is where its records are emitted
                handlers = stdlib_logger.handlers or logging.getLogger().handlers
                if handlers:
                    list_handler.setFormatter(handlers[0].formatter)
                stdlib_logger.addHandler(list_handler)

            # execute the operation
            return_value = await self.execute_operation(instance, objekt, operation, payload, preserialized_payload)

            # handle return value
            serializer = Serializers.for_object(thing_id, instance.__class__.__name__, objekt)
            content_type_if_no_serializer = Serializers.get_content_type_for_object(
                thing_id,
                instance.__class__.__name__,
                objekt,
            )
            rpayload, rpreserialized_payload = format_return_value(
                return_value,
                serializer=serializer,
                content_type_if_no_serializer=content_type_if_no_serializer,
            )

            # complete thing execution context
            if fetch_execution_logs:
                rpayload.value = dict(
                    return_value=rpayload.value,
                    execution_logs=list_handler.log_list,
                )

            # raise any payload errors now
            rpayload.require_serialized()

            # set reply
            scheduler.last_operation_reply = Reply(rpayload, rpreserialized_payload, ReplyKind.OK)

        except BreakInnerLoop:
            # exit the loop and stop the thing
            instance.logger.info("exiting event loop")

            # send a reply with None return value
            rpayload, rpreserialized_payload = format_return_value(None, Serializers.default)

            # complete thing execution context
            if fetch_execution_logs:
                rpayload.value = dict(
                    return_value=rpayload.value,
                    execution_logs=list_handler.log_list,
                )

            # set reply, let the protocol at the border decide how to signal an exit
            scheduler.last_operation_reply = Reply(rpayload, rpreserialized_payload, ReplyKind.EXIT)

            # quit the loop
            break

        except Exception as ex:
            # error occurred while executing the operation
            instance.logger.error(f"error while executing operation - {ex!s}")
            instance.logger.exception(ex)

            # send a reply with error
            rpayload, rpreserialized_payload = format_return_value(
                dict(exception=format_exception_as_json(ex)), Serializers.default
            )

            # complete thing execution context
            if fetch_execution_logs:
                rpayload.value["execution_logs"] = list_handler.log_list

            # set error reply
            scheduler.last_operation_reply = Reply(rpayload, rpreserialized_payload, ReplyKind.ERROR)

        finally:
            # cleanup
            if fetch_execution_logs:
                if isinstance(instance.logger, structlog.stdlib.BoundLoggerBase):
                    stdlib_logger = instance.logger._logger
                else:
                    stdlib_logger = instance.logger
                stdlib_logger.removeHandler(list_handler)
            instance.logger.debug(f"completed execution of operation {operation} on {objekt}")
    logger.info("stopped running thing")

tunnel_message_to_things async

tunnel_message_to_things(scheduler: Scheduler) -> None

Drain one scheduler's queue, handing each operation to its Thing.

Does not have to be invoked by the caller. It's always running once the event loop is started.

Parameters:

Name Type Description Default

scheduler

Scheduler

the scheduler whose jobs are to be drained

required
Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
async def tunnel_message_to_things(self, scheduler: Scheduler) -> None:
    """
    Drain one scheduler's queue, handing each operation to its `Thing`.

    Does not have to be invoked by the caller. It's always running once the event loop is started.

    Parameters
    ----------
    scheduler: Scheduler
        the scheduler whose jobs are to be drained
    """
    self.logger.info("started schedulers")
    loop = get_current_async_loop()
    while self._run and scheduler.run:
        # wait for a job first
        if not scheduler.has_job:
            await scheduler.wait_for_job()
            # this means in next loop it wont be in this block as a job arrived
            continue

        invokation_timed_out = False
        job = scheduler.next_job
        job.started.set()  # releases the invokation timeout
        if job.invokation_timeout_task is not None:
            # conditional because sometimes some operations dont ask for an invocation timeout
            # and simply wait arbitrarily long
            invokation_timed_out = await asyncio.wrap_future(job.invokation_timeout_task)
        if invokation_timed_out:
            # the timeout already answered the caller, drop operation
            continue

        # hand the operation to the thing
        scheduler.last_operation_request = job.operation

        # schedule an execution timeout, which answers the caller early but would
        # still unable to interrupt the thing's execution
        execution_timed_out = False
        execution_timeout = job.operation.scheduler_execution_context.execution_timeout
        if execution_timeout is not None:
            job.execution_timeout_task = loop.create_task(job.answer_if_overdue(execution_timeout))

        # always drain the reply, even once a timeout has answered. Abandoning the wait leaves
        # the thing's answer sitting in the scheduler for the next job to pick up as its own.
        await scheduler.wait_for_reply()
        job.completed.set()  # releases the execution timeout
        if job.execution_timeout_task is not None:
            # conditional for the same reason as the invokation timeout above
            execution_timed_out = await job.execution_timeout_task

        # check the reply is never undefined, Undefined is a sensible placeholder for the
        # NotImplemented singleton
        if scheduler.last_operation_reply is Undefined:
            # this is a logic error, as the reply should never be undefined
            payload, preserialized_payload = format_return_value(
                dict(exception=format_exception_as_json(RuntimeError("No reply from thing - logic error"))),
                Serializers.default,
            )
            job.answer(Reply(payload, preserialized_payload, ReplyKind.ERROR))
            continue

        reply = scheduler.last_operation_reply
        scheduler.reset_operation_reply()
        if execution_timed_out:
            # drop the thing's late reply
            continue
        job.answer(reply)

    scheduler.cleanup()
    self.logger.info("stopped schedulers")

run_coro_threadsafe

run_coro_threadsafe(coro: Coroutine[Any, Any, Any]) -> Future

Run a coroutine on this EventLoop's own asyncio loop, from whichever thread is asking.

Parameters:

Name Type Description Default

coro

Coroutine[Any, Any, Any]

the coroutine to schedule

required

Returns:

Type Description
Future

resolved with the coroutine's result

Raises:

Type Description
RuntimeError

if the asyncio loop is not running

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def run_coro_threadsafe(self, coro: Coroutine[Any, Any, Any]) -> Future:
    """
    Run a coroutine on this `EventLoop`'s own asyncio loop, from whichever thread is asking.

    Parameters
    ----------
    coro: Coroutine
        the coroutine to schedule

    Returns
    -------
    concurrent.futures.Future
        resolved with the coroutine's result

    Raises
    ------
    RuntimeError
        if the asyncio loop is not running
    """
    if self._loop is None:
        coro.close()
        raise RuntimeError("the event loop is not running, call run() before submitting operations")
    return asyncio.run_coroutine_threadsafe(coro, self._loop)

add_stop_hook

add_stop_hook(callback: Callable[[], None]) -> None

Ask to be called when the event loop stops.

A protocol server registers its socket teardown here, so that stopping the event loop - which is what Thing.exit() reaches - also stops whatever is sitting in front of it.

Parameters:

Name Type Description Default

callback

Callable[[], None]

called from stop(), on whichever thread stopped the event loop

required
Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def add_stop_hook(self, callback: Callable[[], None]) -> None:
    """
    Ask to be called when the event loop stops.

    A protocol server registers its socket teardown here, so that stopping the event loop - which
    is what `Thing.exit()` reaches - also stops whatever is sitting in front of it.

    Parameters
    ----------
    callback: Callable[[], None]
        called from `stop()`, on whichever thread stopped the event loop
    """
    if callback not in self._stop_hooks:
        self._stop_hooks.append(callback)

stop

stop() -> None

Stop the event loop, and everything hooked onto it. This method is threadsafe.

Source code in repo/hololinked/hololinked/core/eventloop/eventloop.py
def stop(self) -> None:
    """Stop the event loop, and everything hooked onto it. This method is threadsafe."""
    self._run = False
    for hook in self._stop_hooks:
        try:
            hook()
        except Exception as ex:
            self.logger.warning(f"stop hook raised while stopping the event loop - {ex!s}")
    for scheduler in self.per_thing_schedulers.values():
        scheduler.cleanup()
    self.pending_operations.clear()

Attributes

logger

structlog.stdlib.BoundLogger
instance-attribute, read-only
logger instance, bound with component="eventloop" and the implementing class name.

event_bus

EventBus
instance-attribute, read-only
publishes events of every served Thing to the subscribed protocol servers. Replaces the ZMQ EventPublisher that the RPC server used to own.