Comandar una flota
Un lote de comandos envía un solo comando a muchos dispositivos como una única operación registrada. Puedes nombrar los dispositivos o dejar que la plataforma los resuelva a partir de un grupo de entidades. Lo que recibes de vuelta es un registro persistente de lo que la plataforma intentó hacer: a cuántos dispositivos resolvió el objetivo, cuántos se encolaron realmente y cuáles fueron rechazados y por qué.
Cada dispositivo se sigue tratando exactamente igual que con un comando individual. Su comando se valida contra el contrato de capacidades de ese dispositivo, se retiene si el dispositivo está ausente, se sigue por el mismo ciclo de vida y vence con el mismo TTL. Lee primero Enviar un comando; esta guía solo cubre lo que cambia cuando el objetivo es una flota.
Un bucle de llamadas a createCommand puede comandar los mismos dispositivos. Lo que no puede hacer
es:
- dejar un registro de lo que se intentó;
- fijar la membresía del grupo, para que una edición del selector a mitad del bucle no cambie el objetivo;
- anularse como una sola operación.
Los lotes viven en el endpoint de command-delivery,
https://<tu-host>/api/command-delivery/graphql, y usan un token de acceso de inquilino. Disparar y
cancelar un lote requieren command:write. Leer los registros de lote requiere command:read.
device:readResolver un grupo hasta sus miembros lee el registro de dispositivos bajo la propia identidad de la
plataforma, y la respuesta te llega a ti: la lista de rechazos nombra tokens de dispositivo, y
resolved revela el tamaño del grupo. Por eso apuntar a un grupo, leer el registro de un lote
dirigido a un grupo y cancelarlo requieren cada uno device:read además de la autoridad de
comandos. Nombrar dispositivos explícitamente solo necesita la autoridad de comandos, porque quien lo
hace ya los conoce.
Elige el objetivo: dispositivos o un grupo
deviceTokens y groupToken son alternativas, y debes suministrar exactamente uno. Ambos o
ninguno se rechaza con BATCH_TARGET_AMBIGUOUS en lugar de resolverse por una regla de precedencia,
porque quien envió ambos no sabe qué flota acaba de actuar.
Nombrar dispositivos
- Puedes nombrar como máximo 10 000 tokens en una petición. Más es
BATCH_TOO_LARGE, y tienes que dividir la operación. - El orden importa. Un lote admitido parcialmente admite los dispositivos en el orden que diste, así que pon primero los que más te importan.
- Un token nombrado dos veces se cuenta una sola vez.
Nombrar un grupo
- El grupo debe agrupar dispositivos.
- Un grupo dinámico debe estar publicado. Un lote resuelve el selector publicado, nunca el borrador, porque una actuación sobre una flota no debe seguir lo que alguien tecleó por última vez en el editor.
- Pasa
groupVersionpara fijar una versión congelada concreta, u omítelo para usar la publicada activa. Nombrar una versión para un grupo estático se rechaza en lugar de ignorarse, igual que nombrar una sin grupo alguno. - Un grupo que resuelve a más de 10 000 dispositivos es
BATCH_TOO_LARGE. La plataforma lo rechaza en lugar de comandar los primeros 10 000 e informar éxito.
El registro guarda la versión del grupo contra la que se resolvió el conjunto objetivo. Así, una auditoría puede responder qué significaba el grupo cuando se disparó el lote, incluso después de que alguien edite el selector. La versión guardada es nula para un grupo estático, que nunca se versiona, y para un lote por lista de dispositivos.
Editar un grupo dinámico después de disparar un lote no cambia nada de lo que ya salió. Consulta Facetas y grupos dinámicos.
Decide qué significa una difusión parcial
En una flota real, algunos dispositivos no podrán recibir el comando: uno no está en el registro, el
perfil de otro no declara el comando, un tercero no cabe bajo el techo del inquilino. allowPartial
dice qué ocurre entonces:
allowPartial | Si algún dispositivo no puede recibir el comando |
|---|---|
false | El lote entero se rechaza y no se crea nada, ni siquiera el registro del lote: no ocurrió nada, así que no hay nada que registrar. El rechazo nombra los dispositivos responsables. |
true | Mejor esfuerzo. Los dispositivos que pueden recibir el comando lo reciben. El resto no obtiene fila de comando alguna y aparece en la lista de rechazos del registro. |
La bandera tiene un solo significado para todos los motivos de rechazo. No es una tolerancia solo para problemas de capacidad: activarla también acepta que un dispositivo cuyo perfil rechaza el comando quede fuera en silencio.
allowPartial no tiene valor por defecto — tienes que enviarloEs un booleano no nulo sin valor predeterminado, así que una petición que lo omite es inválida. Este campo decide si una actuación física puede alcanzar a una parte de la flota pero no a toda, así que declaras tu intención en lugar de heredarla de un esquema que quizá no has leído.
Dispara el lote
mutation {
createCommandBatch(request: {
token: "nightly-reboot-2026-08-14",
name: "reboot", # el commandKey, no el nombre visible
payload: "{\"delaySeconds\":5}",
groupToken: "pumps-arid-us",
allowPartial: true
}) {
batch {
token targetKind groupToken groupVersion
resolved accepted
refusals { deviceToken code reason }
refusalCounts { code count }
}
rejection {
code reason resolved
refusals { deviceToken code reason }
refusalCounts { code count }
}
}
}
name es el commandKey del vocabulario del dispositivo, exactamente igual que en createCommand;
consulta commandKey es el identificador.
Cada dispositivo objetivo recibe la misma clave y la misma carga útil, que es lo que hace asequible
validar una escritura de flota en primer lugar.
expiresAt fija el TTL de todos los comandos que crea el lote. Sin él, se aplica a todos el valor
predeterminado de la plataforma: siete días. metadata se registra en el registro del lote; no se
copia a los comandos individuales.
rejection, no solo si hay errorescreateCommandBatch devuelve exactamente uno de batch o rejection. Un lote rechazado es una
respuesta GraphQL exitosa que lleva un rejection, no un error de GraphQL. Un error de GraphQL en
lugar de cualquiera de los dos significa que el lote no se pudo decidir en absoluto: no se creó
nada, el token queda sin gastar y puedes reintentar la petición.
El token es una clave de idempotencia
token lo eliges tú, y después nombra la operación completa. Volver a emitir un token que ya nombra
un lote devuelve ese lote, sin cambios. Nunca se rellena con más dispositivos, porque admitir más
bajo el mismo token haría de accepted una cifra móvil y del registro algo no auditable.
Por eso un reintento tras un fallo de red es seguro. Eso importa aquí más que para un comando individual: la petición de la que dudas puede haber reiniciado diez mil bombas.
No existe un rechazo TOKEN_IN_USE para un lote. Un token ya en uso no es un conflicto; es una
repetición.
Cuando se rechaza un lote
Ramifica según code, nunca según reason. La razón es prosa para una persona, y su redacción
puede cambiar.
code | Significado | ¿Reintentar? |
|---|---|---|
BATCH_PARTIAL_REFUSED | Al menos un dispositivo no puede recibir el comando y allowPartial está desactivado. No se creó nada. | Lee los rechazos: el código propio de cada dispositivo dice si seguirá siendo rechazado la próxima vez |
HELD_CEILING_EXCEEDED | El lote necesita más espacio del que el inquilino tiene para comandos no entregados. | Sí; se libera conforme se drena la acumulación |
BATCH_TARGET_AMBIGUOUS | Se dieron ambos objetivos, o ninguno, o un groupVersion sin grupo. | No |
BATCH_TOO_LARGE | Más dispositivos de los que un lote puede comandar, nombrados explícitamente o resueltos del grupo. | No; divide la operación o acota el grupo |
BATCH_GROUP_UNUSABLE | El grupo no existe, agrupa algo que no son dispositivos, nunca se publicó o la versión nombrada no existe (o se nombró una versión para un grupo estático). El código propio del servicio de grupos viaja en la razón. | No |
PAYLOAD_NOT_JSON / METADATA_NOT_JSON | La cadena no es JSON válido. | No |
EXPIRES_AT_INVALID | expiresAt no es una marca de tiempo RFC3339. | No |
La lista es abierta. Trata un código que no reconozcas como un rechazo que no puedes clasificar, nunca como un éxito.
BATCH_PARTIAL_REFUSED es el único código que no puede responder por sí solo a la pregunta del
reintento, y por eso los dispositivos culpables viajan con él. Un dispositivo que falta en el
vocabulario de comandos necesita un cambio de perfil, mientras que uno rechazado por falta de espacio
tendrá éxito cuando se drene la acumulación. Un solo código no puede decir ambas cosas, así que no
dice ninguna y delega en la lista.
La lista refusals del rechazo se rellena para exactamente un código, BATCH_PARTIAL_REFUSED, y
está vacía para todos los demás, incluido HELD_CEILING_EXCEEDED. La asimetría es deliberada:
- Un rechazo parcial lo causan dispositivos concretos, así que nombrarlos te evita bisecar una flota a mano.
- Un rechazo por techo lo causa la acumulación del inquilino. Ningún dispositivo de la petición tiene
la culpa, y nada cambiaría si intercambiaras sus miembros; una lista ahí invitaría a arreglar
dispositivos que están bien. Qué hacer al respecto está en
reason.
resolved puede ser nulo — y nulo no es ceronull significa que nunca se estableció un conjunto objetivo: el rechazo ocurrió antes de resolver
nada. 0 significa un objetivo que realmente resolvió a ningún dispositivo, lo cual es un lote real
y exitoso, no un rechazo.
Lee el registro
query {
commandBatchesByToken(tokens: ["nightly-reboot-2026-08-14"]) {
token name targetKind groupToken groupVersion allowPartial
resolved accepted
refusals { deviceToken code reason }
refusalCounts { code count }
}
}
También puedes buscar por clave de comando, por grupo o por targetKind (DEVICE_LIST o GROUP):
query {
commandBatches(criteria: {
pageNumber: 1, pageSize: 25,
groupToken: "pumps-arid-us"
}) {
results { token name resolved accepted createdAt }
pagination { totalRecords }
}
}
resolved y accepted describen el momento del disparo, no el presenteSon hechos almacenados, no cuentas en vivo. Para el estado de entrega en presente, busca los comandos (consulta Sigue los comandos que creó).
Las filas de comando no son inmortales: pueden borrarse de forma lógica, o eliminarse junto con un
inquilino. Derivar accepted de una consulta en vivo dejaría que bajara de la verdad del momento de
creación sin ningún rechazo que explicara la diferencia, y por eso el registro lo almacena.
refusals es una muestra; refusalCounts es completo
refusals conserva como máximo 100 entradas por código, así que un lote disparado contra un
grupo grande rechaza más dispositivos de los que el registro nombra. refusalCounts es el total
completo por código y nunca se trunca, lo que hace que el registro se audite solo:
resolved = accepted + la suma de refusalCounts
Esa identidad siempre se cumple. La muestra puede quedarse corta; compara su longitud con los recuentos para saber si se acotó.
El code por dispositivo usa el mismo vocabulario abierto que el rechazo de una admisión individual:
DEVICE_NOT_FOUNDCOMMAND_NOT_IN_VOCABULARYPAYLOAD_SCHEMA_VIOLATION, transmitido desde el perfil del dispositivoHELD_CEILING_EXCEEDED, para los dispositivos que no cupieron en el espacio restante del inquilino
Consulta Cuando se rechaza una admisión para saber qué significa cada uno.
Sigue los comandos que creó
El registro del lote deliberadamente no se mueve. Para preguntar qué está haciendo la escritura de
flota («de los 5000 en cola, ¿cuántos han salido?»), busca los comandos con batchToken:
query {
commands(criteria: {
pageNumber: 1, pageSize: 50,
batchToken: "nightly-reboot-2026-08-14",
statuses: ["QUEUED", "HELD", "PARKED"]
}) {
results { token deviceToken status queuedTime }
pagination { totalRecords }
}
}
La plataforma genera los tokens de los comandos individuales; elegiste el token del lote, no los
suyos. Por eso los encuentras con batchToken en lugar de construir un token tú mismo.
El vínculo también funciona en sentido contrario. Una fila de comando lleva batchToken como campo
legible, así que quien tenga un solo comando (del historial de un dispositivo, o de una respuesta que
llegó sin contexto) puede preguntar qué escritura de flota lo creó:
query {
commands(criteria: { pageNumber: 1, pageSize: 20, deviceToken: "gw-4471" }) {
results { token name status batchToken }
}
}
batchToken es nulo para un comando emitido de uno en uno, y es el único campo que distingue los dos
casos. Un lote envía la misma clave de comando, con la misma carga útil, que el dispositivo habría
recibido individualmente, así que nada más en la fila difiere.
Esta dirección importa porque una sola fila de comando no puede mostrarte la parte interesante de una escritura de flota: los dispositivos que rechazó. No se les dio ningún comando, así que no aparecen en el historial de ningún dispositivo. Solo el registro del lote sabe que fueron seleccionados.
Cancela un lote
mutation {
cancelCommandBatch(token: "nightly-reboot-2026-08-14") {
cancelled
alreadySent
alreadyFinished
matched
}
}
| Campo | Significado |
|---|---|
cancelled | La cifra autoritativa. Ese número de comandos pasó de QUEUED, HELD o PARKED a CANCELLED y no se entregará. |
alreadySent | Comandos ya despachados a sus dispositivos. Esos dispositivos actuarán igualmente sobre ellos. |
alreadyFinished | Comandos que ya habían alcanzado un estado terminal: SUCCESSFUL, FAILED, TIMEOUT, EXPIRED o CANCELLED. |
matched | Cuántas de las filas de comando del lote estaban vivas en ese momento (consulta más abajo). |
Este es el mismo freno que cancelCommand aplica a un comando individual: ambos cancelan QUEUED,
HELD y PARKED, y ninguno toca SENT. Por qué SENT es la línea se explica en Cancelar un
lote.
Cancelar nunca se rechaza. Un freno que se negara a actuar porque parte de la flota ya se había
movido dejaría comandado al resto de la flota, que es el peor desenlace posible. Así que un lote cuyos
comandos ya se enviaron todos es una llamada exitosa que informa cancelled: 0. Lee las cifras en
lugar de suponer que la llamada no hizo nada. Un token que no corresponde a ningún lote sí es un
error de GraphQL.
Cancelar necesita command:write, y un lote dirigido a un grupo necesita además device:read, por
la misma razón que dispararlo.
El propio registro del lote queda sellado con cancelledAt y cancelledCount, de modo que la
cancelación es tan auditable como lo fue la difusión. cancelledCount es lo que alcanzó esa llamada.
El sello es de primero que llega: una segunda cancelación no sobrescribe lo que registró la
primera.
matched y las demás cifras
matched es una cuenta en vivo, y las cuatro cifras no tienen por qué cuadrar.
matched cuenta las filas de comando del lote que estaban vivas en ese momento, no cuántas creó el
lote. Las filas eliminadas desde entonces (por una purga, o por un borrado) no están ahí para
coincidir. Así que un matched por debajo del accepted del lote es normal y no dice nada sobre la
cancelación.
matched también puede superar a cancelled + alreadySent + alreadyFinished. Un comando cuya
entrega falló puede volver a la cola entre la cancelación y el recuento. Ese comando queda fuera de
los tres grupos en lugar de contarse en alreadyFinished, porque informar de un comando vivo como si
hubiera terminado es justo lo que este vocabulario existe para evitar. Cancela otra vez y quedará
atrapado.
Lo que hace que esto sea raro es el propio sello de cancelación: una vez confirmada una cancelación, una entrega fallida retira el comando en lugar de devolverlo a la cola. La excepción es un comando liberado en el mismo instante que la cancelación, que sigue vivo dentro de un lote ya anulado. El remedio es cancelar otra vez, no esperar.
Límites que un lote comparte con los comandos individuales
Un lote queda acotado exactamente por los mismos límites que alcanzaría un bucle de comandos individuales. Se admite contra el techo del inquilino para comandos no entregados, menos la parte reservada para la entrega de la propia plataforma. No hay forma de eludir ninguno de los dos, ni ventaja de una forma sobre la otra.
En la práctica, cuando el inquilino está cerca de su techo:
- Con
allowPartialactivado, una difusión grande puede admitirse solo parcialmente. Los dispositivos que no cupieron vuelven como rechazosHELD_CEILING_EXCEEDEDpor dispositivo. - Con él desactivado, el lote entero se rechaza con ese código y no se crea nada.
En ambos casos es una condición temporal y no un defecto en la petición. Una vez drenada la acumulación, un token nuevo comandará al resto. Repetir el token original no puede, porque una repetición devuelve el lote que ya tienes.