Skip to content

AlchemicalTaskQueue

Bases: Generic[T, R]

An Alchemical Queue with crash-safe delivery: get() claims an entry rather than removing it, release()/discard()/extend() manage that claim, and respond()/responses() record and retrieve a result for a given entry. This is the queue tasks.Worker is built on. It is not intended to be initialized by a user, go through AlchemicalQueues.get_task_queue instead.

T is the type of entries (put()/get()); R is the type of responses (respond()/responses()) -- they're independent, each with their own serializer/response_serializer, since a task's input and its result are usually different shapes.

If you just want a plain FIFO/priority queue and don't need redelivery, retries, or result tracking, use AlchemicalQueues.get / AlchemicalQueue instead -- it has a much smaller surface.

Source code in src/alchemical_queues/main.py
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
class AlchemicalTaskQueue(Generic[T, R]):
    """An Alchemical Queue with crash-safe delivery: `get()` claims an entry
    rather than removing it, `release()`/`discard()`/`extend()` manage that
    claim, and `respond()`/`responses()` record and retrieve a result for a
    given entry. This is the queue [tasks.Worker][alchemical_queues.tasks.Worker]
    is built on. It is not intended to be initialized by a user, go through
    [AlchemicalQueues.get_task_queue][alchemical_queues.AlchemicalQueues.get_task_queue]
    instead.

    `T` is the type of entries (`put()`/`get()`); `R` is the type of
    responses (`respond()`/`responses()`) -- they're independent, each with
    their own `serializer`/`response_serializer`, since a task's input and
    its result are usually different shapes.

    If you just want a plain FIFO/priority queue and don't need redelivery,
    retries, or result tracking, use
    [AlchemicalQueues.get][alchemical_queues.AlchemicalQueues.get] /
    [AlchemicalQueue][alchemical_queues.AlchemicalQueue] instead -- it has a
    much smaller surface.
    """

    def __init__(
        self,
        engine: Engine,
        model,
        response_model,
        name: str,
        visibility_timeout: timedelta = DEFAULT_VISIBILITY_TIMEOUT,
        serializer: Serializer[T] = DEFAULT_SERIALIZER,
        response_serializer: Serializer[R] = DEFAULT_SERIALIZER,
    ):
        self._engine = engine
        self._model = model
        self._response_model = response_model
        self._name = name
        self._visibility_timeout = visibility_timeout
        self._response_serializer = response_serializer
        self._serializer = serializer
        self._session = sessionmaker(
            engine,
            autoflush=False,
            expire_on_commit=False,
        )

    @property
    def name(self) -> str:
        """The name of the queue"""
        return self._name

    @property
    def visibility_timeout(self) -> timedelta:
        """The default visibility_timeout this queue's `get()` calls use."""
        return self._visibility_timeout

    @_retry_on_deadlock
    def put(
        self,
        item: T,
        *,
        schedule_at: Union[datetime, None] = None,
        priority: int = 0,
    ) -> "AlchemicalEntry[T]":
        """Put an entry into the queue

        Args:
            item (T): The item you wish to add to the queue. Must be
                serializable by this queue's `serializer` (pickle-able, by
                default).
            schedule_at (datetime | None, optional): Earliest timestamp this entry may be claimed.
            priority (int, optional): Entry priority. Entries are claimed first in order of priority and then
                                      in order of adding to the queue.

        Returns:
            AlchemicalEntry[T]: The resultant queue entry.
        """

        entry = self._model(
            enqueued_at=datetime.now(),
            schedule_at=schedule_at,
            priority=priority,
            queue_name=self._name,
            data=self._serializer.dumps(item),
        )

        with self._session() as session:
            session.add(entry)
            session.commit()

            return AlchemicalEntry(entry, item)

    @_retry_on_deadlock
    def get(
        self, *, visibility_timeout: Union[timedelta, None] = None
    ) -> Union["AlchemicalEntry[T]", None]:
        """Claim the highest priority entry from the queue.

        The entry is not removed by this call: it is marked claimed until
        `visibility_timeout` elapses, so if your process dies before you call
        [release][alchemical_queues.AlchemicalTaskQueue.release] or
        [discard][alchemical_queues.AlchemicalTaskQueue.discard] for it, a
        later `get()` call will eventually claim it again instead of the
        entry being lost. Call `discard()` (after `respond()`, if you have a
        result) or `release()` (if you want it retried sooner than the
        timeout) as soon as you're done with an entry to free it up
        immediately, rather than waiting out the timeout. For work that can
        run longer than `visibility_timeout`, call
        [extend][alchemical_queues.AlchemicalTaskQueue.extend] periodically to
        push the deadline out while you're still on it.

        The returned entry's `claim_token` identifies *this* claim
        specifically -- pass it to `release()`/`discard()`/`extend()` so they
        only affect it, even if your claim has since timed out and been
        reclaimed by someone else (same entry_id, different claim_token).

        Args:
            visibility_timeout (timedelta | None, optional): how long this entry
                stays claimed before it becomes claimable again. Defaults to the
                visibility_timeout this queue was obtained with (see
                [AlchemicalQueues.get_task_queue][alchemical_queues.AlchemicalQueues.get_task_queue]).

        Returns:
            (AlchemicalEntry | None): The claimed entry, or None if the queue is empty
            (or nothing is claimable/scheduled yet)
        """

        timestamp = datetime.now()
        timeout = (
            visibility_timeout
            if visibility_timeout is not None
            else self._visibility_timeout
        )
        token = _new_claim_token()

        with self._session() as session:
            query = (
                select(self._model)
                .filter(
                    self._model.queue_name == self._name,
                    or_(
                        self._model.schedule_at == None,  # noqa: E711 (SQLAlchemy requires == None for IS NULL)
                        self._model.schedule_at <= timestamp,  # type: ignore
                    ),
                    or_(
                        self._model.claimed_until == None,  # noqa: E711 (SQLAlchemy requires == None for IS NULL)
                        self._model.claimed_until <= timestamp,  # type: ignore
                    ),
                )
                .order_by(self._model.priority.desc(), self._model.entry_id.asc())  # type: ignore
                .limit(1)
            )

            if self._engine.dialect.name in _SKIP_LOCKED_DIALECTS:
                query = query.with_for_update(skip_locked=True)

            if _supports_returning(self._engine, "update"):
                # Picking the candidate row and claiming it happen in one
                # atomic UPDATE ... RETURNING statement, so two concurrent
                # get() calls can never claim the same entry. This needs no
                # special transaction isolation (previously SQLite needed a
                # global BEGIN EXCLUSIVE, which serialized every transaction
                # on the engine, including ones from unrelated code sharing
                # the same engine).
                candidate = query.with_only_columns(self._model.entry_id)
                item = session.execute(
                    update(self._model)
                    .where(self._model.entry_id == candidate.scalar_subquery())
                    .values(claimed_until=timestamp + timeout, claim_token=token)
                    .returning(self._model)
                ).scalar_one_or_none()
            else:
                # MySQL has no RETURNING support at all, and MariaDB only
                # supports it for DELETE/INSERT, not UPDATE: lock and fetch
                # the full candidate row first instead, set the claim fields
                # on the already-loaded instance, and let the session flush
                # that as a plain UPDATE on commit. The row stays locked
                # throughout, so the same atomicity guarantee holds.
                item = session.execute(query).scalar_one_or_none()
                if item is not None:
                    item.claimed_until = timestamp + timeout
                    item.claim_token = token

            if item is None:
                session.rollback()
                return None

            entry = AlchemicalEntry(item, self._serializer.loads(item.data))
            session.commit()

        return entry

    @_retry_on_deadlock
    def release(self, entry_id: int, claim_token: int) -> None:
        """Release a claimed entry back to the queue, without recording a
        response for it. The entry itself is kept -- it becomes claimable
        again immediately, same as if its visibility timeout had just
        elapsed, instead of whoever's turn it is next having to wait that
        out.

        Use this when you called [get][alchemical_queues.AlchemicalTaskQueue.get],
        decided you have nothing to respond with yet (you want it retried
        sooner than the timeout, or by someone else), and don't want to
        discard it. To remove an entry entirely without a response, see
        [discard][alchemical_queues.AlchemicalTaskQueue.discard].

        Args:
            entry_id (int): the entry_id you wish to release.
            claim_token (int): the claim_token from the `get()` call that
                claimed this entry (`AlchemicalEntry.claim_token`).

        Raises:
            ClaimExpired: if entry_id isn't currently claimed under claim_token.
        """

        if not isinstance(entry_id, int):
            raise TypeError(f"entry_id={entry_id} should be integer")

        with self._session() as session:
            result = session.execute(
                update(self._model)
                .where(
                    self._model.entry_id == entry_id,
                    self._model.claim_token == claim_token,
                )
                .values(claimed_until=None, claim_token=None)
            )
            session.commit()

        if result.rowcount == 0:  # type: ignore[attr-defined]
            raise ClaimExpired(entry_id)

    @_retry_on_deadlock
    def discard(self, entry_id: int, claim_token: int) -> None:
        """Remove a claimed entry from the queue entirely, without recording
        a response for it. Unlike [release][alchemical_queues.AlchemicalTaskQueue.release],
        the entry does not become claimable again -- it's simply gone, the same
        as if [respond][alchemical_queues.AlchemicalTaskQueue.respond] had been
        called but without creating a response.

        Args:
            entry_id (int): the entry_id you wish to discard.
            claim_token (int): the claim_token from the `get()` call that
                claimed this entry (`AlchemicalEntry.claim_token`).

        Raises:
            ClaimExpired: if entry_id isn't currently claimed under claim_token.
        """

        if not isinstance(entry_id, int):
            raise TypeError(f"entry_id={entry_id} should be integer")

        with self._session() as session:
            result = session.execute(
                delete(self._model).where(
                    self._model.entry_id == entry_id,
                    self._model.claim_token == claim_token,
                )
            )
            session.commit()

        if result.rowcount == 0:  # type: ignore[attr-defined]
            raise ClaimExpired(entry_id)

    @_retry_on_deadlock
    def extend(
        self,
        entry_id: int,
        claim_token: int,
        *,
        by: Union[timedelta, None] = None,
    ) -> None:
        """Extend a claim's visibility timeout, for work that can take longer
        than it. Call this periodically while you're still actively on an
        entry (a keepalive) to push its claim's expiry further out, so it
        isn't redelivered to another `get()` call while you're still working
        on it. [tasks.Worker][alchemical_queues.tasks.Worker] can do this for
        you automatically; see its `keepalive_every` argument.

        Args:
            entry_id (int): the entry_id whose claim you wish to extend.
            claim_token (int): the claim_token from the `get()` call that
                claimed this entry (`AlchemicalEntry.claim_token`).
            by (timedelta | None, optional): how much longer the claim should
                last from now. Defaults to this queue's configured visibility_timeout.

        Raises:
            ClaimExpired: if entry_id isn't currently claimed under claim_token
                -- it's already timed out (and possibly been redelivered and
                finished by someone else), so extending it further would be
                meaningless at best and misleading at worst. Stop working.
        """

        if not isinstance(entry_id, int):
            raise TypeError(f"entry_id={entry_id} should be integer")

        timeout = by if by is not None else self._visibility_timeout
        new_claimed_until = datetime.now() + timeout

        with self._session() as session:
            result = session.execute(
                update(self._model)
                .where(
                    self._model.entry_id == entry_id,
                    self._model.claim_token == claim_token,
                )
                .values(claimed_until=new_claimed_until)
            )
            session.commit()

        if result.rowcount == 0:  # type: ignore[attr-defined]
            raise ClaimExpired(entry_id)

    def qsize(self) -> int:
        """Return the approximate size of this queue.

        Returns:
            int: Queue size.
        """
        with self._session() as session:
            return (
                session.scalar(
                    select(func.count())  # pylint: disable=not-callable
                    .select_from(self._model)
                    .where(self._model.queue_name == self._name)
                )
                or 0
            )

    def empty(self) -> bool:
        """Return `True` if the Queue is emtpy, `False` otherwise. More efficient than
        `qsize() > 0`.

        Returns:
            bool: wether the Queue is empty.
        """
        with self._session() as session:
            return (
                session.scalar(
                    select(self._model.entry_id)
                    .where(self._model.queue_name == self._name)
                    .limit(1)
                )
                is None
            )

    @_retry_on_deadlock
    def clear(self) -> None:
        """Clear all entries from this queue. Might fail-silent an update call."""

        with self._session() as session:
            session.execute(
                delete(self._model).where(self._model.queue_name == self._name)
            )
            session.commit()

    @_retry_on_deadlock
    def respond(
        self, entry_id: int, response: R, cleanup_at: Union[datetime, None] = None
    ) -> "AlchemicalResponse[R]":
        """Record a response for a queue entry. Used to implement task queues.

        This only records the response -- it has no effect on a claim you may
        be holding on entry_id (there may not even be one: it's independent
        of [get][alchemical_queues.AlchemicalTaskQueue.get]/claim lifecycle
        entirely, just like `responses()` is). Call
        [discard][alchemical_queues.AlchemicalTaskQueue.discard] separately once
        you've responded, to free up the entry you claimed.

        If this entry_id belongs to a `tasks.task`-scheduled task, note that
        [QueuedTask.result][alchemical_queues.tasks.QueuedTask.result] only
        recognizes the `{"result": ...}`/`{"error": ..., "error_type": ...}`
        shape `tasks.Worker` itself responds with to tell success from
        failure -- call `respond()` directly (as here) with anything else
        and `QueuedTask.result` just hands that value back to you unparsed.

        Args:
            entry_id (int): The entry_id you wish to respond to.
            response (R): The response data. Must be serializable by this
                queue's `response_serializer` (pickle-able, by default).
            cleanup_at (datetime, optional): The optional cleanup timestamp. After this time the response will be removed.
                                             By default it is not automatically cleaned up.

        Returns:
            AlchemicalResponse[R]: the response as sent.
        """

        if not isinstance(entry_id, int):
            raise TypeError(f"entry_id={entry_id} should be integer")

        entry = self._response_model(
            entry_id=entry_id,
            delivered_at=datetime.now(),
            cleanup_at=cleanup_at,
            queue_name=self._name,
            data=self._response_serializer.dumps(response),
        )

        with self._session() as session:
            session.add(entry)
            session.commit()

            return AlchemicalResponse(entry, response)

    @_retry_on_deadlock
    def responses(self, entry_id: int) -> List["AlchemicalResponse[R]"]:
        """Obtain the response(s) to a specific queue entry.

        Returns:
            List[AlchemicalResponse[R]]: A list of responses
        """
        if not isinstance(entry_id, int):
            raise TypeError(f"entry_id={entry_id} should be integer")

        with self._session() as session:
            now = datetime.now()

            session.execute(
                delete(self._response_model).where(
                    self._response_model.cleanup_at != None,  # noqa: E711 (SQLAlchemy requires == None for IS NULL)
                    self._response_model.cleanup_at < now,
                )
            )
            session.commit()

            entries: Any = session.scalars(
                select(self._response_model).where(
                    self._response_model.queue_name == self._name,
                    self._response_model.entry_id == entry_id,
                )
            ).all()
            return [
                AlchemicalResponse(e, self._response_serializer.loads(e.data))
                for e in entries
            ]

