Threads: Read receipts & notifications (#1255)
* Spec MSC3771: Threaded read receipts Note: this builds on a (as of writing) non-existent "threading" section, which is part of a different commit. * Spec MSC3773: Threaded notifications * changelog * Various clarifications per review
This commit is contained in:
parent
25dda1eadb
commit
227757d499
12 changed files with 218 additions and 10 deletions
1
changelogs/client_server/newsfragments/1255.feature
Normal file
1
changelogs/client_server/newsfragments/1255.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add per-thread notifications and read receipts, as per [MSC3771](https://github.com/matrix-org/matrix-spec-proposals/pull/3771) and [MSC3773](https://github.com/matrix-org/matrix-spec-proposals/pull/3773).
|
1
changelogs/server_server/newsfragments/1255.feature
Normal file
1
changelogs/server_server/newsfragments/1255.feature
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Add per-thread notifications and read receipts, as per [MSC3771](https://github.com/matrix-org/matrix-spec-proposals/pull/3771) and [MSC3773](https://github.com/matrix-org/matrix-spec-proposals/pull/3773).
|
|
@ -107,8 +107,10 @@ determined by the push rules which apply to an event.
|
||||||
|
|
||||||
When the user updates their read receipt (either by using the API or by
|
When the user updates their read receipt (either by using the API or by
|
||||||
sending an event), notifications prior to and including that event MUST
|
sending an event), notifications prior to and including that event MUST
|
||||||
be marked as read. Note that users can send both an `m.read` and
|
be marked as read. Which specific events are affected can vary depending
|
||||||
`m.read.private` receipt, both of which are capable of clearing notifications.
|
on whether a [threaded read receipt](#threaded-read-receipts) was used.
|
||||||
|
Note that users can send both an `m.read` and `m.read.private` receipt,
|
||||||
|
both of which are capable of clearing notifications.
|
||||||
|
|
||||||
If the user has both `m.read` and `m.read.private` set in the room then
|
If the user has both `m.read` and `m.read.private` set in the room then
|
||||||
the receipt which is more recent/ahead must be used to determine where
|
the receipt which is more recent/ahead must be used to determine where
|
||||||
|
@ -121,6 +123,17 @@ ahead), however if the `m.read.private` receipt were to be updated to
|
||||||
event D then the user has read up to D (the `m.read` receipt is now
|
event D then the user has read up to D (the `m.read` receipt is now
|
||||||
behind the `m.read.private` receipt).
|
behind the `m.read.private` receipt).
|
||||||
|
|
||||||
|
{{< added-in v="1.4" >}} When handling threaded read receipts, the server
|
||||||
|
is to partition the notification count to each thread (with the main timeline
|
||||||
|
being its own thread). To determine if an event is part of a thread the
|
||||||
|
server follows the [event relationship](#forming-relationships-between-events)
|
||||||
|
until it finds a thread root (as specified by the [threading module](#threading)),
|
||||||
|
however it is not recommended that the server traverse infinitely. Instead,
|
||||||
|
implementations are encouraged to do a maximum of 3 hops to find a thread
|
||||||
|
before deciding that the event does not belong to a thread. This is primarily
|
||||||
|
to ensure that future events, like `m.reaction`, are correctly considered
|
||||||
|
"part of" a given thread.
|
||||||
|
|
||||||
##### Push Rules
|
##### Push Rules
|
||||||
|
|
||||||
A push rule is a single rule that states under what *conditions* an
|
A push rule is a single rule that states under what *conditions* an
|
||||||
|
|
|
@ -22,33 +22,68 @@ that the user had read all events *up to* the referenced event. See the
|
||||||
[Receiving notifications](#receiving-notifications) section for more
|
[Receiving notifications](#receiving-notifications) section for more
|
||||||
information on how read receipts affect notification counts.
|
information on how read receipts affect notification counts.
|
||||||
|
|
||||||
|
{{< added-in v="1.4" >}} Read receipts exist in three major forms:
|
||||||
|
* Unthreaded: Denotes a read-up-to receipt regardless of threads. This is how
|
||||||
|
pre-threading read receipts worked.
|
||||||
|
* Threaded, main timeline: Denotes a read-up-to receipt for events not in a
|
||||||
|
particular thread. Identified by the thread ID `main`.
|
||||||
|
* Threaded, in a thread: Denotes a read-up-to receipt within a particular
|
||||||
|
thread. Identified by the event ID of the thread root.
|
||||||
|
|
||||||
|
Threaded read receipts are discussed in further detail [below](#threaded-read-receipts).
|
||||||
|
|
||||||
#### Events
|
#### Events
|
||||||
|
|
||||||
Each `user_id`, `receipt_type` pair must be associated with only a
|
{{< changed-in v="1.4" >}} Each `user_id`, `receipt_type`, and categorisation
|
||||||
single `event_id`.
|
(unthreaded, or `thread_id`) tuple must be associated with only a single
|
||||||
|
`event_id`.
|
||||||
|
|
||||||
{{% event event="m.receipt" %}}
|
{{% event event="m.receipt" %}}
|
||||||
|
|
||||||
#### Client behaviour
|
#### Client behaviour
|
||||||
|
|
||||||
|
{{< changed-in v="1.4" >}} Altered to support threaded read receipts.
|
||||||
|
|
||||||
In `/sync`, receipts are listed under the `ephemeral` array of events
|
In `/sync`, receipts are listed under the `ephemeral` array of events
|
||||||
for a given room. New receipts that come down the event streams are
|
for a given room. New receipts that come down the event streams are
|
||||||
deltas which update existing mappings. Clients should replace older
|
deltas which update existing mappings. Clients should replace older
|
||||||
receipt acknowledgements based on `user_id` and `receipt_type` pairs.
|
receipt acknowledgements based on `user_id`, `receipt_type`, and the
|
||||||
|
`thread_id` (if present).
|
||||||
For example:
|
For example:
|
||||||
|
|
||||||
Client receives m.receipt:
|
Client receives m.receipt:
|
||||||
user = @alice:example.com
|
user = @alice:example.com
|
||||||
receipt_type = m.read
|
receipt_type = m.read
|
||||||
event_id = $aaa:example.com
|
event_id = $aaa:example.com
|
||||||
|
thread_id = undefined
|
||||||
|
|
||||||
Client receives another m.receipt:
|
Client receives another m.receipt:
|
||||||
user = @alice:example.com
|
user = @alice:example.com
|
||||||
receipt_type = m.read
|
receipt_type = m.read
|
||||||
event_id = $bbb:example.com
|
event_id = $bbb:example.com
|
||||||
|
thread_id = main
|
||||||
|
|
||||||
The client should replace the older acknowledgement for $aaa:example.com with
|
The client does not replace any acknowledgements, yet.
|
||||||
this one for $bbb:example.com
|
|
||||||
|
Client receives yet another m.receipt:
|
||||||
|
user = @alice:example.com
|
||||||
|
receipt_type = m.read
|
||||||
|
event_id = $ccc:example.com
|
||||||
|
thread_id = undefined
|
||||||
|
|
||||||
|
The client replaces the older acknowledgement for $aaa:example.com
|
||||||
|
with this new one for $ccc:example.com, but does not replace the
|
||||||
|
acknowledgement for $bbb:example.com because it belongs to a thread.
|
||||||
|
|
||||||
|
Client receives yet another m.receipt:
|
||||||
|
user = @alice:example.com
|
||||||
|
receipt_type = m.read
|
||||||
|
event_id = $ddd:example.com
|
||||||
|
thread_id = main
|
||||||
|
|
||||||
|
Now the client replaces the older $bbb:example.com acknowledgement with
|
||||||
|
this new $ddd:example.com acknowledgement. The client does NOT replace the
|
||||||
|
older acknowledgement for $ccc:example.com as it is unthreaded.
|
||||||
|
|
||||||
Clients should send read receipts when there is some certainty that the
|
Clients should send read receipts when there is some certainty that the
|
||||||
event in question has been **displayed** to the user. Simply receiving
|
event in question has been **displayed** to the user. Simply receiving
|
||||||
|
@ -58,6 +93,12 @@ room that the event was sent to or dismissing a notification in order
|
||||||
for the event to count as "read". Clients SHOULD NOT send read receipts
|
for the event to count as "read". Clients SHOULD NOT send read receipts
|
||||||
for events sent by their own user.
|
for events sent by their own user.
|
||||||
|
|
||||||
|
Similar to the rules for sending receipts, threaded receipts should appear
|
||||||
|
in the context of the thread. If a thread is rendered behind a disclosure,
|
||||||
|
the client hasn't yet shown the event (or any applicable read receipts)
|
||||||
|
to the user. Once they expand the thread though, a threaded read receipt
|
||||||
|
would be sent and per-thread receipts from other users shown.
|
||||||
|
|
||||||
A client can update the markers for its user by interacting with the
|
A client can update the markers for its user by interacting with the
|
||||||
following HTTP APIs.
|
following HTTP APIs.
|
||||||
|
|
||||||
|
@ -87,6 +128,89 @@ not have their notification counts rewound to that point in time. While
|
||||||
uncommon, it is considered valid to have an `m.read` (public) receipt lag
|
uncommon, it is considered valid to have an `m.read` (public) receipt lag
|
||||||
several messages behind the `m.read.private` receipt, for example.
|
several messages behind the `m.read.private` receipt, for example.
|
||||||
|
|
||||||
|
##### Threaded read receipts
|
||||||
|
|
||||||
|
{{% added-in v="1.4" %}}
|
||||||
|
|
||||||
|
If a client does not use [threading](#threading), then they will simply only
|
||||||
|
send "unthreaded" read receipts which affect the whole room regardless of threads.
|
||||||
|
|
||||||
|
A threaded read receipt is simply one which has a `thread_id` on it, targeting
|
||||||
|
either a thread root's event ID or `main` for the main timeline.
|
||||||
|
|
||||||
|
Threading introduces a concept of multiple conversations being held in the same
|
||||||
|
room and thus deserve their own read receipts and notification counts. An event is
|
||||||
|
considered to be "in a thread" if it meets any of the following criteria:
|
||||||
|
* It has a `rel_type` of `m.thread`.
|
||||||
|
* It has child events with a `rel_type` of `m.thread` (in which case it'd be the
|
||||||
|
thread root).
|
||||||
|
* Following the event relationships, it has a parent event which qualifies for
|
||||||
|
one of the above. Implementations should not recurse infinitely, though: a
|
||||||
|
maximum of 3 hops is recommended to cover indirect relationships.
|
||||||
|
|
||||||
|
Events not in a thread but still in the room are considered to be part of the
|
||||||
|
"main timeline", or a special thread with an ID of `main`.
|
||||||
|
|
||||||
|
The following is an example DAG for a room, with dotted lines showing event
|
||||||
|
relationships and solid lines showing topological ordering.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
{{% boxes/note %}}
|
||||||
|
`m.reaction` relationships are not currently specified, but are shown here for
|
||||||
|
their conceptual place in a threaded DAG. They are currently proposed as
|
||||||
|
[MSC2677](https://github.com/matrix-org/matrix-spec-proposals/pull/2677).
|
||||||
|
{{% /boxes/note %}}
|
||||||
|
|
||||||
|
This DAG can be represented as 3 threaded timelines, with `A` and `B` being thread
|
||||||
|
roots:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
With this, we can demonstrate that:
|
||||||
|
* A threaded read receipt on `I` would mark `A`, `B`, and `I` as read.
|
||||||
|
* A threaded read receipt on `E` would mark `C` and `E` as read.
|
||||||
|
* An unthreaded read receipt on `D` would mark `A`, `B`, `C`, and `D` as read.
|
||||||
|
|
||||||
|
Note that marking `A` as read with a threaded read receipt would not mean
|
||||||
|
that `C`, `E`, `G`, or `H` get marked as read: Thread A's timeline would need
|
||||||
|
its own threaded read receipt at `H` to accomplish that.
|
||||||
|
|
||||||
|
The read receipts for the above 3 examples would be:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$I": {
|
||||||
|
"m.read": {
|
||||||
|
"@user:example.org": {
|
||||||
|
"ts": 1661384801651,
|
||||||
|
"thread_id": "main" // because `I` is not in a thread, but is a threaded receipt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$E": {
|
||||||
|
"m.read": {
|
||||||
|
"@user:example.org": {
|
||||||
|
"ts": 1661384801651,
|
||||||
|
"thread_id": "$A" // because `E` is in Thread `A`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$D": {
|
||||||
|
"m.read": {
|
||||||
|
"@user:example.org": {
|
||||||
|
"ts": 1661384801651
|
||||||
|
// no `thread_id` because the receipt is *unthreaded*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Conditions on sending read receipts apply similarly to threaded and unthreaded read
|
||||||
|
receipts. For example, a client might send a private read receipt for a threaded
|
||||||
|
event when the user expands that thread.
|
||||||
|
|
||||||
#### Server behaviour
|
#### Server behaviour
|
||||||
|
|
||||||
For efficiency, receipts SHOULD be batched into one event per room
|
For efficiency, receipts SHOULD be batched into one event per room
|
||||||
|
@ -99,7 +223,7 @@ format of the EDUs are:
|
||||||
{
|
{
|
||||||
<room_id>: {
|
<room_id>: {
|
||||||
<receipt_type>: {
|
<receipt_type>: {
|
||||||
<user_id>: { <content> }
|
<user_id>: { <content (ts & thread_id, currently)> }
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
# Copyright 2016 OpenMarket Ltd
|
# Copyright 2016 OpenMarket Ltd
|
||||||
|
# Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
|
@ -16,6 +17,12 @@ allOf:
|
||||||
- type: object
|
- type: object
|
||||||
title: RoomEventFilter
|
title: RoomEventFilter
|
||||||
properties:
|
properties:
|
||||||
|
unread_thread_notifications:
|
||||||
|
type: boolean
|
||||||
|
description: |-
|
||||||
|
If `true`, enables per-[thread](/client-server-api/#threading) notification
|
||||||
|
counts. Only applies to the `/sync` endpoint. Defaults to `false`.
|
||||||
|
x-addedInMatrixVersion: "1.4"
|
||||||
lazy_load_members:
|
lazy_load_members:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: |-
|
description: |-
|
||||||
|
|
|
@ -239,17 +239,50 @@ paths:
|
||||||
Counts of unread notifications for this room. See the
|
Counts of unread notifications for this room. See the
|
||||||
[Receiving notifications](/client-server-api/#receiving-notifications) section
|
[Receiving notifications](/client-server-api/#receiving-notifications) section
|
||||||
for more information on how these are calculated.
|
for more information on how these are calculated.
|
||||||
|
|
||||||
|
If `unread_thread_notifications` was specified as `true` on the `RoomEventFilter`,
|
||||||
|
these counts will only be for the main timeline rather than all events in the room.
|
||||||
|
See the [threading module](#threading) for more information.
|
||||||
|
x-changedInMatrixVersion:
|
||||||
|
1.4: |
|
||||||
|
Updated to reflect behaviour of having `unread_thread_notifications` as `true` in
|
||||||
|
the `RoomEventFilter` for `/sync`.
|
||||||
properties:
|
properties:
|
||||||
highlight_count:
|
highlight_count:
|
||||||
title: Highlighted notification count
|
title: Highlighted notification count
|
||||||
type: integer
|
type: integer
|
||||||
description: The number of unread notifications
|
description: The number of unread notifications
|
||||||
for this room with the highlight flag set
|
for this room with the highlight flag set.
|
||||||
notification_count:
|
notification_count:
|
||||||
title: Total notification count
|
title: Total notification count
|
||||||
type: integer
|
type: integer
|
||||||
description: The total number of unread notifications
|
description: The total number of unread notifications
|
||||||
for this room
|
for this room.
|
||||||
|
unread_thread_notifications:
|
||||||
|
title: Unread Thread Notification Counts
|
||||||
|
type: object
|
||||||
|
description: |-
|
||||||
|
If `unread_thread_notifications` was specified as `true` on the `RoomEventFilter`,
|
||||||
|
the notification counts for each [thread](#threading) in this room. The object is
|
||||||
|
keyed by thread root ID, with values matching `unread_notifications`.
|
||||||
|
|
||||||
|
If a thread does not have any notifications it can be omitted from this object. If
|
||||||
|
no threads have notification counts, this whole object can be omitted.
|
||||||
|
x-addedInMatrixVersion: "1.4"
|
||||||
|
additionalProperties:
|
||||||
|
title: ThreadNotificationCounts
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
highlight_count:
|
||||||
|
title: ThreadedHighlightNotificationCount
|
||||||
|
type: integer
|
||||||
|
description: |-
|
||||||
|
The number of unread notifications for this *thread* with the highlight flag set.
|
||||||
|
notification_count:
|
||||||
|
title: ThreadedTotalNotificationCount
|
||||||
|
type: integer
|
||||||
|
description: |-
|
||||||
|
The total number of unread notifications for this *thread*.
|
||||||
invite:
|
invite:
|
||||||
title: Invited Rooms
|
title: Invited Rooms
|
||||||
type: object
|
type: object
|
||||||
|
@ -424,6 +457,16 @@ paths:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"unread_notifications": {
|
||||||
|
"highlight_count": 1,
|
||||||
|
"notification_count": 5
|
||||||
|
},
|
||||||
|
"unread_thread_notifications": {
|
||||||
|
"$threadroot": {
|
||||||
|
"highlight_count": 3,
|
||||||
|
"notification_count": 6
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -65,6 +65,15 @@ allOf:
|
||||||
A POSIX timestamp in milliseconds for when the user read
|
A POSIX timestamp in milliseconds for when the user read
|
||||||
the event specified in the read receipt.
|
the event specified in the read receipt.
|
||||||
example: 1533358089009
|
example: 1533358089009
|
||||||
|
thread_id:
|
||||||
|
type: string
|
||||||
|
x-addedInMatrixVersion: "1.4"
|
||||||
|
description: |-
|
||||||
|
The root thread event's ID (or `main`) for which
|
||||||
|
thread this receipt is intended to be under. If
|
||||||
|
not specified, the read receipt is *unthreaded*
|
||||||
|
(default).
|
||||||
|
example: "$threadroot"
|
||||||
required: ['ts']
|
required: ['ts']
|
||||||
required: ['event_ids', 'data']
|
required: ['event_ids', 'data']
|
||||||
required: ['m.read']
|
required: ['m.read']
|
||||||
|
|
|
@ -38,6 +38,14 @@ properties:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: The timestamp the receipt was sent at.
|
description: The timestamp the receipt was sent at.
|
||||||
|
thread_id:
|
||||||
|
type: string
|
||||||
|
x-addedInMatrixVersion: "1.4"
|
||||||
|
description: |-
|
||||||
|
The root thread event's ID (or `main`) for which
|
||||||
|
thread this receipt is intended to be under. If
|
||||||
|
not specified, the read receipt is *unthreaded*
|
||||||
|
(default).
|
||||||
"m.read.private":
|
"m.read.private":
|
||||||
type: object
|
type: object
|
||||||
title: Own User
|
title: Own User
|
||||||
|
|
1
static/diagrams/threaded-dag-threads.drawio
Normal file
1
static/diagrams/threaded-dag-threads.drawio
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<mxfile host="app.diagrams.net" modified="2022-09-27T03:26:23.216Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" etag="YZcXq9Sm_7Lqw5o2RvSU" version="14.6.7" type="device"><diagram id="_rQ0dgHO1UnHExDn0l7E" name="Page-1">7ZpdU+IwFIZ/DZc6bdNWuJQCujPquOvOrl452TbQaNowIXztr9+EJv0goLCirYwyo81JGpL3PQ8nU2mBIFlcMDiOr2mESMuxokUL9FqO41i+I/7IyDKL2JbvZZERw5GKFYE7/BfpgSo6xRGaVAZySgnH42owpGmKQl6JQcbovDpsSEn1XcdwhIzAXQiJGf2NIx5n0bZzVsQvER7F+p1tv5P1JFAPVjuZxDCi81II9FsgYJTy7CpZBIhI9bQu2X2DLb35whhK+S43/PIf6YMfPSfXNzfx1WP3ez/2ToDaxwySqdrxz5ghGInYudQaJ4jgFKkt8KXWhdFpGiE5td0C3XmMObobw1D2zkUqiFjME6K6hzTlA5hgIrPgEpEZ4jiEsgMTElBC2WpS0O/Jl4jPEJMjyDnBo1T0cTpW09ypJajty4FosVURO9dZZCiiCeJsKYbo9NTWqOT0dHteOO2c+acqY+Oyz201FKr8GuWzFxaIC+XCPo742x3pHrsjAFQdcS3TEdu3Nvhhv5sfruHHNcTpsTuRq7zUHJhO5G59jBO2YcS5If0rYsPJOCsSQ7yQBq2LHAR9byDW2D2Egp2qgsDeoOAGAcG76dc25EKRKHaqSRmP6YimkPSLaLeay8WYKyrTbhV8QpwvVeWGU06rkqMF5vfi2lLXD/JafKRmrd6i1NVb6kYq9nuvJ5CN0l2yWdy2aun7Kiz9FHhOxHZv0Fz8/kETmObGyn2/bKuQiU5ZiF7QUx1tOGQjxF/LWzNNGCKQ41l1HQc33TGg6TYZGsdvGDQueDM0T9NkrMenNEU1cFRC56GM1RaOCPyDSBeGz6PVTkpeDwL5eqlsHRAw8BkAAwZgwZsBOwRIbn5u1afbjlczSs4XSnWh5O6IklMnSuZJu9cElMBaTXLdumuSewwg7Xm2awhI3o4ggTpB8gyQ+o0AyW5eTbINYb5Q+iCU/B1RcutEyXwaN2gCSu76g7K6axLoGEIlpwzBkGOaHgNje577IjiJ8+XLxi3kHLF0FXEstzkYnr2toqmEPLFObfFTSUpbfbjvTKqa/ZZisYliCB0OJ2Jp6ymaL+L/s9b878dFE+g2C6Xv1lwoQXsD3yjC/BjY3rN+fh62Ozuy7dVZYs3KcdkECF3QvNOqZQhz/LQ1BCX9ILrZj1D0KkswfWsETO6HPdcXzeJLFdlBofhuCuj/Aw==</diagram></mxfile>
|
BIN
static/diagrams/threaded-dag-threads.png
Normal file
BIN
static/diagrams/threaded-dag-threads.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
1
static/diagrams/threaded-dag.drawio
Normal file
1
static/diagrams/threaded-dag.drawio
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<mxfile host="app.diagrams.net" modified="2022-09-27T03:11:43.523Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" etag="L_ujIRop4Jndk67DcTE9" version="14.6.7" type="device"><diagram id="_rQ0dgHO1UnHExDn0l7E" name="Page-1">7VpbU+IwFP41PMqkTS/wKDfdGXXcdWdXn5wsDTTaNkwIAvvrN6UJbYlCF1gbXcYZTU5P0ub0u6S1DdiNFxcMTcJrGuCoYYNg0YC9hm3bwLPFnzSyzCIW8NwsMmYkkLE8cEd+Y5UoozMS4GkpkVMacTIpB4c0SfCQl2KIMTovp41oVD7rBI2xFrgbokiP/iQBD7Noy/bz+CUm41Cd2fLa2ZEYqWS5kmmIAjovhGC/AbuMUp614kUXR2n1VF2ycYM3jq4vjOGEVxnww3ukD17wHF/f3IRXj52v/dA9k7O8oGgmF3wur5YvVQnmIeH4boKGaX8u7nMDdkIeR6JniSaaTrLCj8gCi3N15JSYcbx481qtdQUEdjCNMWdLkaIGQFk0BRtH9uf5PVApYaH8KobkXR+vZ84LIxqyNn9Tp5ZWFhwIoMguZTykY5qgqJ9HO4zOkiAtyapOec4VpRMZfMKcLyXq0YzTcmnxgvB70Qay/ZC2m67s9RaFQ72l6iRivfdqgrRTGJV282Grnho3ogkfoJhEaeA7iQXpbHCD5+L3NxqjZH1j03Vvv62iTHTGhnhLPaUscMTGmO/Cpw4ThiPEyUv5Oo5+022NHB0TyGED08jhn8hxTHLAiuSwayWHr7EjbvKQYRQcjIanWTxR+QlN8IEAadpuASNWNYSApu8WQWLtgEiApuF6AWnnFnGOWbKK2MAR0Qj9wlEHDZ/Hq+V2aUTZqkBw0E1/NoF2iaMXzMkQ1QOvWrUXaujqGqG9vmna2z5p7zHJ4VQkB6xVe52Po71F6QXVALIpvWAHQj6O9FZFV63OroOrZ4L0Qsc06XVP0ntMcrgVyeHUKr3ux5He/ba99ifd9laF13YVOgNNB/pSIitDbjXdOWNoWUiYUJLwaeFst2kgP5WjZpSCp577B2/kW7C1LV80sivYGK0uh45GU1GYTYKsS7A/Z3TK9E0wFMeCTdcwS3FOlnJMznsVOe/Wain6e0ZjLWWv3bz9SXfzVdF16G5+L/dwwYa27XAPG7S25Zfd41jO4GnIHxjhDG3THjXgyReOyVy/InO9Wn1BZ0fcFK4w5IQmRjvDf/+KvSq+Dn2LuJ8ztMrOoH04sOkMbbgt/984g/7fpQsTnME17v2/fZASgJMzlOvZrshcv1ZnAK84Aw4If7ddwrvq/puKXcEQpkIeSDL+vloMyAOi54LXbbCA8wp2UgP2an1abWvQuzRBmD3XNGG2tLKchPkAcqgPQneyo10nOyxdmb+YQA///T6oFN38o9ZsO5h/Gwz7fwA=</diagram></mxfile>
|
BIN
static/diagrams/threaded-dag.png
Normal file
BIN
static/diagrams/threaded-dag.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
Loading…
Add table
Add a link
Reference in a new issue