Skip to content

Commit 3c1012d

Browse files
authored
IRVE : amélioration du rapport, utilisation de messages explicites plutôt que des exceptions pour les erreurs connues (#5544)
1 parent 5a6575c commit 3c1012d

9 files changed

Lines changed: 308 additions & 220 deletions

File tree

apps/transport/lib/irve/consolidation.ex

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ defmodule Transport.IRVE.Consolidation do
8484
process_resource(resource)
8585
rescue
8686
error ->
87+
Logger.error(
88+
"IRVE consolidation: unexpected error for resource #{resource.resource_id} (#{resource.url})\n" <>
89+
Exception.format(:error, error, __STACKTRACE__)
90+
)
91+
92+
Sentry.capture_exception(error,
93+
stacktrace: __STACKTRACE__,
94+
extra: %{resource_id: resource.resource_id, url: resource.url}
95+
)
96+
8797
{:error_occurred, error, resource}
8898
end
8999

@@ -105,28 +115,18 @@ defmodule Transport.IRVE.Consolidation do
105115
|> Map.put(:estimated_pdc_count, estimated_pdc_count)
106116
|> Map.put(:file_extension, extension)
107117

108-
# The code is convoluted mostly because we didn't go far enough on the validator work.
109-
# The validator will ultimately stop raising exceptions, and will instead return structures.
110-
# But currently if a cheap check fails, an exception is thrown, and we would lose the estimated PDC count,
111-
# something which is essential to report on for our current work.
112-
try do
113-
# Raise if the producer is not an organization. This check is not in the validator itself:
114-
# it’s not linked to the file content/format, but to how it is published on data.gouv.fr.
115-
# it is done after downloading the file in order to be able to report on the potential
116-
# loss of PDC count.
117-
ensure_producer_is_org!(resource)
118-
119-
validation_result = Transport.IRVE.Validator.validate(path, extension)
120-
file_valid? = validation_result |> Transport.IRVE.Validator.full_file_valid?()
121-
122-
if file_valid? do
123-
{Transport.IRVE.DatabaseImporter.try_write_to_db(path, resource), resource}
124-
else
125-
{:not_compliant_with_schema, resource}
126-
end
127-
rescue
128-
error ->
129-
{:error_occurred, error, resource}
118+
# This producer_is_org_check is not in the validator itself:
119+
# it’s not linked to the file content/format, but to how it is published on data.gouv.fr.
120+
# it is done after downloading the file in order to be able to report on the potential
121+
# loss of PDC count.
122+
with :producer_is_an_organization <- producer_is_org(resource),
123+
%{valid: true} <- Transport.IRVE.Validator.validate_and_summarize(path, extension),
124+
import_status <- Transport.IRVE.DatabaseImporter.try_write_to_db(path, resource) do
125+
{import_status, resource}
126+
else
127+
:producer_not_an_organization -> {:producer_not_an_organization, resource}
128+
%{file_level_errors: [_ | _] = errors} -> {:file_level_errors, resource, errors}
129+
%{file_level_errors: []} -> {:not_compliant_with_schema, resource}
130130
end
131131
end)
132132
end
@@ -208,31 +208,38 @@ defmodule Transport.IRVE.Consolidation do
208208
# regular workflow: process the file then delete it afterwards, no matter what, to ensure
209209
# the files do not stack up on the production disk.
210210
def with_maybe_cached_download_on_disk(resource, file_path, extension, false = _use_permanent_disk_cache, work_fn) do
211-
download!(resource.resource_id, resource.url, file_path)
212-
# NOTE: we need to pass the original extension (provided in the URL) because some heuristics use it afterwards.
213-
# but the caching mechanism stores everything under the same `.dat` extension (so the file path is not enough
214-
# to keep the extension around)
215-
work_fn.(file_path, extension)
211+
case download(resource.resource_id, resource.url, file_path) do
212+
# NOTE: we need to pass the original extension (provided in the URL) because some heuristics use it afterwards.
213+
# but the caching mechanism stores everything under the same `.dat` extension (so the file path is not enough
214+
# to keep the extension around)
215+
:ok -> work_fn.(file_path, extension)
216+
{:error, message} -> {:download_failed, resource, message}
217+
end
216218
after
217219
File.rm!(file_path)
218220
end
219221

