-
Notifications
You must be signed in to change notification settings - Fork 73
Oracle perf #4187
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
Oracle perf #4187
Changes from all commits
290f8cc
9297bb6
0f1b5da
8172bc1
c698c64
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ | |
|
|
||
| ActiveSupport.on_load(:active_record) do | ||
| if System::Database.oracle? | ||
| require 'arel/visitors/oracle12_hack' | ||
| require 'arel/visitors/oracle12_hack' || next # once done, we can skip setup | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe on application reload we don't need any of this code to be executed again. Using this |
||
|
|
||
| # in 6.0.6 automatic detection of max identifier length was introduced | ||
| # see https://github.com/rsim/oracle-enhanced/pull/1703 | ||
|
|
@@ -30,9 +30,96 @@ def column(name, type, **options) | |
| end | ||
| end) | ||
|
|
||
| ENV['NLS_LANG'] ||= 'AMERICAN_AMERICA.UTF8' | ||
| # clean-up prepared statements/cursors on connection return to pool | ||
| module OracleStatementCleanup | ||
| def self.included(base) | ||
| base.set_callback :checkin, :after, :close_and_clear_statements | ||
| end | ||
|
|
||
| def close_and_clear_statements | ||
| @statements&.clear | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some AI explanation why clearing cursors from app end may not be much of a performance penalty because oracle server still caches these for reuse.
|
||
| end | ||
| end | ||
|
|
||
| ActiveRecord::Base.skip_callback(:update, :after, :enhanced_write_lobs) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please explain this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We skip the redundant |
||
|
|
||
| # For more information see https://github.com/rsim/oracle-enhanced/pull/2483 | ||
| module OracleEnhancedSmartQuoting | ||
| SQL_UTF8_CHUNK_CHARS = 8191 # (32767÷4), 4 bytes max character; 1000 without MAX_STRING_SIZE=EXTENDED | ||
| BLOB_INLINE_LIMIT = 16383 # (32767÷2) 2000 without MAX_STRING_SIZE=EXTENDED | ||
| PLSQL_BASE64_CHUNK_SIZE = 24_573 | ||
|
Comment on lines
+48
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why this values?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can fit only so many real bytes encoded in base64 within the Now I don't remember if I tested this empirically that any more bytes can be processed, but too tired now to go back to it anymore. 24k is enough of a chunk size to not impose a significant overhead. Moreover this is something people shouldn't rely on. This is only for huge values within inline queries. And I see no practical reason one to use it except in dev mode, to see their SQL being run. In which case using huge values will not be productive anyway. Hope it makes sense. |
||
|
|
||
| def quote(value) | ||
| case value | ||
| when ActiveModel::Type::Binary::Data | ||
| data = value.to_s | ||
| if data.empty? | ||
| "empty_blob()" | ||
| elsif data.bytesize <= BLOB_INLINE_LIMIT | ||
| "to_blob(hextoraw('#{data.unpack1('H*')}'))" | ||
| else | ||
| quote_blob_as_subquery(data) | ||
| end | ||
| when ActiveRecord::Type::OracleEnhanced::Text::Data | ||
| text = value.to_s | ||
| text.empty? ? "empty_clob()" : | ||
| value.to_s.scan(/.{1,#{SQL_UTF8_CHUNK_CHARS}}/m) | ||
| .map { |chunk| "to_clob('#{quote_string(chunk)}')" } | ||
| .join(" || ") | ||
| else | ||
| super | ||
| end | ||
| end | ||
|
|
||
| # Generate a scalar subquery with PL/SQL function to build large BLOBs. | ||
| # Uses DBMS_LOB.WRITEAPPEND with base64-encoded chunks for efficiency. | ||
| # Testing showed hextoraw() unusable for being more than 100x slower. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then why are you using
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For a single value, it doesn't show noticeable delay. If used with big data within PL/SQL then it becomes a problem. Since it was already working, I didn't find value in checking whether the base64 functions can be used outside the PL/SQL context. And it is better to avoid the complicated PL/SQL for small values using either function. If not for anything else, just because one uses |
||
| def quote_blob_as_subquery(data) | ||
| out = +"" | ||
| out << "(\n" | ||
| out << " WITH FUNCTION make_blob RETURN BLOB IS\n" | ||
| out << " l_blob BLOB;\n" | ||
| out << " BEGIN\n" | ||
| out << " DBMS_LOB.CREATETEMPORARY(l_blob, TRUE, DBMS_LOB.CALL);\n" | ||
| offset = 0 | ||
| while offset < data.bytesize | ||
| chunk = data.byteslice(offset, PLSQL_BASE64_CHUNK_SIZE) | ||
| out << " DBMS_LOB.WRITEAPPEND(l_blob, " | ||
| out << chunk.bytesize.to_s | ||
| out << ", UTL_ENCODE.BASE64_DECODE(UTL_RAW.CAST_TO_RAW('" | ||
| out << [chunk].pack("m0") # Base64 encoding without newlines | ||
| out << "')));\n" | ||
| offset += PLSQL_BASE64_CHUNK_SIZE | ||
| end | ||
| out << " RETURN l_blob;\n" | ||
| out << " END;\n" | ||
| out << " SELECT make_blob() FROM dual\n" | ||
| out << ")" | ||
| out | ||
| end | ||
| end | ||
|
|
||
| # this is also needed for inline quoting of large BLOBs work | ||
| module OracleEnhancedSmartQuotingPreprocess | ||
| # Add /*+ WITH_PLSQL */ hint for INSERT/UPDATE statements containing | ||
| # PL/SQL function definitions. Oracle requires this hint for DML | ||
| # statements that use PL/SQL in a WITH clause. | ||
| # in Rails 8.0 this method was renamed to preprocess_query(sql) | ||
| def transform_query(sql) | ||
| sql = super | ||
| if sql =~ /\A\s*(INSERT|UPDATE)\b(?=.*\bBEGIN\b)/im | ||
| sql = sql.sub($1, "#{$1} /*+ WITH_PLSQL */") | ||
| end | ||
| sql | ||
| end | ||
| end | ||
|
|
||
| ActiveRecord::ConnectionAdapters::OracleEnhanced::DatabaseStatements.prepend OracleEnhancedSmartQuotingPreprocess | ||
|
|
||
| ActiveRecord::ConnectionAdapters::OracleEnhancedAdapter.class_eval do | ||
| include OracleStatementCleanup | ||
| prepend OracleEnhancedSmartQuoting | ||
|
|
||
| # Fixing OCIError: ORA-01741: illegal zero-length identifier | ||
| # because of https://github.com/rails/rails/commit/c18a95e38e9860953236aed94c1bfb877fa3be84 | ||
| # the value of `columns` is [ "\"ACCOUNTS\".\"ID\"" ] which forms an incorrect query | ||
|
|
@@ -65,24 +152,6 @@ def add_column(table_name, column_name, type, **options) | |
| end | ||
| end | ||
|
|
||
| # We need to patch Oracle Adapter quoting to actually serialize CLOB columns. | ||
| # https://github.com/rsim/oracle-enhanced/issues/1588#issue-272289146 | ||
| # The default behaviour is to serialize them to 'empty_clob()' basically wiping out the data. | ||
| # The team behind it believes `Table.update_all(column: 'text')` | ||
| # should wipe all your data in that column: https://github.com/rsim/oracle-enhanced/issues/1588#issuecomment-343353756 | ||
| # So we try to convert the text to using `to_clob` function. | ||
| def _quote(value) | ||
| case value | ||
| when ActiveModel::Type::Binary::Data | ||
| # I know this looks ugly, but that just modified copy paste of what the adapter does (minus the rescue). | ||
| # It is a bit improved in next version due to ActiveRecord Attributes API. | ||
| %{to_blob(#{quote(value.to_s)})} | ||
| when ActiveRecord::Type::OracleEnhanced::Text::Data | ||
| %{to_clob(#{quote(value.to_s)})} | ||
| else | ||
| super | ||
| end | ||
| end | ||
| end) | ||
| end | ||
|
|
||
|
|
@@ -144,12 +213,12 @@ def adapter_for(model) | |
| return default.new(model) if default | ||
|
|
||
| adapter = adapter_type_for(model) | ||
| klass = case adapter | ||
| when :oracle | ||
| ThinkingSphinx::ActiveRecord::DatabaseAdapters::OracleAdapter | ||
| else | ||
| super | ||
| end | ||
| klass = case adapter | ||
| when :oracle | ||
| ThinkingSphinx::ActiveRecord::DatabaseAdapters::OracleAdapter | ||
| else | ||
| super | ||
| end | ||
| klass.new model | ||
| end | ||
|
|
||
|
|
@@ -178,10 +247,10 @@ def adapter_type_for(model) | |
| # delta column being within the threshold. In the latter's case, no condition | ||
| # is needed, so nil is returned. | ||
| def clause(*args) | ||
| model = (args.length >= 2 ? args[0] : nil) | ||
| model = (args.length >= 2 ? args[0] : nil) | ||
| is_delta = (args.length >= 2 ? args[1] : args[0]) || false | ||
|
|
||
| table_name = (model.nil? ? adapter.quoted_table_name : model.quoted_table_name) | ||
| table_name = (model.nil? ? adapter.quoted_table_name : model.quoted_table_name) | ||
| column_name = (model.nil? ? adapter.quote(@column.to_s) : model.connection.quote_column_name(@column.to_s)) | ||
|
|
||
| if is_delta | ||
|
|
@@ -273,5 +342,25 @@ def column_definitions(table_name) | |
| end | ||
|
|
||
| ActiveRecord::ConnectionAdapters::OracleEnhancedAdapter.prepend OracleEnhancedAdapterSchemaIssue2276 | ||
|
|
||
| # see https://github.com/kubo/ruby-oci8/pull/271 | ||
| module OCI8DisableArrayFetch | ||
| private | ||
|
|
||
| def define_one_column(pos, param) | ||
| @fetch_array_size = nil # disable memory array fetching anytime | ||
| super # call original | ||
| end | ||
|
jlledom marked this conversation as resolved.
|
||
| end | ||
|
|
||
| OCI8::Cursor.prepend(OCI8DisableArrayFetch) | ||
|
|
||
| # see https://github.com/kubo/ruby-oci8/pull/271 | ||
| # Enable piecewise retrieval for both CLOBs and BLOBs | ||
| # With the OCIConnectionCursorLobFix above, we can safely use both mappings | ||
| # because LOBs are bound as OCI8::CLOB/BLOB objects, not LONG data | ||
| # Note: disable temporary for issues with NLS_LANG=AMERICAN_AMERICA.UTF8 | ||
| # OCI8::BindType::Mapping[:clob] = OCI8::BindType::Long | ||
| # OCI8::BindType::Mapping[:blob] = OCI8::BindType::LongRaw | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,7 @@ | |
| end | ||
|
|
||
| After do | ||
| ActiveRecord::Base.clear_active_connections! | ||
| ActiveRecord::Base.connection_handler.clear_active_connections! | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. avoid DEPRECATION warning |
||
| end | ||
|
|
||
| Around '@security' do |scenario, block| | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require 'test_helper' | ||
|
|
||
| class OracleLobLargeUpdateTest < ActiveSupport::TestCase | ||
| # Test large LOB (CLOB and BLOB) updates with OCI8::BindType::Long/LongRaw piecewise retrieval | ||
| # This verifies the OCIConnectionCursorLobFix in config/initializers/oracle.rb works correctly | ||
| # by using OCI8::CLOB.new() and OCI8::BLOB.new() for direct binding | ||
|
|
||
| setup do | ||
| skip "Only run on Oracle database" unless System::Database.oracle? | ||
|
|
||
| @service = FactoryBot.create(:simple_service) | ||
| @proxy = @service.proxy | ||
| end | ||
|
|
||
| test "update and retrieve large CLOB (policies_config) with 512KB data" do | ||
| # Generate ~512KB of JSON data for policies_config | ||
| large_policy_data = generate_large_policies_config(2.kilobytes) | ||
|
|
||
| # Initial save using ActiveRecord | ||
| @proxy.policies_config = large_policy_data | ||
| @proxy.save! | ||
|
|
||
| @proxy.reload | ||
| retrieved_policies = @proxy.policies_config | ||
| assert retrieved_policies.to_json.bytesize >= 2.kilobytes | ||
|
|
||
| expected_policy = JSON.parse(large_policy_data).first | ||
| assert_includes retrieved_policies.map(&:to_h), expected_policy | ||
|
|
||
| # NOW UPDATE with different data | ||
| large_policy_data_v2 = generate_large_policies_config(512.kilobytes, version: "2.0") | ||
| @proxy.reload | ||
| @proxy.policies_config = large_policy_data_v2 | ||
| @proxy.save! | ||
|
|
||
| @proxy.reload | ||
| retrieved_policies_v2 = @proxy.policies_config | ||
|
|
||
| assert retrieved_policies_v2.to_json.bytesize >= 512.kilobytes | ||
|
|
||
| expected_policy_v2 = JSON.parse(large_policy_data_v2).first | ||
| assert_includes retrieved_policies_v2.map(&:to_h), expected_policy_v2 | ||
| end | ||
|
|
||
| test "update and retrieve large BLOB (MemberPermission service_ids) with 512KB data" do | ||
| # Simple test model double for MemberPermission to avoid the JSON serialization logic | ||
| test_class = Class.new(ActiveRecord::Base) do | ||
| self.table_name = 'member_permissions' | ||
| end | ||
|
|
||
| small_binary_data = Random.bytes(2.kilobytes) | ||
| test_record = test_class.create!(service_ids: small_binary_data) | ||
| test_record.reload | ||
| assert_equal small_binary_data, test_record.service_ids | ||
|
|
||
| # NOW UPDATE with different random binary data | ||
| large_binary_data = Random.bytes(512.kilobytes) | ||
| test_record.service_ids = large_binary_data | ||
| test_record.save! | ||
|
|
||
| test_record.reload | ||
| retrieved_value = test_record.service_ids | ||
|
|
||
| assert_equal large_binary_data.bytesize, retrieved_value.bytesize,"Updated binary data size should match" | ||
| assert_equal large_binary_data, retrieved_value,"Updated service_ids should match new binary data" | ||
| end | ||
|
|
||
| test "test large blobs inline quoting" do | ||
| sizes = [ | ||
| 15.kilobytes, # within EXTENDED limit | ||
| 160.kilobytes # Large size | ||
| ] | ||
|
|
||
| sizes.each do |size| | ||
| inline_data = Random.bytes(size) | ||
| blob_data = ActiveModel::Type::Binary::Data.new(inline_data) | ||
| ActiveRecord::Base.connection_pool.with_connection do |conn| | ||
| quoted = conn.quote(blob_data) | ||
| conn.execute("INSERT INTO member_permissions (id, service_ids) VALUES (member_permissions_seq.nextval, #{quoted})") | ||
| res = conn.uncached { conn.select_all("SELECT service_ids FROM member_permissions") } | ||
| assert_equal inline_data, res.first["service_ids"] | ||
| conn.exec_delete("DELETE member_permissions") | ||
| end | ||
| end | ||
| end | ||
|
|
||
| # practical max is 2GB - 8 bytes; can be increased with a OCI8::BLOB and CLOB fixes to write big data in chunks | ||
| test "test large CLOBs inline quoting" do | ||
| sizes = [ | ||
| 8.kilobytes - 1, # VARCHAR2 within EXTENDED limit | ||
| 160.kilobytes # Large size | ||
| ] | ||
|
|
||
| sizes.each do |size| | ||
| description = "\u20AC" * size # 3 bytes character because we use UTF8 instead of AL32UTF8 | ||
| # description = "\u{1F600}" * size # 3 bytes character because we use UTF8 instead of AL32UTF8 | ||
| large_data = ActiveRecord::Type::OracleEnhanced::Text::Data.new(description) | ||
| conn = ActiveRecord::Base.connection | ||
| quoted = conn.quote(large_data) | ||
| conn.execute("update services SET description = #{quoted} where id=#{@service.id}") | ||
|
|
||
| actual_description = @service.reload.description | ||
| assert_equal description, actual_description, "CLOB size #{size} did not match" | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # Generate large JSON policies config data | ||
| def generate_large_policies_config(target_size, version="1.0") | ||
| # Generate a test JSON and calculate actual overhead | ||
| test_policy = { | ||
| "name" => "test_policy", | ||
| "version" => version, | ||
| "configuration" => { "data" => "" }, | ||
| "enabled" => true | ||
| } | ||
|
|
||
| test_json = [test_policy].to_json | ||
| padding_size = target_size - test_json.bytesize | ||
|
|
||
| return test_json if padding_size <= 0 | ||
|
|
||
| test_policy["configuration"]["data"] = "X" * padding_size | ||
| [test_policy].to_json | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another attempt at setting reliably
NLS_LANGbefore oracle connection is established and only if using oracle. Might be a hack but I'm not sure any other place would be less of a hack and I'm tired of trying to figure out where that other place might be. So here I'm sure it's gonna be set prior loading the oracle gems.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did we have encoding problems before this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can see always a complaint that we fallback to some 7bit ASCII encoding if you don't set this variable separately in your environment. But we have always tried to set a default in the initializer. But that has been too late. So this one should be early enough always.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can see that line removed from the initializer