Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ gem 'unicorn', require: false, group: %i[production]
# NOTE: Use ENV['DB'] only to install oracle dependencies
group :oracle do
oracle = -> { (ENV['ORACLE'] == '1') || ENV.fetch('DATABASE_URL', ENV['DB'])&.start_with?('oracle') }
# ENV['NLS_LANG'] ||= 'AMERICAN_AMERICA.AL32UTF8' if oracle
ENV['NLS_LANG'] ||= 'AMERICAN_AMERICA.UTF8' if oracle

Copy link
Copy Markdown
Contributor Author

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_LANG before 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.

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Contributor Author

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

gem 'activerecord-oracle_enhanced-adapter', '~> 7.1.0', install_if: oracle
gem 'ruby-oci8', require: false, install_if: oracle
end
Expand Down
9 changes: 8 additions & 1 deletion SETUP_ORACLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@

Go to [the official Oracle Instant Client Downloads site](https://www.oracle.com/database/technologies/instant-client/downloads.html) and install basic and SDK RPMs like this:

On Fedora 43, because of required file digests, for version 19 you need:
```
sudo dnf install https://download.oracle.com/otn_software/linux/instantclient/1918000/oracle-instantclient19.18-basic-19.18.0.0.0-2.x86_64.rpm https://download.oracle.com/otn_software/linux/instantclient/1918000/oracle-instantclient19.18-devel-19.18.0.0.0-2.x86_64.rpm
sudo dnf install -y libnsl
sudo rpm -ivh --nodigest --nofiledigest https://download.oracle.com/otn_software/linux/instantclient/1930000/oracle-instantclient19.30-basic-19.30.0.0.0-1.el9.x86_64.rpm https://download.oracle.com/otn_software/linux/instantclient/1930000/oracle-instantclient19.30-devel-19.30.0.0.0-1.x86_64.rpm
```

Earlier can do with dnf only:
```
sudo dnf install https://download.oracle.com/otn_software/linux/instantclient/1930000/oracle-instantclient19.30-basic-19.30.0.0.0-1.el9.x86_64.rpm https://download.oracle.com/otn_software/linux/instantclient/1930000/oracle-instantclient19.30-devel-19.30.0.0.0-1.x86_64.rpm
```

If you wish, you can also install SQLPLus client from same location as well.
Expand Down
145 changes: 117 additions & 28 deletions config/initializers/oracle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 require statement as a state tracking tool know whether we ever run or not. If your local development environment breaks on code reload, this would be the culprit. Be my guests any time!


# in 6.0.6 automatic detection of max identifier length was introduced
# see https://github.com/rsim/oracle-enhanced/pull/1703
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Why Oracle Keeps "Closed" Cursors

Oracle's V$OPEN_CURSOR view is somewhat misleadingly named - it shows cursors Oracle is tracking for the session, not just actively open ones. Oracle keeps them for performance:

  1. Session Cursor Cache: When you "close" a cursor from the application side, Oracle often keeps it cached in case you run the same SQL again. This is controlled by the SESSION_CACHED_CURSORS parameter.
  2. Soft vs Hard Close:
    - Soft close: Application closes the cursor, but Oracle keeps it in its cache
    - Hard close: Oracle actually deallocates it

end
end

ActiveRecord::Base.skip_callback(:update, :after, :enhanced_write_lobs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please explain this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We skip the redundant enhanced_write_lobs callback. It is something never needed by our usage and mot needed for the wider project either especially with the quoting fixes.


# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why this values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 32767 bytes value limit

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Then why are you using hextoraw() instead of this in other scenarios?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 to_sql for readability. And seeing a very popular standard Oracle SQL function is much more readable than the ugly PL/SQL insertion.

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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
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
2 changes: 1 addition & 1 deletion features/support/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
end

After do
ActiveRecord::Base.clear_active_connections!
ActiveRecord::Base.connection_handler.clear_active_connections!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

avoid DEPRECATION warning

end

Around '@security' do |scenario, block|
Expand Down
129 changes: 129 additions & 0 deletions test/integration/oracle_lob_large_update_test.rb
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