220222
# variant for dev work, where it is important to support permanent disk caching (fully offline, no etag)
221223
def with_maybe_cached_download_on_disk(resource, file_path, extension, true = _use_permanent_disk_cache, work_fn) do
222-
if !File.exists?(file_path), do: download!(resource.resource_id, resource.url, file_path)
223-
work_fn.(file_path, extension)
224+
download_result =
225+
if File.exists?(file_path), do: :ok, else: download(resource.resource_id, resource.url, file_path)
226+
227+
case download_result do
228+
:ok -> work_fn.(file_path, extension)
229+
{:error, message} -> {:download_failed, resource, message}
230+
end
224231
end
225232

226-
def download!(resource_id, url, file) do
233+
# Returns `:ok` on success, or `{:error, message}` for a non-200 response.
234+
# Timeouts / transport errors still raise for now (caught upstream as `:error_occurred`).
235+
def download(resource_id, url, file) do
227236
Logger.info("Processing resource #{resource_id} (url=#{url})")
228237
%{status: status} = Transport.HTTPClient.get!(url, compressed: false, decode_body: false, into: File.stream!(file))
229238

230-
unless status == 200 do
231-
raise "Error processing resource (#{resource_id}) (http_status=#{status})"
232-
end
239+
if status == 200, do: :ok, else: {:error, "http_status=#{status}"}
233240
end
234241

235-
defp ensure_producer_is_org!(%{dataset_organisation_id: "???"}), do: raise("producer is not an organization")
236-
237-
defp ensure_producer_is_org!(_row), do: :ok
242+
defp producer_is_org(%{dataset_organisation_id: org_id}) do
243+
if org_id == "???", do: :producer_not_an_organization, else: :producer_is_an_organization
244+
end
238245
end

apps/transport/lib/irve/report_item.ex

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,19 @@ defmodule Transport.IRVE.ReportItem do
2020
]
2121

2222
def from_result({:error_occurred, error, resource}) do
23-
new(resource, :error_occurred, error)
23+
new(resource, :error_occurred, Exception.message(error), inspect(error.__struct__))
24+
end
25+
26+
def from_result({:download_failed, resource, message}) do
27+
new(resource, :download_failed, message, nil)
28+
end
29+
30+
def from_result({:file_level_errors, resource, file_level_errors}) do
31+
new(resource, :file_level_errors, Enum.join(file_level_errors, "\n"), nil)
2432
end
2533

2634
def from_result({status, resource}) do
27-
new(resource, status, nil)
35+
new(resource, status, nil, nil)
2836
end
2937

3038
def to_map(%__MODULE__{} = report_row) do
@@ -33,7 +41,7 @@ defmodule Transport.IRVE.ReportItem do
3341
|> Map.update!(:status, &to_string/1)
3442
end
3543

36-
defp new(resource, status, error) do
44+
defp new(resource, status, error_message, error_type) do
3745
%__MODULE__{
3846
dataset_id: resource.dataset_id,
3947
resource_id: resource.resource_id,
@@ -43,15 +51,9 @@ defmodule Transport.IRVE.ReportItem do
4351
datagouv_last_modified: resource.datagouv_last_modified,
4452
status: status,
4553
estimated_pdc_count: resource[:estimated_pdc_count],
46-
error_message: maybe_error_message(error),
47-
error_type: maybe_error_type(error),
54+
error_message: error_message,
55+
error_type: error_type,
4856
file_extension: resource[:file_extension]
4957
}
5058
end
51-
52-
defp maybe_error_message(nil), do: nil
53-
defp maybe_error_message(error), do: Exception.message(error)
54-
55-
defp maybe_error_type(nil), do: nil
56-
defp maybe_error_type(error), do: error.__struct__ |> inspect()
5759
end

