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

feat: new page in snuba admin to do deletes #6157

Merged
merged 13 commits into from
Jul 31, 2024
14 changes: 14 additions & 0 deletions snuba/admin/static/api_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ interface Client {
) => Promise<ReplayInstruction | null>;
clearDlqInstruction: () => Promise<ReplayInstruction | null>;
getAdminRegions: () => Promise<string[]>;
runLightweightDelete: (storage_name: string, column_conditions: object) => Promise<Response>
}

function Client() {
Expand Down Expand Up @@ -473,6 +474,19 @@ function Client() {
method: "DELETE",
}).then((resp) => resp.json());
},
runLightweightDelete: (storage_name: string, column_conditions: object) => {
const url = baseUrl + "delete"
return fetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
storage: storage_name,
columns: column_conditions
})
})
},
};
}

Expand Down
6 changes: 6 additions & 0 deletions snuba/admin/static/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import CardinalityAnalyzer from "SnubaAdmin/cardinality_analyzer";
import ProductionQueries from "SnubaAdmin/production_queries";
import SnubaExplain from "SnubaAdmin/snuba_explain";
import Welcome from "SnubaAdmin/welcome";
import DeleteTool from "SnubaAdmin/delete_tool";

const NAV_ITEMS = [
{ id: "overview", display: "🤿 Snuba Admin", component: Welcome },
Expand Down Expand Up @@ -82,6 +83,11 @@ const NAV_ITEMS = [
display: "🔦 Production Queries",
component: ProductionQueries,
},
{
id: "delete-tool",
display: "🗑️ Delete Tool",
component: DeleteTool,
},
];

export { NAV_ITEMS };
63 changes: 63 additions & 0 deletions snuba/admin/static/delete_tool/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Client from "SnubaAdmin/api_client";
import React, { useEffect, useState } from "react"

function DeleteTool(props: { api: Client }) {
const [storageName, setStorageName] = useState('')
const [columnConditions, setColumnConditions] = useState('')
const [result, setResult] = useState('')
const [showHelpMessage, setShowHelpMessage] = useState(false)

function getHelpMessage() {
if (showHelpMessage) {
return <div style={{"backgroundColor":"#DDD"}}>
<h3><u>Inputs:</u></h3>
<p><u>Storage name</u> - the name of the storage you want to delete from.<br/>
<u>Column Conditions</u> - example input:
<pre><code>{
`{
"project_id": [1]
"resource_id": ["123456789"]
}`
}</code></pre>
which represents <pre><code>DELETE FROM ... WHERE project_id=1 AND resource_id='123456789'</code></pre></p>
</div>
} else {
return <div><p></p></div>
}
}

function submitRequest() {
let conds;
try {
conds = JSON.parse(columnConditions)
} catch (error) {
alert("expect columnConditions to be valid json but its not");
return;
}
let resp_status = ""
props.api.runLightweightDelete(storageName, conds).then(res => {
resp_status = `${res.status} ${res.statusText}\n`
if (res.headers.get("Content-Type") == "application/json") {
return res.json().then(json => JSON.stringify(json))
} else {
return res.text()
}
}).then(data_str => setResult(resp_status + data_str))
}

return (
<div>
kylemumma marked this conversation as resolved.
Show resolved Hide resolved
<div>
<button type="button" onClick={(event) => setShowHelpMessage(!showHelpMessage)}>Help</button>
{getHelpMessage()}
</div>
<input type="text" value={storageName} placeholder="storage name" onChange={(event) => setStorageName(event.target.value)} /><br/>
<textarea value={columnConditions} placeholder="column conditions" onChange={(event) => setColumnConditions(event.target.value)} /><br/>
<button type="submit" onClick={(event) => submitRequest()}>Submit</button>
<p>latest result:</p>
{result}
</div>
);
}

export default DeleteTool
1 change: 1 addition & 0 deletions snuba/admin/tool_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class AdminTools(Enum):
PRODUCTION_QUERIES = "production-queries"
CARDINALITY_ANALYZER = "cardinality-analyzer"
SNUBA_EXPLAIN = "snuba-explain"
DELETE_TOOL = "delete_tool"