name property

The name of the queue

visibility_timeout property

The default visibility_timeout this queue's get() calls use.

put(item, *, schedule_at=None, priority=0)

Put an entry into the queue

Parameters:

Name Type Description Default
item T

The item you wish to add to the queue. Must be serializable by this queue's serializer (pickle-able, by default).

required
schedule_at datetime | None

Earliest timestamp this entry may be claimed.

None
priority int

Entry priority. Entries are claimed first in order of priority and then in order of adding to the queue.

0

Returns:

Type Description
AlchemicalEntry[T]

AlchemicalEntry[T]: The resultant queue entry.

Source code in src/alchemical_queues/main.py
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
@_retry_on_deadlock
def put(
    self,
    item: T,
    *,
    schedule_at: Union[datetime, None] = None,
    priority: int = 0,
) -> "AlchemicalEntry[T]":
    """Put an entry into the queue

    Args:
        item (T): The item you wish to add to the queue. Must be
            serializable by this queue's `serializer` (pickle-able, by
            default).
        schedule_at (datetime | None, optional): Earliest timestamp this entry may be claimed.
        priority (int, optional): Entry priority. Entries are claimed first in order of priority and then
                                  in order of adding to the queue.

    Returns:
        AlchemicalEntry[T]: The resultant queue entry.
    """

    entry = self._model(
        enqueued_at=datetime.now(),
        schedule_at=schedule_at,
        priority=priority,
        queue_name=self._name,
        data=self._serializer.dumps(item),
    )

    with self._session() as session:
        session.add(entry)
        session.commit()

        return AlchemicalEntry(entry, item)

