Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support device deletion #816

Merged
merged 17 commits into from
Oct 6, 2023
Merged

Conversation

Annopaolo
Copy link
Collaborator

@Annopaolo Annopaolo commented Jul 6, 2023

A device may be deleted with a DELETE request on the /:realm_name/devices/:device_id endpoint of Realm Management API. Deletion is then performed asynchronously.
When a device is being deleted, it is also disconnected from Astarte. Already stored data may still be available until deletion is complete. All inflight data coming from the device are discarded. A synchronization mechanism makes sure that data deletion is performed only when all inflight messages have been handled (i.e. discarded) and the device is not connected to Astarte.

In detail, the deletion procedure follows the steps outlined below:

  • RM API forwards the request via RPC to RM (see also Add support for device deletion astarte_rpc#63)
  • RM inserts the device_id in the deletion_in_progress table
  • RM answers to the RPC and RM API answers with 204 ok to the client
  • Each DataUpdater process checks periodically if its device is being deleted. If that's the case:
  • In order to delete offline (or old) devices, too, the DUP RemovalScheduler periodically checks the deletion_in_progress table and starts the DataUpdater process of offline (or old) devices with a :start_device_deletion message.
  • When the broker receives the RPC from DUP, it forcefully disconnects the device and will not allow any reconnection until that device_id is in the deletion_in_progress table. Then the broker writes the vmq_ack in the deletion_in_progress table and sends an empty message on the "/internal" topic related to the device to signal that messages from the device are finished.
  • When the DataUpdater process handles the empty termination message, it writes the dup_end_ack in the deletion_in_progress table and terminates itself
  • RM periodically checks the deletion_in_progress table and spawns a task for deleting data from devices whose deletion has been acked both by the broker and the DataUpdater.

Related to astarte-platform/astarte_vmq_plugin#75 and based, based on astarte-platform/astarte_rpc#63.
Closes #392.

@Annopaolo Annopaolo added enhancement New feature or request main feature This issue or pull request is about a main scheduled feature API This issue or pull request is about API (e.g. unclear API, new API, API change, deprecation) app:data_updater_plant This issue or pull request is about astarte_data_updater_plant application app:realm_management This issue or pull request is about astarte_realm_management application app:realm_management_api This issue or pull request is about astarte_realm_management_api application labels Jul 6, 2023
@Annopaolo Annopaolo added this to the v1.2 milestone Jul 6, 2023
@Annopaolo Annopaolo requested review from bettio and rbino July 6, 2023 12:48
@Annopaolo
Copy link
Collaborator Author

Do not merge until astarte-platform/astarte_vmq_plugin#75 and astarte-platform/astarte_rpc#63 are merged.

@codecov
Copy link

codecov bot commented Jul 7, 2023

Codecov Report

Merging #816 (7c30db5) into master (e0fd426) will increase coverage by 0.49%.
Report is 14 commits behind head on master.
The diff coverage is 71.23%.

❗ Current head 7c30db5 differs from pull request most recent head a47a2a8. Consider uploading reports for the commit a47a2a8 to get more accurate results

@@            Coverage Diff             @@
##           master     #816      +/-   ##
==========================================
+ Coverage   67.42%   67.92%   +0.49%     
==========================================
  Files         264      269       +5     
  Lines        6429     6781     +352     
==========================================
+ Hits         4335     4606     +271     
- Misses       2094     2175      +81     
Files Coverage Δ
...astarte_data_updater_plant/consumers_supervisor.ex 100.00% <ø> (ø)
...ant/lib/astarte_data_updater_plant/data_updater.ex 94.20% <100.00%> (+0.26%) ⬆️
.../astarte_data_updater_plant/data_updater/server.ex 87.27% <100.00%> (+6.88%) ⬆️
...t/lib/astarte_data_updater_plant/rpc/vmq_plugin.ex 29.41% <100.00%> (+29.41%) ⬆️
...e_realm_management/lib/astarte_realm_management.ex 100.00% <100.00%> (ø)
...ib/astarte_realm_management_api/devices/devices.ex 100.00% <100.00%> (ø)
...tarte_realm_management_api/rpc/realm_management.ex 56.81% <100.00%> (ø)
...anagement_api_web/controllers/device_controller.ex 100.00% <100.00%> (ø)
...api/lib/astarte_realm_management_api_web/router.ex 77.77% <100.00%> (+2.77%) ⬆️
..._api/test/support/astarte_realm_management_mock.ex 94.11% <100.00%> (+0.36%) ⬆️
... and 14 more

... and 6 files with indirect coverage changes

@Annopaolo Annopaolo force-pushed the delete-device branch 3 times, most recently from 1ea2a42 to f914059 Compare August 1, 2023 08:20
@Annopaolo Annopaolo marked this pull request as ready for review August 4, 2023 12:35
Copy link
Contributor

@noaccOS noaccOS left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have reported a couple instances where the copyright signature was incorrect, mainly in new files, but in most updated files the copyright wasn't updated.

There is a lot of formatting changes, mainly in 03d5cb2 and 445711b, which make a lot of unnecessary noise in the diff. Consider at least adding a separate commit for the formatting before the commit with actual changes.

I don't know how much/if it is a problem but 445711b isn't formatted with mix format

Comment on lines 942 to 943
Logger.warn("Database error while retrieving property: #{inspect(reason)}.")

{:error, :database_error}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this line was a mistake as the previous entry in the else block doesn't have an empty line between the Logger.warn and the returned tuple

Comment on lines 960 to 981
with {:ok, _} <- DatabaseQuery.call(client, query) do
:ok
else
%{acc: _, msg: error_message} ->
Logger.warn("Database error when writing end ack device deletion: #{error_message}.")

{:error, :database_error}

{:error, reason} ->
Logger.warn("Device deletion end ack failed with reason: #{inspect(reason)}.")

{:error, :database_error}
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
with {:ok, _} <- DatabaseQuery.call(client, query) do
:ok
else
%{acc: _, msg: error_message} ->
Logger.warn("Database error when writing end ack device deletion: #{error_message}.")
{:error, :database_error}
{:error, reason} ->
Logger.warn("Device deletion end ack failed with reason: #{inspect(reason)}.")
{:error, :database_error}
end
case DatabaseQuery.call(client, query) do
{:ok, _} ->
:ok
%{acc: _, msg: error_message} ->
Logger.warn("Database error when writing end ack device deletion: #{error_message}.")
{:error, :database_error}
{:error, reason} ->
Logger.warn("Device deletion end ack failed with reason: #{inspect(reason)}.")
{:error, :database_error}
end

is there a reason we're not using Xandra for new queries?
retrieve_realms!/0 and retrieve_devices_waiting_to_start_deletion!/1 in this file are already using it.
I get that ack_start_device_deletion/2 and check_device_deletion_in_progress/2 are used in a context with a db_client already defined, but this is not the case for ack_end_device_deletion/2

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++, let's use Xandra for new queries

Comment on lines 987 to 1018
with {:ok, _} <- DatabaseQuery.call(client, query) do
:ok
else
%{acc: _, msg: error_message} ->
Logger.warn("Database error when writing start ack device deletion: #{error_message}.")

{:error, :database_error}

{:error, reason} ->
Logger.warn("Device deletion start ack failed with reason: #{inspect(reason)}.")

{:error, :database_error}
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as before

Comment on lines 1014 to 1032
with {:ok, result} <- DatabaseQuery.call(client, query),
deletion_row when is_list(deletion_row) <- DatabaseResult.head(result) do
{:ok, true}
else
:empty_dataset ->
{:ok, false}

%{acc: _, msg: error_message} ->
_ = Logger.warn("Database error: #{error_message}.", tag: "db_error")
{:error, :database_error}

{:error, reason} ->
_ =
Logger.warn("Database error, reason: #{inspect(reason)}.",
tag: "db_error"
)

{:error, :database_error}
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
with {:ok, result} <- DatabaseQuery.call(client, query),
deletion_row when is_list(deletion_row) <- DatabaseResult.head(result) do
{:ok, true}
else
:empty_dataset ->
{:ok, false}
%{acc: _, msg: error_message} ->
_ = Logger.warn("Database error: #{error_message}.", tag: "db_error")
{:error, :database_error}
{:error, reason} ->
_ =
Logger.warn("Database error, reason: #{inspect(reason)}.",
tag: "db_error"
)
{:error, :database_error}
end
case DatabaseQuery.call(client, query) do
{:ok, result} ->
result_not_empty? = DatabaseResult.head(result) != :empty_dataset
{:ok, result_not_empty?}
%{acc: _, msg: error_message} ->
_ = Logger.warn("Database error: #{error_message}.", tag: "db_error")
{:error, :database_error}
{:error, reason} ->
_ =
Logger.warn("Database error, reason: #{inspect(reason)}.",
tag: "db_error"
)
{:error, :database_error}
end

Comment on lines 1047 to 1056
with {:ok, result} <- DatabaseQuery.call(client, query),
deletion_row when is_list(deletion_row) <- DatabaseResult.head(result) do
{:ok, true}
else
:empty_dataset ->
{:ok, false}

%{acc: _, msg: error_message} ->
_ = Logger.warn("Database error: #{error_message}.", tag: "db_error")
{:error, :database_error}

{:error, reason} ->
_ =
Logger.warn("Database error, reason: #{inspect(reason)}.",
tag: "db_error"
)

{:error, :database_error}
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as before

#
# This file is part of Astarte.
#
# Copyright 20230 SECO Mind Srl
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Copyright 20230 SECO Mind Srl
# Copyright 2023 SECO Mind Srl

Comment on lines 37 to 46
setup_all do
with {:ok, client} <- DatabaseTestHelper.connect_to_test_database() do
DatabaseTestHelper.create_test_keyspace(client)
seed_device_data!()
end

on_exit(fn ->
with {:ok, client} <- DatabaseTestHelper.connect_to_test_database() do
DatabaseTestHelper.drop_test_keyspace(client)
end
end)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't really matter as we only have one test for now, but being assertive and failing in the setup makes the error message more readable as we only have one global database message for the file and not one for each test

Suggested change
setup_all do
with {:ok, client} <- DatabaseTestHelper.connect_to_test_database() do
DatabaseTestHelper.create_test_keyspace(client)
seed_device_data!()
end
on_exit(fn ->
with {:ok, client} <- DatabaseTestHelper.connect_to_test_database() do
DatabaseTestHelper.drop_test_keyspace(client)
end
end)
end
setup_all do
{:ok, client} = DatabaseTestHelper.connect_to_test_database()
DatabaseTestHelper.create_test_keyspace(client)
seed_device_data!()
on_exit(fn ->
{:ok, client} = DatabaseTestHelper.connect_to_test_database()
DatabaseTestHelper.drop_test_keyspace(client)
end)
end

Comment on lines 811 to 812
# my sanity is slowly degrading
aliaz = "ahahahah now I can write 'alias' without Elixir complaining"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about device_alias?

#
# This file is part of Astarte.
#
# Copyright 2018 Ispirata Srl
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Copyright 2018 Ispirata Srl
# Copyright 2023 SECO Mind Srl

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan to squash and merge this pr? If not, there are a few files in b663702 and 2c0bb89 which have been renamed mid-pr, and I think it would be better to avoid it


# TODO expose this via config
# 5 minutes
@reconciliation_timeout 300 * 1000
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:timer.minutes(5)

Comment on lines 960 to 981
with {:ok, _} <- DatabaseQuery.call(client, query) do
:ok
else
%{acc: _, msg: error_message} ->
Logger.warn("Database error when writing end ack device deletion: #{error_message}.")

{:error, :database_error}

{:error, reason} ->
Logger.warn("Device deletion end ack failed with reason: #{inspect(reason)}.")

{:error, :database_error}
end
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++, let's use Xandra for new queries

astarte_core, astarte_rpc, astarte_data_access

Signed-off-by: Arnaldo Cesco <[email protected]>
@Annopaolo Annopaolo force-pushed the delete-device branch 5 times, most recently from e9be707 to 2d2e97e Compare August 29, 2023 14:53
@Annopaolo Annopaolo requested review from rbino and noaccOS August 29, 2023 15:12
@Annopaolo
Copy link
Collaborator Author

I have reported a couple instances where the copyright signature was incorrect, mainly in new files, but in most updated files the copyright wasn't updated.

There is a lot of formatting changes, mainly in 03d5cb2 and 445711b, which make a lot of unnecessary noise in the diff. Consider at least adding a separate commit for the formatting before the commit with actual changes.

I don't know how much/if it is a problem but 445711b isn't formatted with mix format

Other than the inline suggestions, these have been tackled, too (updated copyright, removed unnecessary formatting changes). TY @noaccOS!

Copy link
Contributor

@noaccOS noaccOS left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

end

def init(_args) do
{:ok, %{}, {:continue, []}}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given handle_continue just sends a single message, handling this with handle_continue adds an unnecessary indirection

{:noreply, state}
end

defp start_device_deletion!() do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for parentheses for declaration with no arguments. Also, imho having a start_device_deletion! and a start_device_deletion which actually do two different things is actually quite confusing. Given start_device_deletion is 3 lines and it's only used once, I think you can just move its implementation in the anonymous function called by Enum.each

Comment on lines 78 to 84
Enum.flat_map(realms, fn %{"realm_name" => realm_name} ->
devices = Queries.retrieve_devices_waiting_to_start_deletion!(realm_name)
Enum.map(devices, &Map.put(&1, "realm_name", realm_name))
end)
|> Enum.filter(fn %{"realm_name" => realm_name, "device_id" => device_id} ->
should_handle_data_from_device?(realm_name, device_id)
end)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the ability of for-comprehension of doing nested loop and filtering this could be made more readable

for %{"realm_name" => realm_name} <- realms,
    %{"device_id" => device_id} <- Queries.retrieve_devices_waiting_to_start_deletion!(realm_name),
    encoded_device_id = Device.encode_device_id(device_id),
    should_handle_data_from_device?(realm_name, encoded_device_id) do
  %{realm_name: realm_name, device_id: encoded_device_id}
end

Moreover I switched the returned map to an atom-keyed map with only the fields you need. This makes the intent more clear by asserting that you're only using those two keys from the result, while if you take the result from the DB and do Map.put the reader is not sure if you're using the other fields or not (which you are not).
Also, I encode the device ID here so it doesn't have to be encoded twice in should_handle... and start_device_deletion. Feel free to change the key to encoded_device_id: if you think it's clearer.

Comment on lines 85 to 87
|> tap(fn devices ->
Logger.debug("Retrieved devices to delete #{inspect(devices)}", tag: "devices_to_delete")
end)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd move this inside the loop and log device-per-device, so you can add realm and device_id as log metadata, making it more discoverable with log parsing (while just dumping the list of maps is not parsable by Loki and co)

@@ -98,6 +101,11 @@ defmodule Astarte.DataUpdaterPlant.DataUpdater.Impl do
:ok
end

def handle_connection(%State{drop_messages: true} = state, _, message_id, _) do
MessageTracker.ack_delivery(state.message_tracker, message_id)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given we're throwing these away, I'd say that using discard is more semantically accurate (here and in all similar clauses)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Annopaolo I think this wasn't fixed

Comment on lines +948 to +951
Xandra.Cluster.run(
:xandra,
&do_ack_end_device_deletion(&1, realm_name, device_id)
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should validate the realm name like we do in Astarte Data Access, otherwise you're interpolating a string in a query without checking it

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be handled by #838 once rebased on this PR.

vmq_ack boolean,
dup_start_ack boolean,
dup_end_ack boolean,
PRIMARY KEY ((device_id))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for the double parentheses

defp do_check_device_exists(conn, realm_name, device_id) do
statement = """
SELECT COUNT (*)
FROM #{realm_name}.devices
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, realm_name should be checked.

defp filter_object_interfaces(realm_name, introspection) do
Enum.reduce_while(introspection, {:ok, []}, fn {interface_name, interface_major}, acc ->
case Queries.retrieve_interface_descriptor(realm_name, interface_name, interface_major) do
# TODO check
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check wat

statement = """
INSERT INTO #{realm_name}.individual_datastreams
(device_id, interface_id, endpoint_id, path, value_timestamp, reception_timestamp, reception_timestamp_submillis, integer_value)
VALUES (:device_id, :interface_id, :endpoint_id, :path, '2017-09-28 04:06+0000', '2017-09-28 05:06+0000', 0, 42);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass also the values you insert here as a parameter instead of hardcoding them, otherwise the known value magically appears in assertions in the test case and it's not clear where it is coming from.

assert :ok = Devices.delete_device(@realm, device_id)
end

test "install fails when the device does not exists" do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Install?

# See the License for the specific language governing permissions and
# limitations under the License.
#
defmodule Astarte.RealmManagement.DeviceRemover do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think this whole file is a leftover

_ = Logger.info("Starting to remove device #{encoded_device_id}", tag: "device_delete_start")

datastream_keys = Queries.retrieve_individual_datastreams_keys!(realm_name, device_id)
:ok = delete_individual_datastreams!(realm_name, datastream_keys)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching with :ok on bang functions is redundant and adds noise, I'd drop it

introspection = retrieve_device_introspection_map!(realm_name, device_id)
object_interfaces = filter_object_interfaces!(realm_name, introspection)
object_tables = Enum.map(object_interfaces, &object_interface_to_table_name/1)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes directly calling Enum functions and some other times defining private helpers to call them, I'd prefer a consistent behavior. I'd suggest moving the Enum.each here and leaving as helper only the function that unpacks the struct and uses that to create the arguments for the function call.
If you combine this with dropping the match on :ok, you can easily group functions in pipes which end up handling a specific side effect, e.g:

# Delete individual datastreams
Queries.retrieve_individual_datastreams_keys!(realm_name, device_id)
|> Enum.each(&delete_individual_datastreams_from_key!/1)

...
end)

defp delete_individual_datastreams_from_key!(key) do
  %{
    device_id: device_id,
    interface_id: interface_id,
    endpoint_id: endpoint_id,
    path: path
  } = key

  Queries.delete_individual_datastream_values!(
    realm_name,
    device_id,
    interface_id,
    endpoint_id,
    path
  )
end

@Annopaolo Annopaolo force-pushed the delete-device branch 3 times, most recently from 7defc29 to a3d3018 Compare September 8, 2023 10:36
@Annopaolo Annopaolo requested a review from rbino September 8, 2023 11:19
Signed-off-by: Arnaldo Cesco <[email protected]>
Signed-off-by: Arnaldo Cesco <[email protected]>
Device deletion begins when a device_id is put in the
`deletion_in_progress` table.
The actual deletion of data is started only when it has been
acknowledged by both DUP (two times) and the broker (once).
The DeviceRemoverSupervisor supervises DeviceRemover tasks.
Since deletion is asynchronous, it may happen that the
RM service goes down during deletion, or that a temporary filure
brings down the deletion service. Therefore, a supervision tree
is put in place.
Data deletion is started (or resumed, if needed) by the
DeviceRemoval.Scheduler and it is carried out by the
DeviceRemoval.DeviceRemover Task.
The DeviceRemoverSupervisor supervises DeviceRemovers.
astarte_core and astarte_rpc

Signed-off-by: Arnaldo Cesco <[email protected]>
Allow to delete a device with an HTTP request
using the DELETE method on the `/{realm_name}/devices/{device_id}`
endpoint.

Signed-off-by: Arnaldo Cesco <[email protected]>
astarte_core, astarte_rpc, astarte-data_access

Signed-off-by: Arnaldo Cesco <[email protected]>
Add a migration for creating the `deletion_in_progress`
table.

Signed-off-by: Arnaldo Cesco <[email protected]>
Track devices currently under deletion in the
`deletion_in_progress` table. It also contains
fields to track the acknowledgement of the
deletion by DUP and the broker.

Signed-off-by: Arnaldo Cesco <[email protected]>
Bump cyanide, astarte_core, astarte_rpc, astarte-data_access.
Contextually, remove some outdated macros.

Signed-off-by: Arnaldo Cesco <[email protected]>
A DataUpdater process checks periodically if the related device
is being deleted. If that is the case, the process updates its
`deletion_in_progress` state subfield to `true` and writes
to db that deletion has started (`dup_start_ack` field).
From now on, all received messages will be acked but ignored,
in order to end consuming all inflight messages.
The DataUpdater also performs an RPC to the broker to
forcefully disconnect the device and prevent it from
reconnecting.
The last message to be processed by the DataUpdater
is by construction received on the internal `"/f"` path
(see astarte-platform/astarte_vmq_plugin#75).
After that, the DUP deletion end ack is written to the database
and the DataUpdater process is terminated.

In order to allow deletion of offline (or old) devices,
the RemovalScheduler periodically checks the
`deletion_in_progress` table and makes the device process
start just to perform the deletion check.

Signed-off-by: Arnaldo Cesco <[email protected]>
Add the Mox library in order to mock the
Astarte RPC client.

Signed-off-by: Arnaldo Cesco <[email protected]>
Now the Astarte VerneMQ Plugin needs access to the
astarte database. Add the relevant env var in the
docker-compose.yml file to allow it to connect.

Signed-off-by: Arnaldo Cesco <[email protected]>
@rbino rbino merged commit 01c8510 into astarte-platform:master Oct 6, 2023
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
API This issue or pull request is about API (e.g. unclear API, new API, API change, deprecation) app:data_updater_plant This issue or pull request is about astarte_data_updater_plant application app:realm_management_api This issue or pull request is about astarte_realm_management_api application app:realm_management This issue or pull request is about astarte_realm_management application enhancement New feature or request main feature This issue or pull request is about a main scheduled feature
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

Devices deletion
3 participants