apps/transport/lib/irve/static_probes.ex

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,34 @@ defmodule Transport.IRVE.Static.Probes do
33
This module groups functions related to IRVE-specific CSV handling.
44
"""
55

6-
def run_cheap_blocking_checks(body, extension) do
7-
if Transport.ZipProbe.likely_zip_content?(body) do
8-
raise("the content is likely to be a zip file, not uncompressed CSV data")
9-
end
6+
def file_level_errors(body, extension) do
7+
cond do
8+
Transport.ZipProbe.likely_zip_content?(body) ->
9+
["the content is likely to be a zip file, not uncompressed CSV data"]
1010

11-
if String.downcase(extension) not in ["", ".csv"] do
12-
raise("the content is likely not a CSV file (extension is #{extension})")
13-
end
11+
String.downcase(extension) not in ["", ".csv"] ->
12+
["the content is likely not a CSV file (extension is #{extension})"]
1413

15-
if probably_v1_schema(body) do
16-
raise("looks like a v1 irve")
17-
end
14+
probably_v1_schema(body) ->
15+
["looks like a v1 irve"]
1816

19-
if !has_id_pdc_itinerance(body) do
20-
raise("content has no id_pdc_itinerance in first line")
21-
end
17+
!has_id_pdc_itinerance(body) ->
18+
["content has no id_pdc_itinerance in first line"]
2219

23-
header_separator = hint_header_separator(body)
20+
true ->
21+
separator_errors(body)
22+
end
23+
end
2424

25-
unless header_separator in [";", ","] do
26-
raise("unsupported column separator #{header_separator}")
25+
# `hint_header_separator/1` raises when it cannot find a separator at all
26+
# (e.g. single-column file); we catch that so this stays non-raising.
27+
defp separator_errors(body) do
28+
case hint_header_separator(body) do
29+
sep when sep in [";", ","] -> []
30+
sep -> ["unsupported column separator #{sep}"]
2731
end
32+
rescue
33+
error -> [Exception.message(error)]
2834
end
2935

3036
@doc """

apps/transport/lib/irve/validator/validator.ex