get(*, visibility_timeout=None)

Claim the highest priority entry from the queue.

The entry is not removed by this call: it is marked claimed until visibility_timeout elapses, so if your process dies before you call release or discard for it, a later get() call will eventually claim it again instead of the entry being lost. Call discard() (after respond(), if you have a result) or release() (if you want it retried sooner than the timeout) as soon as you're done with an entry to free it up immediately, rather than waiting out the timeout. For work that can run longer than visibility_timeout, call extend periodically to push the deadline out while you're still on it.

The returned entry's claim_token identifies this claim specifically -- pass it to release()/discard()/extend() so they only affect it, even if your claim has since timed out and been reclaimed by someone else (same entry_id, different claim_token).

Parameters:

Name Type Description Default
visibility_timeout timedelta | None

how long this entry stays claimed before it becomes claimable again. Defaults to the visibility_timeout this queue was obtained with (see AlchemicalQueues.get_task_queue).

None

Returns:

Type Description
AlchemicalEntry | None

The claimed entry, or None if the queue is empty

Union[AlchemicalEntry[T], None]

(or nothing is claimable/scheduled yet)

Source code in src/alchemical_queues/main.py
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
@_retry_on_deadlock
def get(
    self, *, visibility_timeout: Union[timedelta, None] = None
) -> Union["AlchemicalEntry[T]", None]:
    """Claim the highest priority entry from the queue.

    The entry is not removed by this call: it is marked claimed until
    `visibility_timeout` elapses, so if your process dies before you call
    [release][alchemical_queues.AlchemicalTaskQueue.release] or
    [discard][alchemical_queues.AlchemicalTaskQueue.discard] for it, a
    later `get()` call will eventually claim it again instead of the
    entry being lost. Call `discard()` (after `respond()`, if you have a
    result) or `release()` (if you want it retried sooner than the
    timeout) as soon as you're done with an entry to free it up
    immediately, rather than waiting out the timeout. For work that can
    run longer than `visibility_timeout`, call
    [extend][alchemical_queues.AlchemicalTaskQueue.extend] periodically to
    push the deadline out while you're still on it.

    The returned entry's `claim_token` identifies *this* claim
    specifically -- pass it to `release()`/`discard()`/`extend()` so they
    only affect it, even if your claim has since timed out and been
    reclaimed by someone else (same entry_id, different claim_token).

    Args:
        visibility_timeout (timedelta | None, optional): how long this entry
            stays claimed before it becomes claimable again. Defaults to the
            visibility_timeout this queue was obtained with (see
            [AlchemicalQueues.get_task_queue][alchemical_queues.AlchemicalQueues.get_task_queue]).

    Returns:
        (AlchemicalEntry | None): The claimed entry, or None if the queue is empty
        (or nothing is claimable/scheduled yet)
    """

    timestamp = datetime.now()
    timeout = (
        visibility_timeout
        if visibility_timeout is not None
        else self._visibility_timeout
    )
    token = _new_claim_token()

    with self._session() as session:
        query = (
            select(self._model)
            .filter(
                self._model.queue_name == self._name,
                or_(
                    self._model.schedule_at == None,  # noqa: E711 (SQLAlchemy requires == None for IS NULL)
                    self._model.schedule_at <= timestamp,  # type: ignore
                ),
                or_(
                    self._model.claimed_until == None,  # noqa: E711 (SQLAlchemy requires == None for IS NULL)
                    self._model.claimed_until <= timestamp,  # type: ignore
                ),
            )
            .order_by(self._model.priority.desc(), self._model.entry_id.asc())  # type: ignore
            .limit(1)
        )

        if self._engine.dialect.name in _SKIP_LOCKED_DIALECTS:
            query = query.with_for_update(skip_locked=True)

        if _supports_returning(self._engine, "update"):
            # Picking the candidate row and claiming it happen in one
            # atomic UPDATE ... RETURNING statement, so two concurrent
            # get() calls can never claim the same entry. This needs no
            # special transaction isolation (previously SQLite needed a
            # global BEGIN EXCLUSIVE, which serialized every transaction
            # on the engine, including ones from unrelated code sharing
            # the same engine).
            candidate = query.with_only_columns(self._model.entry_id)
            item = session.execute(
                update(self._model)
                .where(self._model.entry_id == candidate.scalar_subquery())
                .values(claimed_until=timestamp + timeout, claim_token=token)
                .returning(self._model)
            ).scalar_one_or_none()
        else:
            # MySQL has no RETURNING support at all, and MariaDB only
            # supports it for DELETE/INSERT, not UPDATE: lock and fetch
            # the full candidate row first instead, set the claim fields
            # on the already-loaded instance, and let the session flush
            # that as a plain UPDATE on commit. The row stays locked
            # throughout, so the same atomicity guarantee holds.
            item = session.execute(query).scalar_one_or_none()
            if item is not None:
                item.claimed_until = timestamp + timeout
                item.claim_token = token

        if item is None:
            session.rollback()
            return None

        entry = AlchemicalEntry(item, self._serializer.loads(item.data))
        session.commit()

    return entry