DEVELOPER_TOOLS: set[AdminTools] = {AdminTools.SNQL_TO_SQL, AdminTools.QUERY_TRACING}
Expand Down
71 changes: 70 additions & 1 deletion snuba/admin/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,26 @@
store_instruction,
)
from snuba.datasets.factory import InvalidDatasetError, get_enabled_dataset_names
from snuba.datasets.storages.factory import get_all_storage_keys, get_storage
from snuba.datasets.storages.factory import (
get_all_storage_keys,
get_storage,
get_writable_storage,
)
from snuba.datasets.storages.storage_key import StorageKey
from snuba.migrations.connect import check_for_inactive_replicas
from snuba.migrations.errors import InactiveClickhouseReplica, MigrationError
from snuba.migrations.groups import MigrationGroup, get_group_readiness_state
from snuba.migrations.runner import MigrationKey, Runner
from snuba.query.exceptions import InvalidQueryException
from snuba.query.query_settings import HTTPQuerySettings
from snuba.replacers.replacements_and_expiry import (
get_config_auto_replacements_bypass_projects,
)
from snuba.request.exceptions import InvalidJsonRequestException
from snuba.request.schema import RequestSchema
from snuba.state.explain_meta import explain_cleanup, get_explain_meta
from snuba.utils.metrics.timer import Timer
from snuba.web.delete_query import delete_from_storage
from snuba.web.views import dataset_query

logger = structlog.get_logger().bind(module=__name__)
Expand Down Expand Up @@ -1046,3 +1054,64 @@ def get_allowed_projects() -> Response:
@application.route("/admin_regions", methods=["GET"])
def get_admin_regions() -> Response:
return make_response(jsonify(settings.ADMIN_REGIONS), 200)


@application.route(
"/delete",
methods=["DELETE"],
)
@check_tool_perms(tools=[AdminTools.DELETE_TOOL])
def delete() -> Response:
"""
Given a storage name and columns object, parses the input and calls
delete_from_storage with them.

Input:
an http DELETE request with a json body containing elements "storage" and "columns"
see delete_from_storage for definition of these inputs.
"""
body = request.get_json()
assert isinstance(body, dict)
storage = body.pop("storage", None)
if storage is None:
return make_response(
jsonify(
{
"error",
"all required input 'storage' is not present in the request body",
}
),
400,
)
try:
storage = get_writable_storage(StorageKey(storage))
except Exception as e:
return make_response(
jsonify(
{
"error": str(e),
}
),
400,
)
try:
schema = RequestSchema.build(HTTPQuerySettings, is_delete=True)
request_parts = schema.validate(body)
delete_results = delete_from_storage(storage, request_parts.query["columns"])
except InvalidJsonRequestException as schema_error:
return make_response(
jsonify({"error": str(schema_error)}),
400,
)
except Exception as e:
if application.debug:
from traceback import format_exception

return make_response(jsonify({"error": format_exception(e)}), 500)
else:
sentry_sdk.capture_exception(e)
return make_response(jsonify({"error": "unexpected internal error"}), 500)

return Response(
json.dumps(delete_results), 200, {"Content-Type": "application/json"}
)
14 changes: 8 additions & 6 deletions snuba/web/delete_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@

@with_span()
def delete_from_storage(
storage: WritableTableStorage,
columns: Dict[str, Any],
) -> dict[str, Any]:
storage: WritableTableStorage, columns: Dict[str, list[Any]]
) -> dict[str, Result]:
"""
Inputs:
storage - storage to delete from
Expand All @@ -37,6 +36,9 @@ def delete_from_storage(

Deletes all rows in the given storage, that satisfy the conditions
defined in 'columns' input.

Returns a mapping from clickhouse table name to deletion results, there
will be an entry for every local clickhouse table that makes up the storage.
"""
if get_config("storage_deletes_enabled", 0):
raise Exception("Deletes not enabled")
Expand All @@ -45,11 +47,11 @@ def delete_from_storage(
if not delete_settings.is_enabled:
raise Exception(f"Deletes not enabled for {storage.get_storage_key().value}")

payload: dict[str, Any] = {}
results: dict[str, Result] = {}
for table in delete_settings.tables:
result = _delete_from_table(storage, table, columns)
payload[table] = {**result}
return payload
results[table] = result
return results


def _get_rows_to_delete(
Expand Down
Loading