Lines changed: 48 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ defmodule Transport.IRVE.Validator do
33
Central entry point for IRVE file validation (currently working on `DataFrame`).
44
"""
55

6+
@unexpected_error_prefix "Unexpected error: "
7+
68
def compute_validation(%Explorer.DataFrame{} = df) do
79
schema = Transport.IRVE.StaticIRVESchema.schema_content()
810

@@ -12,27 +14,6 @@ defmodule Transport.IRVE.Validator do
1214
|> Transport.IRVE.Validator.DataFrameValidation.setup_computed_warning_columns()
1315
end
1416

15-
@doc """
16-
Validate an IRVE file located at `path`, returning a DataFrame with validation results.
17-
This wrapper includes some pre-processing steps before actual validation.
18-
These preprocessing steps do not output any warning and are silent,
19-
so files that are not strictly valid may be considered as valid without any notice by this function.
20-
If you want to call a strict validator (no preprocessing), use `compute_validation/1` instead.
21-
"""
22-
def validate(path, extension \\ ".csv") do
23-
# NOTE: for now, load the body in memory, because refactoring to get full streaming
24-
# is too involved for the current sprint deadline.
25-
body = File.read!(path)
26-
Transport.IRVE.Static.Probes.run_cheap_blocking_checks(body, extension)
27-
# TODO: accumulate warnings
28-
body = Transport.IRVE.Transcoder.ensure_utf8(body)
29-
# TODO: accumulate warnings
30-
31-
body
32-
|> Transport.IRVE.Processing.read_as_uncasted_data_frame()
33-
|> compute_validation()
34-
end
35-
3617
@doc """
3718
Says from the dataframe output of compute_validation/1 if all rows are valid.
3819
"""
@@ -72,33 +53,58 @@ defmodule Transport.IRVE.Validator do
7253
end
7354

7455
@doc """
75-
Combines `validate/2` and `summarize/1` into a single call, catching any file-level error
76-
(bad encoding, wrong format, missing columns, etc.) instead of raising.
56+
The main entry point for IRVE validation: turns a file into a `%Summary{}`.
57+
Used by on demand validation as well as the consolidation pipeline.
58+
59+
If you want to call a strict validator (no preprocessing, no file-level error handling),
60+
or want to have access to line by line details, use `compute_validation/1` instead,
61+
as this function doesn’t output the whole validation report, only a summary.
7762
78-
Returns the same map as `summarize/1` with an additional `file_level_error` key:
79-
- `nil` when validation ran successfully (the file could still be invalid row-by-row)
80-
- an error message string when a hard file-level error was caught
63+
Known file-level problems (zip, wrong format, v1 schema, …) are detected up-front by
64+
`Transport.IRVE.Static.Probes.file_level_errors/2` and reported in `file_level_errors`.
65+
In this case, `valid_row_count`, `invalid_row_count`, `column_errors`, and
66+
`error_samples` are all `nil`/empty as the file was not processed at all.
8167
82-
On a file-level error, `valid_row_count`, `invalid_row_count`, `column_errors`, and
83-
`error_samples` are all `nil`/empty since there is no DataFrame to summarize from.
68+
Unexpected errors anywhere in the pipeline are also caught and reported in `file_level_errors`
69+
and prefixed with `#{@unexpected_error_prefix}` to tell them apart.
8470
"""
8571
def validate_and_summarize(path, extension \\ ".csv") do
86-
path
87-
|> validate(extension)
88-
|> summarize()
72+
# Known problem: whole file is loaded in memory, no streaming.
73+
# We could change that by having probes use only the first lines,
74+
# stream the utf8 conversion,
75+
# and finally cast to a dataframe in a streaming way (Explorer.DataFrame.from_csv/2 supports streaming).
76+
body = File.read!(path)
77+
78+
# TODO: wrong delimiter is silently fixed, should send a file-level warning instead
79+
case Transport.IRVE.Static.Probes.file_level_errors(body, extension) do
80+
[] ->
81+
body
82+
# TODO: send a file level warning instead of silently fixing
83+
|> Transport.IRVE.Transcoder.ensure_utf8()
84+
|> Transport.IRVE.Processing.read_as_uncasted_data_frame()
85+
|> compute_validation()
86+
|> summarize()
87+
88+
file_level_errors ->
89+
summary_with_file_level_errors(file_level_errors)
90+
end
8991
rescue
9092
error ->
91-
%Transport.IRVE.Validator.Summary{
92-
valid: false,
93-
valid_row_count: nil,
94-
invalid_row_count: nil,
95-
total_row_count: nil,
96-
file_level_errors: [Exception.message(error)],
97-
column_errors: %{},
98-
error_samples: [],
99-
warnings: %{},
100-
warning_samples: []
101-
}
93+
summary_with_file_level_errors([@unexpected_error_prefix <> Exception.message(error)])
94+
end
95+
96+
defp summary_with_file_level_errors(file_level_errors) do
97+
%Transport.IRVE.Validator.Summary{
98+
valid: false,
99+
valid_row_count: nil,
100+
invalid_row_count: nil,
101+
total_row_count: nil,
102+
file_level_errors: file_level_errors,
103+
column_errors: %{},
104+
error_samples: [],
105+
warnings: %{},
106+
warning_samples: []
107+
}
102108
end
103109

104110
@max_samples_per_group 5

apps/transport/test/transport/irve/consolidation_test.exs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,32 @@ defmodule Transport.IRVE.ConsolidationTest do
3333
%{
3434
"dataset_id" => "another-dataset-id",
3535
"resource_id" => "another-resource-id",
36-
"status" => "error_occurred",
37-
"error_type" => "ArgumentError",
36+
"status" => "file_level_errors",
37+
"error_type" => nil,
3838
"estimated_pdc_count" => "1",
3939
"file_extension" => ".csv",
4040
"url" => "https://static.data.gouv.fr/resources/another-irve-url-2024/data.csv",
4141
"dataset_title" => "another-dataset-title",
4242
"datagouv_organization_or_owner" => "another-org",
4343
"datagouv_last_modified" => "2024-02-29T07:43:59.660000+00:00",
44+
# An unexpected error (here a missing required column) is caught by the validator,
45+
# marked with the "Unexpected error: " prefix and surfaced in error_message.
4446
# TODO rework to only compare the part of the message that matters
4547
"error_message" =>
46-
~s|could not find column name "nom_station". The available columns are: ["accessibilite_pmr", "telephone_operateur", "coordonneesXY", "observations", "date_maj", "num_pdl", "code_insee_commune", "nom_enseigne", "puissance_nominale", "adresse_station", "id_station_itinerance", "siren_amenageur", "contact_operateur", "implantation_station", "date_mise_en_service", "horaires", "id_pdc_itinerance", "nbre_pdc", "raccordement", "id_station_local", "nom_amenageur", "restriction_gabarit", "nom_operateur", "contact_amenageur", "id_pdc_local", "tarification", "condition_acces", "prise_type_ef", "prise_type_2", "prise_type_combo_ccs", "prise_type_chademo", "prise_type_autre", "gratuit", "paiement_acte", "paiement_cb", "paiement_autre", "reservation", "station_deux_roues", "cable_t2_attache", "check_column_nom_amenageur_valid", "check_column_siren_amenageur_valid", "check_column_contact_amenageur_valid", "check_column_nom_operateur_valid", "check_column_contact_operateur_valid", "check_column_telephone_operateur_valid", "check_column_nom_enseigne_valid", "check_column_id_station_itinerance_valid", "check_column_id_station_local_valid"].\nIf you are attempting to interpolate a value, use ^nom_station.|
48+
~s|Unexpected error: could not find column name "nom_station". The available columns are: ["accessibilite_pmr", "telephone_operateur", "coordonneesXY", "observations", "date_maj", "num_pdl", "code_insee_commune", "nom_enseigne", "puissance_nominale", "adresse_station", "id_station_itinerance", "siren_amenageur", "contact_operateur", "implantation_station", "date_mise_en_service", "horaires", "id_pdc_itinerance", "nbre_pdc", "raccordement", "id_station_local", "nom_amenageur", "restriction_gabarit", "nom_operateur", "contact_amenageur", "id_pdc_local", "tarification", "condition_acces", "prise_type_ef", "prise_type_2", "prise_type_combo_ccs", "prise_type_chademo", "prise_type_autre", "gratuit", "paiement_acte", "paiement_cb", "paiement_autre", "reservation", "station_deux_roues", "cable_t2_attache", "check_column_nom_amenageur_valid", "check_column_siren_amenageur_valid", "check_column_contact_amenageur_valid", "check_column_nom_operateur_valid", "check_column_contact_operateur_valid", "check_column_telephone_operateur_valid", "check_column_nom_enseigne_valid", "check_column_id_station_itinerance_valid", "check_column_id_station_local_valid"].\nIf you are attempting to interpolate a value, use ^nom_station.|
4749
},
4850
%{
4951
"dataset_id" => "individual-published-dataset-id",
5052
"resource_id" => "individual-published-resource-id",
51-
"status" => "error_occurred",
52-
"error_type" => "RuntimeError",
53+
"status" => "producer_not_an_organization",
54+
"error_type" => nil,
5355
"estimated_pdc_count" => "1",
5456
"file_extension" => ".csv",
5557
"url" => "https://static.data.gouv.fr/resources/individual-published-irve-url-2024/data.csv",
5658
"dataset_title" => "individual-published-dataset-title",
5759
"datagouv_organization_or_owner" => "Guy Who loves IRVE",
5860
"datagouv_last_modified" => "2024-02-29T07:43:59.660000+00:00",
59-
"error_message" => "producer is not an organization"
61+
"error_message" => nil
6062
},
6163
%{
6264
"dataset_id" => "the-dataset-id",
@@ -100,7 +102,7 @@ defmodule Transport.IRVE.ConsolidationTest do
100102
start_path: "consolidation_transport_irve_statique_rapport_#{date}",
101103
bucket: bucket_name,
102104
acl: :private,
103-
file_content: "f06fd15d5afcd8be10880b049dc45424c6c9475b8ee2071c5ab1b9880638f3d9"
105+
file_content: sha256_of(report_content)
104106
)
105107

106108
Transport.Test.S3TestUtils.s3_mocks_remote_copy_file(

0 commit comments

Comments
 (0)