release(entry_id, claim_token)

Release a claimed entry back to the queue, without recording a response for it. The entry itself is kept -- it becomes claimable again immediately, same as if its visibility timeout had just elapsed, instead of whoever's turn it is next having to wait that out.

Use this when you called get, decided you have nothing to respond with yet (you want it retried sooner than the timeout, or by someone else), and don't want to discard it. To remove an entry entirely without a response, see discard.

Parameters:

Name Type Description Default
entry_id int

the entry_id you wish to release.

required
claim_token int

the claim_token from the get() call that claimed this entry (AlchemicalEntry.claim_token).

required

Raises:

Type Description
ClaimExpired

if entry_id isn't currently claimed under claim_token.

Source code in src/alchemical_queues/main.py
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
@_retry_on_deadlock
def release(self, entry_id: int, claim_token: int) -> None:
    """Release a claimed entry back to the queue, without recording a
    response for it. The entry itself is kept -- it becomes claimable
    again immediately, same as if its visibility timeout had just
    elapsed, instead of whoever's turn it is next having to wait that
    out.

    Use this when you called [get][alchemical_queues.AlchemicalTaskQueue.get],
    decided you have nothing to respond with yet (you want it retried
    sooner than the timeout, or by someone else), and don't want to
    discard it. To remove an entry entirely without a response, see
    [discard][alchemical_queues.AlchemicalTaskQueue.discard].

    Args:
        entry_id (int): the entry_id you wish to release.
        claim_token (int): the claim_token from the `get()` call that
            claimed this entry (`AlchemicalEntry.claim_token`).

    Raises:
        ClaimExpired: if entry_id isn't currently claimed under claim_token.
    """

    if not isinstance(entry_id, int):
        raise TypeError(f"entry_id={entry_id} should be integer")

    with self._session() as session:
        result = session.execute(
            update(self._model)
            .where(
                self._model.entry_id == entry_id,
                self._model.claim_token == claim_token,
            )
            .values(claimed_until=None, claim_token=None)
        )
        session.commit()

    if result.rowcount == 0:  # type: ignore[attr-defined]
        raise ClaimExpired(entry_id)

