Les traductions sont fournies par des outils de traduction automatique. En cas de conflit entre le contenu d'une traduction et celui de la version originale en anglais, la version anglaise prévaudra.
Réalisation d'upserts Gremlin efficaces avec fold()/coalesce()/unfold()
Une insertion conditionnelle (également appelée « upsert ») réutilise un sommet ou une arête qui existe déjà, ou crée l'objet nécessaire dans le cas contraire. Des upserts efficaces peuvent faire une différence significative dans les performances des requêtes Gremlin.
Cette page montre comment utiliser le modèle Gremlin fold()/coalesce()/unfold()
pour réaliser des upserts efficaces. Cependant, avec la sortie de la TinkerPop version 3.6.x introduite dans Neptune dans la version 1.2.1.0 du moteur, les nouvelles mergeE()
étapes sont préférables dans la plupart mergeV()
des cas. Le modèle fold()/coalesce()/unfold()
décrit ici peut encore être utile dans certaines situations complexes, mais en régle générale, utilisez mergeV()
et mergeE()
si vous le pouvez, comme décrit dans Réalisation d'upserts efficaces avec les étapes Gremlin mergeV() et mergeE().
Les upserts vous permettent d'écrire des opérations d'insertion idempotentes : quel que soit le nombre de fois que vous exécutez cette opération, le résultat global est le même. Cela est utile dans les scénarios d'écriture hautement simultanés où les modifications simultanées apportées à la même partie du graphe peuvent forcer une ou plusieurs transactions à revenir en arrière avec une exception ConcurrentModificationException
, nécessitant ainsi une nouvelle tentative.
Par exemple, la requête suivante insère un sommet en recherchant d'abord le sommet spécifié dans le jeu de données, puis en regroupant les résultats dans une liste. Dans la première traversée fournie à l'étape coalesce()
, la requête déplie ensuite cette liste. Si la liste dépliée n'est pas vide, les résultats sont émis à partir de coalesce()
. Toutefois, si unfold()
renvoie une collection vide parce que le sommet n'existe pas, coalesce()
passe à l'évaluation de la deuxième traversée avec laquelle il a été fourni, et dans cette deuxième traversée, la requête crée le sommet manquant.
g.V('v-1').fold() .coalesce( unfold(), addV('Person').property(id, 'v-1') .property('email', 'person-1@example.org') )
Utilisation d'une forme optimisée de coalesce()
pour les upserts
Neptune peut optimiser l'idiome fold().coalesce(unfold(), ...)
pour effectuer des mises à jour à haut débit, mais cette optimisation ne fonctionne que si les deux parties de l'idiome coalesce()
renvoient un sommet ou une arête, mais rien d'autre. Si vous essayez de renvoyer quelque chose de différent, tel qu'une propriété, à partir de n'importe quelle partie du coalesce()
, l'optimisation Neptune n'a pas lieu. La requête peut aboutir, mais elle ne fonctionnera pas aussi bien qu'une version optimisée, en particulier pour les vastes jeux de données.
Étant donné que les requêtes d'upsert non optimisées augmentent les temps d'exécution et réduisent le débit, il est utile d'utiliser le point de terminaison Gremlin explain
pour déterminer si une requête d'upsert est entièrement optimisée. Lorsque vous examinez les plans explain
, recherchez les lignes commençant par + not converted into Neptune steps
et WARNING: >>
. Par exemple :
+ not converted into Neptune steps: [FoldStep, CoalesceStep([[UnfoldStep], [AddEdgeSte
...
WARNING: >> FoldStep << is not supported natively yet
Ces avertissements peuvent vous aider à identifier les parties d'une requête qui empêchent son optimisation complète.
Parfois, il n'est pas possible d'optimiser complètement une requête. Dans ces situations, vous devez essayer de placer les étapes qui ne peuvent pas être optimisées à la fin de la requête, afin de permettre au moteur d'optimiser autant d'étapes que possible. Cette technique est utilisée dans certains exemples d'upserts en bloc, où tous les upserts optimisés pour un ensemble de sommets ou d'arêtes sont effectués avant que des modifications supplémentaires potentiellement non optimisées ne soient appliquées aux mêmes sommets ou arêtes.
Exécution d'upserts par lots pour améliorer le débit
Pour les scénarios d'écriture à haut débit, vous pouvez enchaîner les étapes d'upsert pour effectuer l'upsert en bloc des sommets et des arêtes. Le traitement par lots réduit la charge transactionnelle liée à l'insertion par upsert d'un grand nombre de sommets et d'arêtes. Vous pouvez ainsi améliorer davantage le débit en augmentant les demandes d'upserts par lots en parallèle à l'aide de plusieurs clients.
En règle générale, nous recommandons d'insérer par upsert environ 200 enregistrements par demande par lots. Un enregistrement correspond à une étiquette ou propriété individuelle de sommet ou d'arête. Par exemple, un sommet doté d'une seule étiquette et de quatre propriétés génère cinq enregistrements. Une arête dotée d'une étiquette et d'une seule propriété génère deux enregistrements. Si vous souhaitez insérer par upsert des lots de sommets, chacun avec une seule étiquette et quatre propriétés, vous devez commencer par une taille de lot de 40, car 200 / (1 + 4)
= 40
.
Vous pouvez tester différentes tailles de lots. 200 enregistrements par lot constituent un bon point de départ, mais la taille de lot idéale peut être supérieure ou inférieure en fonction de votre charge de travail. Notez toutefois que Neptune peut limiter le nombre total d'étapes Gremlin par demande. Cette limite n'est pas documentée, mais par mesure de sécurité, essayez de faire en sorte que les demandes ne contiennent pas plus de 1 500 étapes Gremlin. Neptune peut rejeter des demandes en bloc volumineuses comportant plus de 1 500 étapes.
Pour augmenter le débit, vous pouvez insérer par upsert des lots en parallèle à l'aide de plusieurs clients (voir Création d'écritures Gremlin multithreads efficaces). Le nombre de clients doit être le même que le nombre de threads de travail sur votre instance Neptune Writer, qui est généralement 2 fois le nombre de threads vCPUs sur le serveur. Par exemple, une r5.8xlarge
instance possède 32 vCPUs et 64 threads de travail. Pour les scénarios d'écriture à haut débit utilisant une instance r5.8xlarge
, vous devez utiliser 64 clients écrivant des upserts par lots sur Neptune en parallèle.
Chaque client doit soumettre une demande par lots et attendre qu'elle soit terminée avant de soumettre une autre demande. Bien que les différents clients fonctionnent en parallèle, chacun d'eux soumet des demandes en série. Cela garantit que le serveur reçoit un flux constant de demandes qui occupent tous les threads de travail sans encombrer la file d'attente des demandes côté serveur (voir Dimensionnement des instances de base de données dans un cluster de bases de données Neptune).
Essayer d'éviter les étapes qui génèrent plusieurs traverseurs
Lorsqu'une étape Gremlin s'exécute, elle utilise un traverseur entrant et émet un ou plusieurs traverseurs de sortie. Le nombre de traverseurs émis par une étape détermine le nombre de fois que l'étape suivante sera exécutée.
Généralement, lorsque vous effectuez des opérations par lots, vous souhaitez que chaque opération, telle que l'upsert du sommet A, soit exécutée une seule fois, de sorte que la séquence des opérations ressemble à ceci : upsert du sommet A, puis upsert du sommet B, puis upsert du sommet C, etc. Tant qu'une étape ne crée ou ne modifie qu'un seul élément, elle n'émet qu'un seul traverseur, et les étapes représentant l'opération suivante ne sont exécutées qu'une seule fois. Si, en revanche, une opération crée ou modifie plusieurs éléments, elle émet plusieurs traverseurs, ce qui entraîne l'exécution des étapes suivantes plusieurs fois, une fois par traverseur émis. Cela peut obliger la base de données à effectuer des tâches supplémentaires inutiles et, dans certains cas, à créer des sommets, des arêtes ou des valeurs de propriétés supplémentaires superflus.
La requête g.V().addV()
est un bon exemple de cas où la situation peut dégénérer. Cette requête simple ajoute un sommet pour chaque sommet du graphe, car V()
émet un traverseur pour chaque sommet du graphe et chacun de ces traverseurs déclenche un appel à addV()
.
Consultez Combinaison d'upserts et d'insertions pour découvrir comment gérer les opérations qui peuvent émettre plusieurs traverseurs.
Insertion de sommets par upsert
Vous pouvez utiliser un ID de sommet pour déterminer s'il existe un sommet correspondant. Il s'agit de l'approche préférée, car Neptune optimise les upserts pour les cas d'utilisation très concurrents. IDs Par exemple, la requête suivante crée un sommet avec un ID de sommet donné s'il n'existe pas déjà, ou le réutilise s'il existe déjà :
g.V('v-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-1') .property('email', 'person-1@example.org')) .id()
Notez que cette requête se termine par une étape id()
. Bien que cela ne soit pas strictement nécessaire pour réaliser l'upsert du sommet, l'ajout d'une étape id()
à la fin d'une requête d'upsert garantit que le serveur ne sérialise pas toutes les propriétés du sommet vers le client, ce qui contribue à réduire le coût de verrouillage de la requête.
Vous pouvez également utiliser une propriété de sommet pour déterminer si le sommet existe :
g.V() .hasLabel('Person') .has('email', 'person-1@example.org') .fold() .coalesce(unfold(), addV('Person').property('email', 'person-1@example.org')) .id()
Si possible, utilisez les sommets fournis par l'utilisateur IDs pour créer des sommets, et utilisez-les IDs pour déterminer si un sommet existe lors d'une opération de remontée. Cela permet à Neptune d'optimiser les upserts autour du. IDs Un upsert basé sur un ID peut être nettement plus efficace qu'un upsert basé sur des propriétés dans les scénarios où les modifications simultanées sont nombreuses.
Enchaînement d'upserts de sommets
Vous pouvez enchaîner des upserts de sommets pour les insérer dans un lot :
g.V('v-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-1') .property('email', 'person-1@example.org')) .V('v-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-2') .property('email', 'person-2@example.org')) .V('v-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-3') .property('email', 'person-3@example.org')) .id()
Exécution d'upserts d'arêtes
Vous pouvez utiliser une arête IDs pour insérer des arêtes de la même manière que vous insérez des sommets à l'aide d'un sommet personnalisé. IDs Là aussi, il s'agit de l'approche préférée, car elle permet à Neptune d'optimiser la requête. Par exemple, la requête suivante crée une arête en fonction de son ID d'arête si elle n'existe pas déjà, ou la réutilise si elle existe déjà. La requête utilise également IDs les to
sommets from
et si elle doit créer une nouvelle arête.
g.E('e-1') .fold() .coalesce(unfold(), addE('KNOWS').from(V('v-1')) .to(V('v-2')) .property(id, 'e-1')) .id()
De nombreuses applications utilisent un sommet personnaliséIDs, mais laissent à Neptune le soin de générer l'arête. IDs Si vous ne connaissez pas l'identifiant d'une arête, mais que vous connaissez le to
sommet from
etIDs, vous pouvez utiliser cette formulation pour surdimensionner une arête :
g.V('v-1') .outE('KNOWS') .where(inV().hasId('v-2')) .fold() .coalesce(unfold(), addE('KNOWS').from(V('v-1')) .to(V('v-2'))) .id()
Notez que l'étape du sommet dans la clause where()
doit être inV()
(ou outV()
si vous avez utilisé inE()
pour trouver l'arête), non pas otherV()
. N'utilisez pas otherV()
ici. Dans le cas contraire, la requête ne sera pas optimisée, et les performances en pâtiront. Par exemple, Neptune n'optimiserait pas la requête suivante :
// Unoptimized upsert, because of otherV() g.V('v-1') .outE('KNOWS') .where(otherV().hasId('v-2')) .fold() .coalesce(unfold(), addE('KNOWS').from(V('v-1')) .to(V('v-2'))) .id()
Si vous ne connaissez pas l'arête ou le sommet situé IDs à l'avant, vous pouvez l'inverser à l'aide des propriétés du sommet :
g.V() .hasLabel('Person') .has('name', 'person-1') .outE('LIVES_IN') .where(inV().hasLabel('City').has('name', 'city-1')) .fold() .coalesce(unfold(), addE('LIVES_IN').from(V().hasLabel('Person') .has('name', 'person-1')) .to(V().hasLabel('City') .has('name', 'city-1'))) .id()
Comme pour les points ascendants, il est préférable d'utiliser des éléments ascendants basés sur un identifiant d'arête ou from
un to
sommetIDs, plutôt que des éléments ascendants basés sur des propriétés, afin que Neptune puisse optimiser pleinement l'insertion.
Vérification de l'existence de sommets from
et to
Notez la structure des étapes qui créent une arête : addE().from().to()
. Cette structure garantit que la requête vérifie l'existence à la fois du sommet from
et du sommet to
. S'ils n'existent pas, la requête renvoie le message d'erreur suivant :
{ "detailedMessage": "Encountered a traverser that does not map to a value for child
...
"code": "IllegalArgumentException", "requestId": "..." }
S'il est possible que le sommet from
ou to
n'existe pas, vous devez essayer de les placer par upsert avant de réaliser l'upsert de l'arête qui les sépare. Consultez Combinaison d'upserts de sommets et d'arêtes.
Une autre structure permet de créer une arête que vous ne devez pas utiliser : V().addE().to()
. Elle ajoute une arête uniquement si le sommet from
existe. Si le sommet to
n'existe pas, la requête génère une erreur, comme décrit précédemment, mais si le sommet from
n'existe pas, l'upsert de l'arête échoue en arrière-plan, sans générer d'erreur. Par exemple, si le sommet from
n'existe pas, l'upsert suivant a lieu sans effectuer l'upsert d'une arête :
// Will not insert edge if from vertex does not exist g.V('v-1') .outE('KNOWS') .where(inV().hasId('v-2')) .fold() .coalesce(unfold(), V('v-1').addE('KNOWS') .to(V('v-2'))) .id()
Enchaînement d'upserts d'arêtes
Si vous souhaitez enchaîner des points d'arête pour créer une demande par lots, vous devez commencer chaque insertion par une recherche de sommet, même si vous connaissez déjà l'arête. IDs
Si vous connaissez déjà les IDs arêtes que vous souhaitez insérer, ainsi que les IDs to
sommets from
et, vous pouvez utiliser cette formulation :
g.V('v-1') .outE('KNOWS') .hasId('e-1') .fold() .coalesce(unfold(), V('v-1').addE('KNOWS') .to(V('v-2')) .property(id, 'e-1')) .V('v-3') .outE('KNOWS') .hasId('e-2').fold() .coalesce(unfold(), V('v-3').addE('KNOWS') .to(V('v-4')) .property(id, 'e-2')) .V('v-5') .outE('KNOWS') .hasId('e-3') .fold() .coalesce(unfold(), V('v-5').addE('KNOWS') .to(V('v-6')) .property(id, 'e-3')) .id()
Le scénario d'inversion d'arêtes par lots le plus courant est peut-être que vous connaissez le to
sommet from
etIDs, mais que vous ne connaissez pas les IDs arêtes que vous souhaitez inverser. Dans ce cas, utilisez la formulation suivante :
g.V('v-1') .outE('KNOWS') .where(inV().hasId('v-2')) .fold() .coalesce(unfold(), V('v-1').addE('KNOWS') .to(V('v-2'))) .V('v-3') .outE('KNOWS') .where(inV().hasId('v-4')) .fold() .coalesce(unfold(), V('v-3').addE('KNOWS') .to(V('v-4'))) .V('v-5') .outE('KNOWS') .where(inV().hasId('v-6')) .fold() .coalesce(unfold(), V('v-5').addE('KNOWS').to(V('v-6'))) .id()
Si vous connaissez IDs les arêtes que vous souhaitez insérer, mais que vous ne connaissez pas les IDs to
sommets from
et les sommets (c'est inhabituel), vous pouvez utiliser cette formulation :
g.V() .hasLabel('Person') .has('email', 'person-1@example.org') .outE('KNOWS') .hasId('e-1') .fold() .coalesce(unfold(), V().hasLabel('Person') .has('email', 'person-1@example.org') .addE('KNOWS') .to(V().hasLabel('Person') .has('email', 'person-2@example.org')) .property(id, 'e-1')) .V() .hasLabel('Person') .has('email', 'person-3@example.org') .outE('KNOWS') .hasId('e-2') .fold() .coalesce(unfold(), V().hasLabel('Person') .has('email', 'person-3@example.org') .addE('KNOWS') .to(V().hasLabel('Person') .has('email', 'person-4@example.org')) .property(id, 'e-2')) .V() .hasLabel('Person') .has('email', 'person-5@example.org') .outE('KNOWS') .hasId('e-1') .fold() .coalesce(unfold(), V().hasLabel('Person') .has('email', 'person-5@example.org') .addE('KNOWS') .to(V().hasLabel('Person') .has('email', 'person-6@example.org')) .property(id, 'e-3')) .id()
Combinaison d'upserts de sommets et d'arêtes
Parfois, il peut être utile d'insérer par upsert à la fois les sommets et les arêtes qui les relient. Vous pouvez combiner les exemples de lots présentés ici. L'exemple suivant insère par upsert trois sommets et deux arêtes :
g.V('p-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-1') .property('email', 'person-1@example.org')) .V('p-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-2') .property('name', 'person-2@example.org')) .V('c-1') .fold() .coalesce(unfold(), addV('City').property(id, 'c-1') .property('name', 'city-1')) .V('p-1') .outE('LIVES_IN') .where(inV().hasId('c-1')) .fold() .coalesce(unfold(), V('p-1').addE('LIVES_IN') .to(V('c-1'))) .V('p-2') .outE('LIVES_IN') .where(inV().hasId('c-1')) .fold() .coalesce(unfold(), V('p-2').addE('LIVES_IN') .to(V('c-1'))) .id()
Combinaison d'upserts et d'insertions
Parfois, il peut être utile d'insérer par upsert à la fois les sommets et les arêtes qui les relient. Vous pouvez combiner les exemples de lots présentés ici. L'exemple suivant insère par upsert trois sommets et deux arêtes :
Les upserts traitent généralement un élément à la fois. Si vous vous en tenez aux modèles d'upsert présentés ici, chaque opération d'upsert émet un seul traverseur, ce qui entraîne l'exécution de l'opération suivante une seule fois.
Cependant, il peut arriver que vous souhaitiez combiner des upserts avec des insertions. Cela peut notamment être le cas si vous utilisez des arêtes pour représenter des instances d'actions ou d'événements. Une demande peut utiliser des upserts pour s'assurer que tous les sommets nécessaires existent, puis utiliser des insertions pour ajouter des arêtes. Avec les demandes de ce type, soyez attentif au nombre potentiel de traverseurs émis par chaque opération.
Prenons l'exemple suivant, qui combine des upserts et des insertions pour ajouter des arêtes représentant des événements dans le graphe :
// Fully optimized, but inserts too many edges g.V('p-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-1') .property('email', 'person-1@example.org')) .V('p-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-2') .property('name', 'person-2@example.org')) .V('p-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-3') .property('name', 'person-3@example.org')) .V('c-1') .fold() .coalesce(unfold(), addV('City').property(id, 'c-1') .property('name', 'city-1')) .V('p-1', 'p-2') .addE('FOLLOWED') .to(V('p-1')) .V('p-1', 'p-2', 'p-3') .addE('VISITED') .to(V('c-1')) .id()
La requête doit insérer 5 arêtes : 2 FOLLOWED arêtes et 3 VISITED arêtes. Cependant, la requête telle qu'elle est écrite insère 8 arêtes : 2 FOLLOWED et 6VISITED. La raison en est que l'opération qui insère les 2 FOLLOWED arêtes émet 2 traverseurs, ce qui entraîne l'exécution de deux fois de l'opération d'insertion suivante, qui insère 3 arêtes.
La solution consiste à ajouter une étape fold()
après chaque opération susceptible d'émettre plusieurs traverseurs :
g.V('p-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-1') .property('email', 'person-1@example.org')) .V('p-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-2'). .property('name', 'person-2@example.org')) .V('p-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'p-3'). .property('name', 'person-3@example.org')) .V('c-1') .fold(). .coalesce(unfold(), addV('City').property(id, 'c-1'). .property('name', 'city-1')) .V('p-1', 'p-2') .addE('FOLLOWED') .to(V('p-1')) .fold() .V('p-1', 'p-2', 'p-3') .addE('VISITED') .to(V('c-1')). .id()
Nous avons inséré ici une fold()
étape après l'opération qui insère FOLLOWED les arêtes. Il en résulte un seul traverseur, et l'opération suivante n'est donc exécutée qu'une seule fois.
L'inconvénient de cette approche est que la requête n'est plus entièrement optimisée, car fold()
n'est pas optimisé. L'opération d'insertion qui suit fold()
ne sera maintenant pas optimisée non plus.
Si vous devez utiliser fold()
pour réduire le nombre de traverseurs lors des étapes suivantes, essayez d'organiser les opérations de manière à ce que les moins coûteuses occupent la partie non optimisée de la requête.
Upserts qui modifient les sommets et les arêtes existants
Parfois, vous souhaitez créer un sommet ou une arête qui n'existe pas, puis y ajouter une propriété ou mettre à jour une propriété qui lui est associée, qu'il s'agisse d'un sommet ou d'une arête qui existe déjà ou pas encore.
Pour ajouter ou modifier une propriété, utilisez l'étape property()
. Utilisez cette étape en dehors de l'étape coalesce()
. Si vous essayez de modifier la propriété d'un sommet ou d'une arête qui existe déjà dans l'étape coalesce()
, il est possible que la requête ne soit pas optimisée par le moteur de requêtes Neptune.
La requête suivante ajoute ou met à jour une propriété de compteur sur chaque sommet faisant l'objet d'un upsert. Chaque étape property()
possède une cardinalité unique afin de garantir que les nouvelles valeurs remplacent toutes les valeurs existantes, plutôt que d'être ajoutées à un ensemble de valeurs existantes.
g.V('v-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-1') .property('email', 'person-1@example.org')) .property(single, 'counter', 1) .V('v-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-2') .property('email', 'person-2@example.org')) .property(single, 'counter', 2) .V('v-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-3') .property('email', 'person-3@example.org')) .property(single, 'counter', 3) .id()
Si vous avez une valeur de propriété, telle qu'une valeur d'horodatage lastUpdated
, qui s'applique à tous les éléments ajoutés par upsert, vous pouvez l'ajouter ou la mettre à jour à la fin de la requête :
g.V('v-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-1') .property('email', 'person-1@example.org')) .V('v-2'). .fold(). .coalesce(unfold(), addV('Person').property(id, 'v-2') .property('email', 'person-2@example.org')) .V('v-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-3') .property('email', 'person-3@example.org')) .V('v-1', 'v-2', 'v-3') .property(single, 'lastUpdated', datetime('2020-02-08')) .id()
Si d'autres conditions déterminent si un sommet ou une arête doit être encore modifié, vous pouvez utiliser une étape has()
pour filtrer les éléments auxquels une modification sera appliquée. L'exemple suivant utilise une étape has()
pour filtrer les sommets ajoutés par upsert en fonction de la valeur de leur propriété version
. La requête fait ainsi passer à 3 la version
de tout sommet dont l'élément version
est inférieur à 3 :
g.V('v-1') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-1') .property('email', 'person-1@example.org') .property('version', 3)) .V('v-2') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-2') .property('email', 'person-2@example.org') .property('version', 3)) .V('v-3') .fold() .coalesce(unfold(), addV('Person').property(id, 'v-3') .property('email', 'person-3@example.org') .property('version', 3)) .V('v-1', 'v-2', 'v-3') .has('version', lt(3)) .property(single, 'version', 3) .id()