このコミットが含まれているのは:
2026-10-31 13:50:10 +09:00
コミット dba409c3e8
33個のファイルの変更、1490行の追加、236行の削除
+59 -25
ファイルの表示
@@ -12,6 +12,15 @@ class CountsExportsController < ApplicationController
render json: { error: 'export_worker_unavailable' }, status: :service_unavailable
rescue CountsExportDownload::Busy
head :too_many_requests
rescue CountsAxes::TooManyAxes
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
rescue CountsAxes::PrivateCondition
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
@@ -48,6 +57,15 @@ class CountsExportsController < ApplicationController
head :gateway_timeout
rescue CountsExportDownload::StorageFull, Errno::ENOSPC
head :insufficient_storage
rescue CountsAxes::TooManyAxes
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
rescue CountsAxes::PrivateCondition
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
@@ -79,36 +97,43 @@ class CountsExportsController < ApplicationController
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'unavailable')
return head :not_found
end
# Open before committing the response, but never buffer the entire export.
stream = File.open(file.fetch('path'), 'rb')
job = params[:job]
authority = HistoryAccess.user
first_check = true
stream = CountsExportDownload::ResponseBody.new(file.fetch('path')) do |status|
if status == 'active'
if first_check
first_check = false
fresh = authority && User.find_by(id: authority.id)
next false if authority && (!fresh || fresh.banned?)
authority = fresh
HistoryAccess.set(user: authority) do
CountsExportDownload.find(arguments[:file_token], owner) &&
CountsExportJobs.stream_active?(job, owner, **arguments)
end
else
CountsExportJobs.stream_active?(job, owner, **arguments)
end
else
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status:)
end
end
end
unless CountsExportJobs.download_attempt(job, owner, **arguments, status: 'streaming')
stream.close
return head :conflict
end
response.headers['Cache-Control'] = 'private, no-store'
send_file_headers! filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
job = params[:job]
authority = HistoryAccess.user
self.response_body = Enumerator.new do |output|
begin
first = true
while (chunk = stream.read(64 * 1024))
output << chunk
if first
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status: 'started')
end
first = false
end
end
rescue IOError, SystemCallError => error
Rails.logger.warn("counts_download_stream_failure: #{ error.class }")
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status: 'failed')
end
ensure
stream.close
end
end
response.headers['Last-Modified'] = File.mtime(file.fetch('path')).httpdate
response.headers['X-Accel-Buffering'] = 'no'
self.response_body = stream
rescue IOError, SystemCallError
stream&.close
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'failed')
head :service_unavailable
end
@@ -138,6 +163,15 @@ class CountsExportsController < ApplicationController
filename: "counts-#{ exporter.resource }.#{ exporter.format }",
type: exporter.format == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
rescue CountsAxes::TooManyAxes
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
rescue CountsAxes::PrivateCondition
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request
end
+39 -2
ファイルの表示
@@ -4,6 +4,7 @@ require 'digest'
class HistoryAccess < ActiveSupport::CurrentAttributes
attribute :user
class Forbidden < StandardError; end
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
'nico_tag_versions' => ['tag_id'],
@@ -18,10 +19,12 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
end
def self.authority
{ 'policy_revision' => revision, 'admin' => admin?, 'actor_id' => user&.id }
{ 'artifact_policy' => ARTIFACT_POLICY, 'policy_revision' => revision,
'admin' => admin?, 'actor_id' => user&.id }
end
def self.authorised_artifact?(metadata)
return false unless metadata['artifact_policy'] == ARTIFACT_POLICY
return false unless metadata['policy_revision'].is_a?(String)
if metadata['admin']
admin? && user.id == metadata['actor_id']
@@ -72,13 +75,47 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
"ON reference_entry.reference_id = CAST(blocked.#{ key } AS CHAR))"
end
def self.snapshot_reference(model, row, external: false)
key = external ? 'external_tag_id' : 'tag_id'
table = model.table_name
source = "JSON_TABLE(COALESCE(#{ row }.tags_json, JSON_ARRAY()), '$[*]' COLUMNS (" +
"tag_id BIGINT PATH '$.#{ key }', version_no INT PATH '$.version_no')) tag_reference"
match = 'reference_version.tag_id = tag_reference.tag_id'
if external
match += " AND reference_version.created_at <= #{ row }.created_at"
latest = "AND NOT EXISTS (SELECT 1 FROM #{ table } later_reference " +
'WHERE later_reference.tag_id = reference_version.tag_id ' +
"AND later_reference.created_at <= #{ row }.created_at " +
'AND later_reference.version_no > reference_version.version_no)'
else
match += ' AND reference_version.version_no = tag_reference.version_no'
latest = ''
end
"NOT EXISTS (SELECT 1 FROM #{ source } JOIN #{ table } reference_version ON #{ match } " +
"WHERE NOT (#{ sql(model, 'reference_version') }) #{ latest })"
end
def self.reference_sql(table, row)
case table
when 'post_versions'
[['tag_versions', 'tag_id', 'tag_id'],
['nico_tag_versions', 'tag_id', 'external_tag_id']].map do |target, key, field|
json_reference(target, key, "JSON_EXTRACT(#{ row }.tags_json, '$[*].#{ field }')")
end + [list_reference('post_versions', 'post_id', "#{ row }.parent_post_ids")]
end + [list_reference('post_versions', 'post_id', "#{ row }.parent_post_ids"),
snapshot_reference(TagVersion, row),
snapshot_reference(NicoTagVersion, row, external: true)]
when 'nico_tag_versions'
# Legacy linked_tags snapshots contain names, not IDs. Match recorded names
# conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
["NOT EXISTS (SELECT 1 FROM tag_versions private_tag " +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
"ON blocked.tag_id = private_tag.tag_id " +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> '' AND " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
when 'tag_versions'
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
when 'tag_description_versions'
+9 -3
ファイルの表示
@@ -61,7 +61,11 @@ class CountsAxes
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
end
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
expression = "CASE WHEN #{ HistoryAccess.sql(@definition.model, 'v') } " +
visible = HistoryAccess.sql(@definition.model, 'v')
if field == 'changed_field'
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
end
expression = "CASE WHEN #{ visible } " +
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
end
expression
@@ -72,8 +76,10 @@ class CountsAxes
return 'COUNT(DISTINCT v.id)' unless rows.any? { _1['field'] == 'tags' }
# Keep tag occurrences even when several private tags share one masked label.
"COUNT(DISTINCT v.id, COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id)))"
values = ['v.id', "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
"CONCAT('external:', tag_entry.external_tag_id))"]
values << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
"COUNT(DISTINCT #{ values.join(', ') })"
end
def joins(fields = all.map { _1['field'] })
+40
ファイルの表示
@@ -13,6 +13,46 @@ class CountsExportDownload
class TimedOut < StandardError; end
class StorageFull < StandardError; end
# Rack body deliberately has no to_path: sendfile middleware must not bypass ACK.
class ResponseBody
def initialize(path, &state)
File.open(path, 'rb') { |file| file.stat }
@path = path
@stream = nil
@closed = false
@state = state
end
def each
return if @closed
@stream = File.open(@path, 'rb')
first = true
while (chunk = @stream.read(64 * 1024))
raise IOError, 'Download no longer authorised' unless @state.call('active')
yield chunk
if first
# This confirms yielding to Rack, never browser receipt or saving.
@state.call('started')
first = false
end
end
@state.call('failed') if first
rescue StandardError => error
Rails.logger.warn("counts_download_stream_failure: #{ error.class }")
@state.call('failed')
raise
ensure
close
end
def close
@closed = true
@stream.close if @stream && !@stream.closed?
end
end
def self.directory
Rails.root.join(ENV.fetch('COUNTS_EXPORT_DIRECTORY', 'tmp/counts_exports'))
end
+62 -4
ファイルの表示
@@ -45,6 +45,20 @@ class CountsExportJobs
write(state)
end
def self.failure_code(error)
case error
when Cancelled then 'cancelled'
when CountsAxes::TooManyAxes then 'too_many_axes'
when CountsAxes::PrivateCondition then 'private_condition'
when CountsTableQuery::TooManyColumns then 'too_many_columns'
when ActiveRecord::StatementTimeout then 'counts_query_timeout'
when CountsExportDownload::TimedOut then 'generation_timeout'
when CountsExportDownload::TooLarge then 'export_too_large'
when CountsExportDownload::StorageFull, Errno::ENOSPC then 'export_storage_full'
else 'generation_failed'
end
end
def self.directory = CountsExportDownload.directory.join('jobs')
def self.verifier = Rails.application.message_verifier('counts_export_job')
@@ -176,6 +190,12 @@ class CountsExportJobs
return unless state && state['owner'] == owner && state['expires_at'] > Time.current.to_f
expire_stalled(state)
if (state['status'] == 'ready' &&
['pending', 'streaming'].include?(state['download_status']) &&
state.fetch('download_expires_at', 0) <= Time.current.to_f)
state['download_status'] = 'timeout'
write(state)
end
if cancel && ['queued', 'running', 'ready'].include?(state['status'])
state['status'] = state['status'] == 'running' ? 'cancelling' : 'cancelled'
@@ -183,8 +203,11 @@ class CountsExportJobs
state.delete('token')
write(state)
end
state.slice('status', 'phase', 'processed', 'total', 'format', 'token', 'error',
result = state.slice('status', 'phase', 'processed', 'total', 'format', 'token', 'error',
'updated_at', 'heartbeat_at', 'download_attempt', 'download_status')
result['max_axes'] = CountsAxes::MAX_AXES if state['error'] == 'too_many_axes'
result['max_columns'] = CountsTableQuery::MAX_COLUMNS if state['error'] == 'too_many_columns'
result
end
end
@@ -198,18 +221,53 @@ class CountsExportJobs
state['expires_at'] > Time.current.to_f && HistoryAccess.authorised_artifact?(state))
if attempt
return unless state['download_attempt'] == attempt && state['token'] == file_token
return unless ['pending', 'started'].include?(state['download_status'])
return unless state['download_status'] == 'started' ||
state.fetch('download_expires_at', 0) > Time.current.to_f
state['download_status'] = status if status
transitions = { 'streaming' => ['pending'], 'started' => ['streaming'],
'failed' => ['pending', 'streaming', 'started'],
'unavailable' => ['pending', 'streaming'] }
if status == 'active'
return state.slice('download_attempt', 'download_status', 'token') if
['streaming', 'started'].include?(state['download_status'])
return
end
if status
return unless transitions.fetch(status, []).include?(state['download_status'])
state['download_status'] = status
else
return unless state['download_status'] == 'pending'
end
else
# Repeated acceptance POSTs do not invalidate an in-flight GET or issue duplicates.
if (state['download_status'] == 'started' ||
(['pending', 'streaming'].include?(state['download_status']) &&
state.fetch('download_expires_at', 0) > Time.current.to_f))
return state.slice('download_attempt', 'download_status', 'token')
end
state['download_attempt'] = SecureRandom.hex(24)
state['download_status'] = 'pending'
state['download_expires_at'] = Time.current.to_f + 45
end
write(state)
state.slice('download_attempt', 'download_status', 'token')
end
end
# GET already verified authority. During streaming, observe cancellation without
# repeating the six-table policy revision aggregate for every 64KiB chunk.
def self.stream_active?(token, owner, file_token:, attempt:)
id = authorised_id(token, owner)
state = id && read(id)
state && state['owner'] == owner && state['status'] == 'ready' &&
state['expires_at'] > Time.current.to_f && state['token'] == file_token &&
state['download_attempt'] == attempt &&
(state['download_status'] == 'started' ||
(state['download_status'] == 'streaming' &&
state.fetch('download_expires_at', 0) > Time.current.to_f))
end
def self.checkpoint(id, processed = nil, total = nil, phase: nil)
synchronise do
state = read(id)
@@ -398,7 +456,7 @@ class CountsExportJobs
if current
unless current['status'] == 'failed'
current['status'] = error.is_a?(Cancelled) ? 'cancelled' : 'failed'
current['error'] = error.class.name
current['error'] = failure_code(error)
end
current['updated_at'] = Time.current.to_f
write(current)
+14 -7
ファイルの表示
@@ -1,4 +1,4 @@
# Re-aggregate the population, never add up already grouped cells (multi-value axes overlap).
# Re-aggregate distinct membership; tag row tuples alone use additive contributions.
class CountsPivotTotals
def initialize(population, axes, row_headers, column_headers)
@population = population
@@ -8,8 +8,12 @@ class CountsPivotTotals
end
def call
# MySQL's unlimited LIMIT prevents CTE merging: authorise once for all branches.
basis = @population.scope.except(:select, :order).select('v.*')
# Materialise full-cell membership once, including omitted multi-value axes.
membership = @population.scope.except(:select, :order)
joins = @axes.joins
membership = membership.joins(joins) unless joins.empty?
# Retain exactly the records contributing a full cell, including changed_field.
basis = membership.select('v.*').distinct
.limit(18_446_744_073_709_551_615).to_sql
queries = @rows.flat_map do |row_depth, headers|
@columns.filter_map do |column_depth, columns|
@@ -18,7 +22,7 @@ class CountsPivotTotals
indexes = (0...row_depth).to_a +
(0...column_depth).map { @axes.rows.length + _1 }
expressions = indexes.map { @axes.expressions[_1] }
scope = @population.scope.except(:select, :order, :where, :from).from('authorised v')
scope = @population.scope.except(:select, :order, :where, :from).from('population_members v')
additive = @axes.rows.any? { _1['field'] == 'tags' }
retained = additive ? (0...@axes.rows.length).to_a | indexes : indexes
joins = @axes.joins(retained.map { @axes.all[_1].fetch('field') })
@@ -47,9 +51,12 @@ class CountsPivotTotals
if additive
# Sum full row tuples, not page cells; preserve DISTINCT inside each tuple.
leaf = retained.map { @axes.expressions[_1] }
selected = leaf.each_with_index.map { |expression, index| "#{ expression } AS k#{ index }" }
selected = leaf.each_with_index.map do |expression, index|
"#{ expression } AS k#{ index }"
end
selected << "#{ @axes.count_expression } AS count"
inner = scope.group(*leaf.map { Arel.sql(_1) })
.select(Arel.sql((selected + ["#{ @axes.count_expression } AS count"]).join(', '))).to_sql
.select(Arel.sql(selected.join(', '))).to_sql
coordinates = indexes.map { "k#{ retained.index(_1) }" }
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
@@ -67,7 +74,7 @@ class CountsPivotTotals
sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ')
@population.definition.model.connection.select_all(
"WITH authorised AS (#{ basis }) " +
"WITH population_members AS (#{ basis }) " +
"SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record|
row_depth = record['row_depth'].to_i
column_depth = record['column_depth'].to_i
+12 -1
ファイルの表示
@@ -57,7 +57,8 @@ class CountsPopulation
"(previous.id IS NOT NULL AND NOT (v.#{ column } <=> previous.#{ column }))"
end
# Unrestricted predicates are used only for numerical totals, never record output.
# Aggregate sources are unrestricted; CountsAxes masks their output coordinates.
# Never serialise raw records from an unrestricted population.
def access_sql(model, row)
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
end
@@ -67,6 +68,16 @@ class CountsPopulation
key = entry.fetch('field')
raise ArgumentError unless definition.axes(history).include?(key)
value = entry['value']
unless value.nil?
valid = case definition.fields[key]
when :integer
value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A-?[0-9]+\z/))
when :boolean then [true, false].include?(value)
else value.is_a?(String)
end
raise ArgumentError unless valid
end
if entry.key?('grain')
unless definition.fields[key] == :datetime && CountsAxes::GRAINS.include?(entry['grain'])
raise ArgumentError
+2
ファイルの表示
@@ -253,6 +253,8 @@ class CountsTableExport
end
def summary_value(value, index, page)
return '********' if value == CountsAxes::MASKED_VALUE
field = @query.axes.all.fetch(index).fetch('field')
return @labels.fetch('bot', '') if field == 'user' && value.nil?
if ['language_code', 'source_language_code'].include?(field)
+31 -9
ファイルの表示
@@ -49,12 +49,18 @@ class CountsTableQuery
result = metadata.merge(mode == 'summary' ? summary : details)
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
totals = output_totals(visible)
result.merge(visible_output_count: visible,
response = result.merge(visible_output_count: visible,
**totals,
output_count_scope: 'before_authorisation',
output_count_unit: 'regular_result_rows',
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
result[:group_count] > limit)
if mode == 'summary'
response.except!(:excluded_count, :excluded_count_scope)
response[:column_values_editable] = HistoryAccess.admin? ||
!result.fetch(:column_values, []).flatten.include?(CountsAxes::MASKED_VALUE)
end
response
end
end
@@ -228,17 +234,17 @@ class CountsTableQuery
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
private_parameters = ['tags', 'tag_refs', 'tag', 'kind', 'resource_id', 'user_id',
'name_scope'].any? do |key|
keys = ['tags', 'tag_refs', 'tag', 'kind', 'resource_id', 'user_id']
private_parameters = keys.any? do |key|
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
end
raise CountsAxes::PrivateCondition if private_fields || private_parameters
['column_order', 'measures'].each do |key|
next if QueryState.records(@params, key).empty?
columns = QueryState.axes(@params.fetch('cols', ''))
raise CountsAxes::PrivateCondition if columns.any? do |axis|
!CountsAxes::PUBLIC_FIELDS.include?(axis['field'])
# Coordinates are compared only to already masked expressions, never raw
# values. Public coordinates remain sortable; a guessed private ID cannot match.
raise CountsAxes::PrivateCondition if QueryState.records(@params, key).any? do |record|
record['value'] == CountsAxes::MASKED_VALUE
end
end
end
@@ -350,6 +356,11 @@ class CountsTableQuery
def measure_relation values
row_expressions = axes.expressions.first(axes.rows.length)
source = summary_population.scope.except(:select, :order)
# Omitted multi-value column axes still determine full-cell membership.
membership = summary_population.scope.except(:select, :order)
all_joins = axes.joins
membership = membership.joins(all_joins) unless all_joins.empty?
source = source.where("v.id IN (#{ membership.select('v.id').distinct.to_sql })")
fields = axes.rows + axes.columns.first(values.length)
joins = axes.joins(fields.map { _1.fetch('field') })
source = source.joins(joins) unless joins.empty?
@@ -367,7 +378,15 @@ class CountsTableQuery
def axis_order_sql key, direction = 'asc', qualifier: nil
column = qualifier ? "#{ qualifier }.#{ key }" : key
expression = column
if axes.all.fetch(key.delete_prefix('a').to_i).fetch('field') == 'category'
field = axes.all.fetch(key.delete_prefix('a').to_i).fetch('field')
if !HistoryAccess.admin? && [:integer, :boolean].include?(@definition.fields[field])
masked = @connection.quote(CountsAxes::MASKED_VALUE)
expression = "CASE WHEN #{ column } <=> #{ masked } THEN NULL " +
"ELSE CAST(#{ column } AS SIGNED) END"
return "#{ column } IS NULL ASC, (#{ column } <=> #{ masked }) ASC, " +
"#{ expression } #{ direction.upcase }"
end
if field == 'category'
# Tag's enum preserves the same category order as TagsController.
cases = Tag.categories.keys.each_with_index.map { |category, index|
"WHEN #{ @connection.quote(category) } THEN #{ index }"
@@ -418,7 +437,10 @@ class CountsTableQuery
labels = case field
when 'tags' then tag_labels(values)
when 'user'
User.where(id: values).pluck(:id, :name).to_h.transform_keys(&:to_s)
actors = HistoryAccess.visible(population.scope, 'v').except(:select, :order)
.select('v.created_by_user_id')
User.where(id: values).where(id: actors)
.pluck(:id, :name).to_h.transform_keys(&:to_s)
else {}
end
[field, labels]