discard(entry_id, claim_token)

Remove a claimed entry from the queue entirely, without recording a response for it. Unlike release, the entry does not become claimable again -- it's simply gone, the same as if respond had been called but without creating a response.

Parameters:

Name Type Description Default
entry_id int

the entry_id you wish to discard.

required
claim_token int

the claim_token from the get() call that claimed this entry (AlchemicalEntry.claim_token).

required

Raises:

Type Description
ClaimExpired

if entry_id isn't currently claimed under claim_token.

Source code in src/alchemical_queues/main.py
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
@_retry_on_deadlock
def discard(self, entry_id: int, claim_token: int) -> None:
    """Remove a claimed entry from the queue entirely, without recording
    a response for it. Unlike [release][alchemical_queues.AlchemicalTaskQueue.release],
    the entry does not become claimable again -- it's simply gone, the same
    as if [respond][alchemical_queues.AlchemicalTaskQueue.respond] had been
    called but without creating a response.

    Args:
        entry_id (int): the entry_id you wish to discard.
        claim_token (int): the claim_token from the `get()` call that
            claimed this entry (`AlchemicalEntry.claim_token`).

    Raises:
        ClaimExpired: if entry_id isn't currently claimed under claim_token.
    """

    if not isinstance(entry_id, int):
        raise TypeError(f"entry_id={entry_id} should be integer")

    with self._session() as session:
        result = session.execute(
            delete(self._model).where(
                self._model.entry_id == entry_id,
                self._model.claim_token == claim_token,
            )
        )
        session.commit()

    if result.rowcount == 0:  # type: ignore[attr-defined]
        raise ClaimExpired(entry_id)

extend(entry_id, claim_token, *, by=None)

Extend a claim's visibility timeout, for work that can take longer than it. Call this periodically while you're still actively on an entry (a keepalive) to push its claim's expiry further out, so it isn't redelivered to another get() call while you're still working on it. tasks.Worker can do this for you automatically; see its keepalive_every argument.

Parameters:

Name Type Description Default
entry_id int

the entry_id whose claim you wish to extend.

required
claim_token int

the claim_token from the get() call that claimed this entry (AlchemicalEntry.claim_token).

required
by timedelta | None

how much longer the claim should last from now. Defaults to this queue's configured visibility_timeout.

None

Raises:

Type Description
ClaimExpired

if entry_id isn't currently claimed under claim_token -- it's already timed out (and possibly been redelivered and finished by someone else), so extending it further would be meaningless at best and misleading at worst. Stop working.

Source code in src/alchemical_queues/main.py
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
@_retry_on_deadlock
def extend(
    self,
    entry_id: int,
    claim_token: int,
    *,
    by: Union[timedelta, None] = None,
) -> None:
    """Extend a claim's visibility timeout, for work that can take longer
    than it. Call this periodically while you're still actively on an
    entry (a keepalive) to push its claim's expiry further out, so it
    isn't redelivered to another `get()` call while you're still working
    on it. [tasks.Worker][alchemical_queues.tasks.Worker] can do this for
    you automatically; see its `keepalive_every` argument.

    Args:
        entry_id (int): the entry_id whose claim you wish to extend.
        claim_token (int): the claim_token from the `get()` call that
            claimed this entry (`AlchemicalEntry.claim_token`).
        by (timedelta | None, optional): how much longer the claim should
            last from now. Defaults to this queue's configured visibility_timeout.

    Raises:
        ClaimExpired: if entry_id isn't currently claimed under claim_token
            -- it's already timed out (and possibly been redelivered and
            finished by someone else), so extending it further would be
            meaningless at best and misleading at worst. Stop working.
    """

    if not isinstance(entry_id, int):
        raise TypeError(f"entry_id={entry_id} should be integer")

    timeout = by if by is not None else self._visibility_timeout
    new_claimed_until = datetime.now() + timeout

    with self._session() as session:
        result = session.execute(
            update(self._model)
            .where(
                self._model.entry_id == entry_id,
                self._model.claim_token == claim_token,
            )
            .values(claimed_until=new_claimed_until)
        )
        session.commit()

    if result.rowcount == 0:  # type: ignore[attr-defined]
        raise ClaimExpired(entry_id)

qsize()

Return the approximate size of this queue.

Returns:

Name Type Description
int int

Queue size.

Source code in src/alchemical_queues/main.py
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
def qsize(self) -> int:
    """Return the approximate size of this queue.

    Returns:
        int: Queue size.
    """
    with self._session() as session:
        return (
            session.scalar(
                select(func.count())  # pylint: disable=not-callable
                .select_from(self._model)
                .where(self._model.queue_name == self._name)
            )
            or 0
        )

empty()

Return True if the Queue is emtpy, False otherwise. More efficient than qsize() > 0.

Returns:

Name Type Description
bool bool

wether the Queue is empty.

Source code in src/alchemical_queues/main.py
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
def empty(self) -> bool:
    """Return `True` if the Queue is emtpy, `False` otherwise. More efficient than
    `qsize() > 0`.

    Returns:
        bool: wether the Queue is empty.
    """
    with self._session() as session:
        return (
            session.scalar(
                select(self._model.entry_id)
                .where(self._model.queue_name == self._name)
                .limit(1)
            )
            is None
        )

clear()

Clear all entries from this queue. Might fail-silent an update call.

Source code in src/alchemical_queues/main.py
1024
1025
1026
1027
1028
1029
1030
1031
1032
@_retry_on_deadlock
def clear(self) -> None:
    """Clear all entries from this queue. Might fail-silent an update call."""

    with self._session() as session:
        session.execute(
            delete(self._model).where(self._model.queue_name == self._name)
        )
        session.commit()

respond(entry_id, response, cleanup_at=None)

Record a response for a queue entry. Used to implement task queues.

This only records the response -- it has no effect on a claim you may be holding on entry_id (there may not even be one: it's independent of get/claim lifecycle entirely, just like responses() is). Call discard separately once you've responded, to free up the entry you claimed.

If this entry_id belongs to a tasks.task-scheduled task, note that QueuedTask.result only recognizes the {"result": ...}/{"error": ..., "error_type": ...} shape tasks.Worker itself responds with to tell success from failure -- call respond() directly (as here) with anything else and QueuedTask.result just hands that value back to you unparsed.

Parameters:

Name Type Description Default
entry_id int

The entry_id you wish to respond to.

required
response R

The response data. Must be serializable by this queue's response_serializer (pickle-able, by default).

required
cleanup_at datetime

The optional cleanup timestamp. After this time the response will be removed. By default it is not automatically cleaned up.

None

Returns:

Type Description
AlchemicalResponse[R]

AlchemicalResponse[R]: the response as sent.

Source code in src/alchemical_queues/main.py
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
@_retry_on_deadlock
def respond(
    self, entry_id: int, response: R, cleanup_at: Union[datetime, None] = None
) -> "AlchemicalResponse[R]":
    """Record a response for a queue entry. Used to implement task queues.

    This only records the response -- it has no effect on a claim you may
    be holding on entry_id (there may not even be one: it's independent
    of [get][alchemical_queues.AlchemicalTaskQueue.get]/claim lifecycle
    entirely, just like `responses()` is). Call
    [discard][alchemical_queues.AlchemicalTaskQueue.discard] separately once
    you've responded, to free up the entry you claimed.

    If this entry_id belongs to a `tasks.task`-scheduled task, note that
    [QueuedTask.result][alchemical_queues.tasks.QueuedTask.result] only
    recognizes the `{"result": ...}`/`{"error": ..., "error_type": ...}`
    shape `tasks.Worker` itself responds with to tell success from
    failure -- call `respond()` directly (as here) with anything else
    and `QueuedTask.result` just hands that value back to you unparsed.

    Args:
        entry_id (int): The entry_id you wish to respond to.
        response (R): The response data. Must be serializable by this
            queue's `response_serializer` (pickle-able, by default).
        cleanup_at (datetime, optional): The optional cleanup timestamp. After this time the response will be removed.
                                         By default it is not automatically cleaned up.

    Returns:
        AlchemicalResponse[R]: the response as sent.
    """

    if not isinstance(entry_id, int):
        raise TypeError(f"entry_id={entry_id} should be integer")

    entry = self._response_model(
        entry_id=entry_id,
        delivered_at=datetime.now(),
        cleanup_at=cleanup_at,
        queue_name=self._name,
        data=self._response_serializer.dumps(response),
    )

    with self._session() as session:
        session.add(entry)
        session.commit()

        return AlchemicalResponse(entry, response)

responses(entry_id)

Obtain the response(s) to a specific queue entry.

Returns:

Type Description
List[AlchemicalResponse[R]]

List[AlchemicalResponse[R]]: A list of responses

Source code in src/alchemical_queues/main.py
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
@_retry_on_deadlock
def responses(self, entry_id: int) -> List["AlchemicalResponse[R]"]:
    """Obtain the response(s) to a specific queue entry.

    Returns:
        List[AlchemicalResponse[R]]: A list of responses
    """
    if not isinstance(entry_id, int):
        raise TypeError(f"entry_id={entry_id} should be integer")

    with self._session() as session:
        now = datetime.now()

        session.execute(
            delete(self._response_model).where(
                self._response_model.cleanup_at != None,  # noqa: E711 (SQLAlchemy requires == None for IS NULL)
                self._response_model.cleanup_at < now,
            )
        )
        session.commit()

        entries: Any = session.scalars(
            select(self._response_model).where(
                self._response_model.queue_name == self._name,
                self._response_model.entry_id == entry_id,
            )
        ).all()
        return [
            AlchemicalResponse(e, self._response_serializer.loads(e.data))
            for e in entries
        ]